diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index 4ebf0e6..8a0059c 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -38,7 +38,7 @@ pub enum Action { // Catalog actions on the highlighted sidebar row AttachHighlighted, ToggleAttachHighlighted, - OpenSpawnModal, + NewDraftPane, KillHighlighted, // Tabs @@ -78,7 +78,7 @@ impl Action { "SidebarPrev" => return Ok(Action::SidebarPrev), "AttachHighlighted" => return Ok(Action::AttachHighlighted), "ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted), - "OpenSpawnModal" => return Ok(Action::OpenSpawnModal), + "NewDraftPane" => return Ok(Action::NewDraftPane), "KillHighlighted" => return Ok(Action::KillHighlighted), "NewTab" => return Ok(Action::NewTab), "CloseTab" => return Ok(Action::CloseTab), @@ -202,6 +202,7 @@ mod tests { for a in [ Action::EnterInsert, Action::Quit, + Action::NewDraftPane, Action::FocusPane(Direction::Right), Action::GotoTab(7), ] { @@ -209,4 +210,14 @@ mod tests { assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}"); } } + + /// The spawn modal is gone (issue #63); its old action string must not + /// resolve to anything. + #[test] + fn the_removed_spawn_modal_action_no_longer_parses() { + assert_eq!( + Action::parse("OpenSpawnModal").unwrap_err(), + ActionParseError::Unknown("OpenSpawnModal".into()) + ); + } } diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index aa9574d..aae1d75 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -6,9 +6,9 @@ //! changes, and a 30Hz heartbeat. //! //! Actions mutate `AppState` directly. Side effects that talk to the daemon -//! (`AttachHighlighted` spawning a `SessionTask`, `OpenSpawnModal` submit -//! firing a POST, `KillHighlighted` confirm firing a DELETE) live inline in -//! `dispatch` / `handle_overlay_input`. +//! (`AttachHighlighted` spawning a `SessionTask`, a draft-pane submit firing +//! a POST, `KillHighlighted` confirm firing a DELETE) live inline in +//! `dispatch` / the Insert-mode pane routing. use std::collections::{HashMap, HashSet}; use std::io::Stdout; @@ -24,15 +24,16 @@ use uuid::Uuid; use crate::action::Action; use crate::catalog::CatalogStore; use crate::config::TuiConfig; +use crate::draft::DraftInputResult; use crate::emulators::SessionEmulators; use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey}; use crate::keymap::Mode; -use crate::overlays::{HelpOverlay, SpawnModal}; +use crate::overlays::HelpOverlay; use crate::pane::{Pane, PaneStatus}; use crate::render::{draw, RegionMap, RenderInputs}; use crate::session_task::{SessionInput, SessionTask}; use crate::sidebar::Sidebar; -use crate::state::{AppState, Focus, OverlayKind}; +use crate::state::{AppState, Focus, OverlayKind, PaneRef}; use crate::tabs::TabBar; use crate::TuiError; @@ -49,7 +50,6 @@ pub struct App { catalog: CatalogStore, emulators: SessionEmulators, state: AppState, - spawn: SpawnModal, help: HelpOverlay, chord: ChordState, daemon: DaemonClient, @@ -90,7 +90,6 @@ impl App { catalog, emulators: SessionEmulators::default(), state: AppState::new(), - spawn: SpawnModal::default(), help, chord: ChordState::new(chord_timeout), daemon, @@ -152,7 +151,15 @@ impl App { self.perform(effects).await; } else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane) { - if let Some(effect) = self.forward_to_session(&key) { + // Insert mode over a focused pane: a draft eats the key + // into its form, a session gets the raw bytes. + let effect = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_))) + { + self.handle_draft_input(&key) + } else { + self.forward_to_session(&key) + }; + if let Some(effect) = effect { self.perform(vec![effect]).await; } } @@ -179,27 +186,7 @@ impl App { } fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec) { - use crate::overlays::SpawnInputResult; match self.state.overlay().cloned() { - Some(OverlayKind::Spawn) => { - let mut effects = Vec::new(); - match self.spawn.on_input(key) { - SpawnInputResult::Consumed => {} - SpawnInputResult::Cancel => { - self.state.dismiss_overlay(); - } - SpawnInputResult::Submit => { - effects.push(Effect::Spawn(CreateSessionRequest { - cwd: std::path::PathBuf::from(self.spawn.cwd.value().to_string()), - command: self.spawn.command.value().to_string(), - env: Default::default(), - metadata: Default::default(), - worktree: None, - })); - } - } - (OverlayDecision::Consumed, effects) - } Some(OverlayKind::KillConfirm(id)) => match key { InputKey::Char { c: 'y', .. } => { self.state.dismiss_overlay(); @@ -265,14 +252,7 @@ impl App { }; self.state.show_overlay(OverlayKind::Help); } - Action::OpenSpawnModal => { - self.spawn = SpawnModal { - cwd: Default::default(), - command: Default::default(), - focused_field: Default::default(), - }; - self.state.show_overlay(OverlayKind::Spawn); - } + Action::NewDraftPane => self.state.open_draft_pane(), Action::AttachHighlighted | Action::ToggleAttachHighlighted => { if let Some(sid) = self.pick_sidebar_target() { effects.push(Effect::Attach(sid)); @@ -322,44 +302,38 @@ impl App { /// Encode a keystroke for the focused pane. `None` when there is nothing /// attached to send it to. fn forward_to_session(&mut self, key: &InputKey) -> Option { - let sid = self.state.active_tab().pane?; + let sid = self.state.active_session()?; self.emulators.get(sid)?; - let bytes = match key { - InputKey::Char { c, ctrl, alt, .. } => { - let mut out = Vec::with_capacity(8); - if *alt { - out.push(0x1b); - } - if *ctrl && c.is_ascii_alphabetic() { - out.push((c.to_ascii_uppercase() as u8) - b'@'); - } else { - let mut b = [0u8; 4]; - let s = c.encode_utf8(&mut b); - out.extend_from_slice(s.as_bytes()); - } - out - } - InputKey::Enter => b"\r".to_vec(), - InputKey::Tab => b"\t".to_vec(), - InputKey::BackTab => b"\x1b[Z".to_vec(), - InputKey::Backspace => vec![0x7f], - InputKey::Esc => vec![0x1b], - InputKey::Up => b"\x1b[A".to_vec(), - InputKey::Down => b"\x1b[B".to_vec(), - InputKey::Right => b"\x1b[C".to_vec(), - InputKey::Left => b"\x1b[D".to_vec(), - InputKey::Home => b"\x1b[H".to_vec(), - InputKey::End => b"\x1b[F".to_vec(), - InputKey::Delete => b"\x1b[3~".to_vec(), - InputKey::Paste(_) | InputKey::Mouse { .. } => return None, - }; + let bytes = encode_key(key)?; Some(Effect::Forward { id: sid, bytes }) } + /// Feed one keystroke into the active tab's draft form. Returns the spawn + /// effect when the user accepted the form. + fn handle_draft_input(&mut self, key: &InputKey) -> Option { + let PaneRef::Draft(draft) = self.state.active_tab_mut().pane.as_mut()? else { + return None; + }; + if draft.on_input(key) != DraftInputResult::Submit { + return None; + } + Some(Effect::Spawn(CreateSessionRequest { + cwd: std::path::PathBuf::from(draft.cwd.value().to_string()), + command: draft.command.value().to_string(), + env: Default::default(), + metadata: Default::default(), + worktree: None, + })) + } + async fn submit_spawn(&mut self, req: CreateSessionRequest) { match self.daemon.create_session(&req).await { - Ok(_) => { - self.state.dismiss_overlay(); + Ok(session) => { + // Fill the pane in place with the new session (web parity, + // #57) and attach to it right away. + let sid = session.id; + self.state.complete_spawn(sid); + self.attach(sid); } Err(e) => self.state.toast(format!("spawn failed: {e}")), } @@ -377,7 +351,12 @@ impl App { terminal: &mut Terminal>, ) -> Result<(), TuiError> { let snap = self.catalog.snapshot(); - let attached: HashSet = self.state.tabs.iter().filter_map(|t| t.pane).collect(); + let attached: HashSet = self + .state + .tabs + .iter() + .filter_map(crate::state::Tab::session) + .collect(); let sidebar = Sidebar { sessions: snap.sessions.clone(), highlight: self.state.sidebar_highlight, @@ -387,7 +366,7 @@ impl App { tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(), active_idx: self.state.active_tab_idx, }; - let active_sid = self.state.active_tab().pane; + let active_sid = self.state.active_session(); let pane = active_sid.and_then(|sid| { self.emulators.get(sid).map(|h| Pane { screen: h.screen.clone(), @@ -395,13 +374,17 @@ impl App { focused: matches!(self.state.focus, Focus::Pane), }) }); + let draft = match self.state.active_tab().pane.as_ref() { + Some(PaneRef::Draft(d)) => Some(d), + _ => None, + }; let inputs = RenderInputs { sidebar: &sidebar, tab_bar: &tab_bar, pane: pane.as_ref(), state: &self.state, - spawn: &self.spawn, + draft, help: &self.help, }; @@ -449,6 +432,43 @@ fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec { snap.sessions.iter().map(|s| s.id).collect() } +/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys +/// with no byte representation (mouse events). A bracketed paste is forwarded +/// verbatim — the remote readline reassembles it, since the pane's PTY also +/// has bracketed paste enabled. +fn encode_key(key: &InputKey) -> Option> { + match key { + InputKey::Char { c, ctrl, alt, .. } => { + let mut out = Vec::with_capacity(8); + if *alt { + out.push(0x1b); + } + if *ctrl && c.is_ascii_alphabetic() { + out.push((c.to_ascii_uppercase() as u8) - b'@'); + } else { + let mut b = [0u8; 4]; + let s = c.encode_utf8(&mut b); + out.extend_from_slice(s.as_bytes()); + } + Some(out) + } + InputKey::Enter => Some(b"\r".to_vec()), + InputKey::Tab => Some(b"\t".to_vec()), + InputKey::BackTab => Some(b"\x1b[Z".to_vec()), + InputKey::Backspace => Some(vec![0x7f]), + InputKey::Esc => Some(vec![0x1b]), + InputKey::Up => Some(b"\x1b[A".to_vec()), + InputKey::Down => Some(b"\x1b[B".to_vec()), + InputKey::Right => Some(b"\x1b[C".to_vec()), + InputKey::Left => Some(b"\x1b[D".to_vec()), + InputKey::Home => Some(b"\x1b[H".to_vec()), + InputKey::End => Some(b"\x1b[F".to_vec()), + InputKey::Delete => Some(b"\x1b[3~".to_vec()), + InputKey::Paste(text) => Some(text.as_bytes().to_vec()), + InputKey::Mouse { .. } => None, + } +} + /// Build the lines shown by the help overlay from the live keymap, so the /// user always sees the bindings that are actually active (defaults + their /// own `[tui.keymap.*]` overrides). @@ -516,10 +536,33 @@ mod tests { } #[test] - fn open_spawn_modal_sets_overlay() { + fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() { let mut app = make_app(); - app.dispatch(Action::OpenSpawnModal); - assert!(matches!(app.state.overlay(), Some(OverlayKind::Spawn))); + app.dispatch(Action::NewDraftPane); + assert!(matches!( + app.state.active_tab().pane, + Some(PaneRef::Draft(_)) + )); + assert_eq!(app.state.focus, Focus::Pane); + assert_eq!(app.state.mode, Mode::Insert); + assert!(app.state.overlay().is_none(), "no overlay involved"); + } + + /// A tab already showing a session gets a new tab for the draft. + #[test] + fn new_draft_pane_over_a_session_opens_a_new_tab() { + let sid = Uuid::from_u128(10); + let mut app = app_with_highlighted_session(sid); + app.dispatch(Action::AttachHighlighted); + + app.dispatch(Action::NewDraftPane); + assert_eq!(app.state.tabs.len(), 2); + assert_eq!(app.state.active_tab_idx, 1); + assert!(matches!( + app.state.active_tab().pane, + Some(PaneRef::Draft(_)) + )); + assert_eq!(app.state.tabs[0].session(), Some(sid)); } /// Seed the catalog with one session and highlight it. @@ -554,7 +597,7 @@ mod tests { app.dispatch(Action::AttachHighlighted), vec![Effect::Attach(sid)] ); - assert_eq!(app.state.active_tab().pane, Some(sid)); + assert_eq!(app.state.active_tab().session(), Some(sid)); } #[test] @@ -587,23 +630,35 @@ mod tests { } #[test] - fn submitting_the_spawn_modal_carries_the_typed_fields() { + fn typing_into_the_draft_then_enter_carries_the_typed_fields() { let mut app = make_app(); - app.dispatch(Action::OpenSpawnModal); - app.spawn.cwd = crate::text_input::TextInput::with_value("/srv/work"); - app.spawn.command = crate::text_input::TextInput::with_value("claude"); - app.spawn.focused_field = crate::overlays::SpawnField::Submit; + app.dispatch(Action::NewDraftPane); - let (_, effects) = app.handle_overlay_input(&InputKey::Enter); - match effects.as_slice() { - [Effect::Spawn(req)] => { + for c in "/srv/work".chars() { + app.handle_draft_input(&InputKey::char(c)); + } + app.handle_draft_input(&InputKey::Tab); // cwd → command + for c in "claude".chars() { + app.handle_draft_input(&InputKey::char(c)); + } + app.handle_draft_input(&InputKey::Tab); // command → submit + + match app.handle_draft_input(&InputKey::Enter) { + Some(Effect::Spawn(req)) => { assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work")); assert_eq!(req.command, "claude"); } - other => panic!("expected one Spawn effect, got {other:?}"), + other => panic!("expected a Spawn effect, got {other:?}"), } } + /// Keys fed while no draft exists must be ignored, not panic. + #[test] + fn draft_input_is_a_no_op_without_a_draft() { + let mut app = make_app(); + assert!(app.handle_draft_input(&InputKey::char('x')).is_none()); + } + /// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a /// failure message stayed on screen until another overlay replaced it. #[test] @@ -632,6 +687,37 @@ mod tests { assert!(app.forward_to_session(&InputKey::Enter).is_none()); } + /// A draft pane is not a session: forwarding must not try to reach it. + #[test] + fn forwarding_is_a_no_op_with_a_draft_pane() { + let mut app = make_app(); + app.dispatch(Action::NewDraftPane); + assert!(app.forward_to_session(&InputKey::Enter).is_none()); + } + + /// Regression review fix: `Paste(_)` used to be silently dropped, so a + /// paste into an attached session vanished once bracketed paste was + /// enabled — the payload must be forwarded verbatim. + #[test] + fn a_bracketed_paste_is_forwarded_verbatim_to_the_session() { + assert_eq!( + encode_key(&InputKey::Paste("cd /tmp/foo".into())), + Some(b"cd /tmp/foo".to_vec()) + ); + } + + #[test] + fn mouse_events_have_no_wire_encoding() { + use crate::input::{MouseKind, Region}; + assert!(encode_key(&InputKey::Mouse { + kind: MouseKind::Click, + region: Region::Pane, + col: 3, + row: 4, + }) + .is_none()); + } + #[test] fn kill_highlighted_opens_confirm() { use chrono::Utc; diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index e4e7c9f..9a727c2 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -45,7 +45,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("up", "SidebarPrev"), // Catalog actions ("enter", "AttachHighlighted"), - ("s", "OpenSpawnModal"), + ("s", "NewDraftPane"), ("x", "KillHighlighted"), // Tabs ("ctrl-t", "NewTab"), @@ -155,6 +155,10 @@ mod tests { cfg.keymap.resolve(Mode::Normal, "g t"), Some(Action::NextTab), ); + assert_eq!( + cfg.keymap.resolve(Mode::Normal, "s"), + Some(Action::NewDraftPane), + ); } #[test] diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs new file mode 100644 index 0000000..93fe613 --- /dev/null +++ b/crates/maestro-tui/src/draft.rs @@ -0,0 +1,272 @@ +//! Draft pane — the spawn form living in an ordinary pane (issue #63). +//! +//! An empty pane renders a [`DraftPane`] instead of nothing; submitting hands +//! the caller a built request and the pane is replaced with the new session, +//! matching the web client's inline draft panes (#57). Field editing goes +//! through [`crate::text_input::TextInput`]; Tab / BackTab walk the fields. +//! +//! Scope note: presets, the project list, generated names, and the worktree +//! section land with issues #64/#65 — this is cwd + command only. + +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::input::InputKey; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum DraftField { + #[default] + Cwd, + Command, + Submit, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct DraftPane { + pub cwd: crate::text_input::TextInput, + pub command: crate::text_input::TextInput, + pub focused_field: DraftField, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DraftInputResult { + Consumed, + Submit, +} + +impl DraftPane { + pub fn next_field(&mut self) { + self.focused_field = match self.focused_field { + DraftField::Cwd => DraftField::Command, + DraftField::Command => DraftField::Submit, + DraftField::Submit => DraftField::Cwd, + }; + } + + pub fn prev_field(&mut self) { + self.focused_field = match self.focused_field { + DraftField::Cwd => DraftField::Submit, + DraftField::Command => DraftField::Cwd, + DraftField::Submit => DraftField::Command, + }; + } + + /// Handle one keystroke while this draft owns input (Insert mode, pane + /// focused). Returns [`DraftInputResult::Submit`] when the user accepts; + /// the caller fires the POST and replaces the draft with the session. + pub fn on_input(&mut self, key: &InputKey) -> DraftInputResult { + match key { + InputKey::Tab => { + self.next_field(); + DraftInputResult::Consumed + } + InputKey::BackTab => { + self.prev_field(); + DraftInputResult::Consumed + } + InputKey::Enter => match self.focused_field { + DraftField::Submit => DraftInputResult::Submit, + _ => { + self.next_field(); + DraftInputResult::Consumed + } + }, + // Everything else — characters, motion, editing, paste — goes to + // the focused field's text input. + other => { + match self.focused_field { + DraftField::Cwd => self.cwd.handle_key(other), + DraftField::Command => self.command.handle_key(other), + DraftField::Submit => {} + } + DraftInputResult::Consumed + } + } + } + + /// Draw into the pane area. `pane_focused` gates the field highlight and + /// cursor placement the same way a session pane hides its cursor when + /// unfocused. + pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) { + let block = Block::default().title(" draft ").borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + let rows = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(2), + Constraint::Length(1), + ]) + .split(inner); + + let style_for = |field: DraftField| { + if pane_focused && self.focused_field == field { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + } + }; + + // Each text field renders through the shared `TextInput`, which + // handles horizontal scrolling and reports where the terminal cursor + // belongs. Label is fixed-width 8 chars + 1 space = column 9. + let label = |text: &str| Paragraph::new(Line::from(Span::raw(format!("{text:8} ")))); + f.render_widget(label("cwd:"), rows[0]); + f.render_widget(label("command:"), rows[1]); + let draw_field = + |f: &mut Frame, input: &crate::text_input::TextInput, row: Rect, style: Style| -> u16 { + let area = Rect { + x: row.x.saturating_add(9), + y: row.y, + width: row.width.saturating_sub(9), + height: 1, + }; + input.render(area, f.buffer_mut(), style) + }; + let cwd_col = draw_field(f, &self.cwd, rows[0], style_for(DraftField::Cwd)); + let cmd_col = draw_field(f, &self.command, rows[1], style_for(DraftField::Command)); + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "[ Spawn ]", + style_for(DraftField::Submit), + )])), + rows[2], + ); + + if pane_focused { + match self.focused_field { + DraftField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)), + DraftField::Command => f.set_cursor_position((cmd_col, rows[1].y)), + DraftField::Submit => { + // The submit button is indicated by the REVERSED style; no + // cursor placement. + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn render_to_string(w: u16, h: u16, draw: impl FnOnce(&mut Frame)) -> String { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(draw).unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn draft_pane_renders_field_labels_and_values() { + let draft = DraftPane { + cwd: crate::text_input::TextInput::with_value("/x"), + command: crate::text_input::TextInput::with_value("bash"), + focused_field: DraftField::Cwd, + }; + let rendered = render_to_string(80, 20, |f| draft.render(f, f.area(), true)); + for s in ["cwd", "command", "draft", "Spawn", "/x", "bash"] { + assert!(rendered.contains(s), "missing {s:?}:\n{rendered}"); + } + } + + #[test] + fn field_rotation_wraps_in_both_directions() { + let mut draft = DraftPane::default(); + assert_eq!(draft.focused_field, DraftField::Cwd); + draft.next_field(); + assert_eq!(draft.focused_field, DraftField::Command); + draft.next_field(); + assert_eq!(draft.focused_field, DraftField::Submit); + draft.next_field(); + assert_eq!(draft.focused_field, DraftField::Cwd); + draft.prev_field(); + assert_eq!(draft.focused_field, DraftField::Submit); + + draft.on_input(&InputKey::BackTab); + assert_eq!(draft.focused_field, DraftField::Command); + draft.on_input(&InputKey::Tab); + draft.on_input(&InputKey::Tab); + assert_eq!(draft.focused_field, DraftField::Cwd); + } + + /// Editing keys must reach the focused field's text input, not a bare + /// String — this is what gives the operator cursor motion and word ops. + #[test] + fn editing_keys_reach_the_focused_text_input() { + let mut draft = DraftPane::default(); + assert_eq!(draft.focused_field, DraftField::Cwd); + for k in [ + InputKey::char('/'), + InputKey::char('t'), + InputKey::char('m'), + InputKey::char('p'), + ] { + draft.on_input(&k); + } + draft.on_input(&InputKey::Left); + draft.on_input(&InputKey::Backspace); + assert_eq!(draft.cwd.value(), "/tp"); + + // Tab to command, then a paste and ctrl-w land there, not in cwd. + draft.on_input(&InputKey::Tab); + assert_eq!(draft.focused_field, DraftField::Command); + draft.on_input(&InputKey::Paste("/tmp/foo".into())); + assert_eq!(draft.command.value(), "/tmp/foo"); + draft.on_input(&InputKey::ctrl_char('w')); + assert_eq!(draft.command.value(), ""); + assert_eq!(draft.cwd.value(), "/tp", "cwd untouched while unfocused"); + } + + /// Enter walks forward until the submit field, where it submits. + #[test] + fn enter_walks_fields_then_submits_from_submit() { + let mut draft = DraftPane::default(); + assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed); + assert_eq!(draft.focused_field, DraftField::Command); + assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed); + assert_eq!(draft.focused_field, DraftField::Submit); + assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit); + } + + /// The terminal cursor must sit right after the typed text in the focused + /// field (label is 8 chars + 1 space, block border takes column 0). + #[test] + fn render_places_the_cursor_after_the_typed_text_when_focused() { + let draft = DraftPane { + cwd: crate::text_input::TextInput::with_value("abc"), + ..DraftPane::default() + }; + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| draft.render(f, f.area(), true)).unwrap(); + let pos = terminal.get_cursor_position().unwrap(); + assert_eq!(pos, ratatui::layout::Position::new(1 + 9 + 3, 1)); + + // Unfocused pane: no field highlight either. + let rendered_unfocused = render_to_string(80, 20, |f| draft.render(f, f.area(), false)); + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| draft.render(f, f.area(), false)).unwrap(); + let buf = terminal.backend().buffer(); + assert!( + !buf.content() + .iter() + .any(|c| c.modifier.contains(ratatui::style::Modifier::REVERSED)), + "unfocused draft must not highlight fields:\n{rendered_unfocused}" + ); + } +} diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index b76bd84..5d805bb 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -7,6 +7,7 @@ pub mod app; pub mod catalog; pub mod cli; pub mod config; +pub mod draft; pub mod emulators; pub mod input; pub mod keymap; diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index 5bb49d2..a046224 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -1,161 +1,16 @@ -//! Overlay widgets: spawn modal, kill-confirm prompt, help overlay, -//! transient toast. +//! Overlay widgets: kill-confirm prompt, help overlay, transient toast. +//! +//! The spawn form is no longer an overlay (issue #63) — it lives in draft +//! panes ([`crate::draft::DraftPane`]). //! //! All overlays are centered popups drawn over the rest of the UI; the //! `Clear` widget wipes the underlying buffer before drawing. -use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Modifier, Style}; +use ratatui::layout::Rect; use ratatui::text::Line; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph}; use ratatui::Frame; -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub enum SpawnField { - #[default] - Cwd, - Command, - Submit, -} - -#[derive(Debug, Default, Clone)] -pub struct SpawnModal { - pub cwd: crate::text_input::TextInput, - pub command: crate::text_input::TextInput, - pub focused_field: SpawnField, -} - -impl SpawnModal { - pub fn next_field(&mut self) { - self.focused_field = match self.focused_field { - SpawnField::Cwd => SpawnField::Command, - SpawnField::Command => SpawnField::Submit, - SpawnField::Submit => SpawnField::Cwd, - }; - } - - pub fn prev_field(&mut self) { - self.focused_field = match self.focused_field { - SpawnField::Cwd => SpawnField::Submit, - SpawnField::Command => SpawnField::Cwd, - SpawnField::Submit => SpawnField::Command, - }; - } - - /// Handle one keystroke while this modal owns input. Returns - /// [`SpawnInputResult::Submit`] when the user accepts; the caller fires - /// the POST and dismisses the modal. - pub fn on_input(&mut self, key: &crate::input::InputKey) -> SpawnInputResult { - use crate::input::InputKey; - match key { - InputKey::Tab => { - self.next_field(); - SpawnInputResult::Consumed - } - InputKey::BackTab => { - self.prev_field(); - SpawnInputResult::Consumed - } - InputKey::Esc => SpawnInputResult::Cancel, - InputKey::Enter => match self.focused_field { - SpawnField::Submit => SpawnInputResult::Submit, - _ => { - self.next_field(); - SpawnInputResult::Consumed - } - }, - // Everything else — characters, motion, editing, paste — goes to - // the focused field's text input. - other => { - match self.focused_field { - SpawnField::Cwd => self.cwd.handle_key(other), - SpawnField::Command => self.command.handle_key(other), - SpawnField::Submit => {} - } - SpawnInputResult::Consumed - } - } - } - - pub fn render(&self, f: &mut Frame, parent: Rect) { - let area = centered(parent, 60, 10); - f.render_widget(Clear, area); - let block = Block::default().title(" spawn ").borders(Borders::ALL); - let inner = block.inner(area); - f.render_widget(block, area); - let rows = Layout::vertical([ - Constraint::Length(2), - Constraint::Length(2), - Constraint::Length(1), - ]) - .split(inner); - let highlight = |field: SpawnField| { - if self.focused_field == field { - Style::default().add_modifier(Modifier::REVERSED) - } else { - Style::default() - } - }; - - // Each text field renders through the shared `TextInput`, which - // handles horizontal scrolling and reports where the terminal cursor - // belongs. Label is fixed-width 8 chars + 1 space = column 9. - let label = - |text: &str| Paragraph::new(Line::from(ratatui::text::Span::raw(format!("{text:8} ")))); - f.render_widget(label("cwd:"), rows[0]); - f.render_widget(label("command:"), rows[1]); - let draw_field = |f: &mut Frame, - input: &crate::text_input::TextInput, - row: Rect, - focused: bool| - -> u16 { - let style = if focused { - Style::default().add_modifier(Modifier::REVERSED) - } else { - Style::default() - }; - let area = Rect { - x: row.x.saturating_add(9), - y: row.y, - width: row.width.saturating_sub(9), - height: 1, - }; - input.render(area, f.buffer_mut(), style) - }; - let cwd_col = draw_field(f, &self.cwd, rows[0], self.focused_field == SpawnField::Cwd); - let cmd_col = draw_field( - f, - &self.command, - rows[1], - self.focused_field == SpawnField::Command, - ); - - f.render_widget( - Paragraph::new(Line::from(vec![ratatui::text::Span::styled( - "[ Spawn ]", - highlight(SpawnField::Submit), - )])), - rows[2], - ); - - match self.focused_field { - SpawnField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)), - SpawnField::Command => f.set_cursor_position((cmd_col, rows[1].y)), - SpawnField::Submit => { - // The submit button is indicated by the REVERSED style; no - // cursor placement. - } - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SpawnInputResult { - Consumed, - Cancel, - Submit, -} - #[derive(Debug, Clone)] pub struct KillConfirm { pub label: String, @@ -262,33 +117,6 @@ mod tests { out } - #[test] - fn spawn_modal_renders_field_labels() { - let modal = SpawnModal { - cwd: crate::text_input::TextInput::with_value("/x"), - command: crate::text_input::TextInput::with_value("bash"), - focused_field: SpawnField::Cwd, - }; - let rendered = render_to_string(80, 20, |f| modal.render(f, f.area())); - for s in ["cwd", "command", "Spawn", "/x", "bash"] { - assert!(rendered.contains(s), "missing {s:?}:\n{rendered}"); - } - } - - #[test] - fn spawn_modal_field_rotation() { - let mut modal = SpawnModal::default(); - assert_eq!(modal.focused_field, SpawnField::Cwd); - modal.next_field(); - assert_eq!(modal.focused_field, SpawnField::Command); - modal.next_field(); - assert_eq!(modal.focused_field, SpawnField::Submit); - modal.next_field(); - assert_eq!(modal.focused_field, SpawnField::Cwd); - modal.prev_field(); - assert_eq!(modal.focused_field, SpawnField::Submit); - } - #[test] fn kill_confirm_renders_prompt() { let modal = KillConfirm { @@ -306,81 +134,11 @@ mod tests { let help = HelpOverlay { lines: vec![ "i EnterInsert".into(), - "s OpenSpawnModal".into(), + "s NewDraftPane".into(), ], }; let rendered = render_to_string(80, 12, |f| help.render(f, f.area())); assert!(rendered.contains("EnterInsert")); - assert!(rendered.contains("OpenSpawnModal")); - } - /// Shift-Tab walked nowhere: `prev_field` existed with no caller, so the - /// spawn modal could only be cycled forwards. - #[test] - fn shift_tab_walks_the_spawn_modal_backwards() { - let mut modal = SpawnModal::default(); - assert_eq!(modal.focused_field, SpawnField::Cwd); - - modal.on_input(&crate::input::InputKey::BackTab); - assert_eq!(modal.focused_field, SpawnField::Submit); - - modal.on_input(&crate::input::InputKey::BackTab); - assert_eq!(modal.focused_field, SpawnField::Command); - } - - #[test] - fn tab_and_shift_tab_are_inverses() { - let mut modal = SpawnModal::default(); - for _ in 0..3 { - modal.on_input(&crate::input::InputKey::Tab); - modal.on_input(&crate::input::InputKey::BackTab); - assert_eq!(modal.focused_field, SpawnField::Cwd); - } - } - - /// Editing keys must reach the focused field's text input, not a bare - /// String — this is what gives the operator cursor motion and word ops. - #[test] - fn editing_keys_reach_the_focused_text_input() { - let mut modal = SpawnModal::default(); - assert_eq!(modal.focused_field, SpawnField::Cwd); - for k in [ - crate::input::InputKey::char('/'), - crate::input::InputKey::char('t'), - crate::input::InputKey::char('m'), - crate::input::InputKey::char('p'), - ] { - modal.on_input(&k); - } - modal.on_input(&crate::input::InputKey::Left); - modal.on_input(&crate::input::InputKey::Backspace); - assert_eq!(modal.cwd.value(), "/tp"); - - // Tab to command, then a paste and ctrl-w land there, not in cwd. - modal.on_input(&crate::input::InputKey::Tab); - assert_eq!(modal.focused_field, SpawnField::Command); - modal.on_input(&crate::input::InputKey::Paste("/tmp/foo".into())); - assert_eq!(modal.command.value(), "/tmp/foo"); - modal.on_input(&crate::input::InputKey::ctrl_char('w')); - assert_eq!(modal.command.value(), ""); - assert_eq!(modal.cwd.value(), "/tp", "cwd untouched while unfocused"); - } - - /// The terminal cursor must sit right after the typed text in the focused - /// field (label is 8 chars + 1 space, modal is centered on screen). - #[test] - fn render_places_the_cursor_after_the_typed_text() { - use ratatui::backend::TestBackend; - use ratatui::Terminal; - - let modal = SpawnModal { - cwd: crate::text_input::TextInput::with_value("abc"), - ..SpawnModal::default() - }; - let backend = TestBackend::new(80, 20); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|f| modal.render(f, f.area())).unwrap(); - // Modal: x = (80-60)/2 = 10; block border → inner.x = 11; label col 9. - let pos = terminal.get_cursor_position().unwrap(); - assert_eq!(pos, ratatui::layout::Position::new(11 + 9 + 3, 6)); + assert!(rendered.contains("NewDraftPane")); } } diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 0bb2c75..8908d93 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -10,7 +10,7 @@ use ratatui::Frame; use crate::input::Region; use crate::keymap::Mode; -use crate::overlays::{HelpOverlay, KillConfirm, SpawnModal, Toast}; +use crate::overlays::{HelpOverlay, KillConfirm, Toast}; use crate::pane::Pane; use crate::sidebar::Sidebar; use crate::state::{AppState, Focus, OverlayKind}; @@ -63,8 +63,9 @@ pub struct RenderInputs<'a> { pub sidebar: &'a Sidebar, pub tab_bar: &'a TabBar, pub pane: Option<&'a Pane>, + /// Inline spawn form when the active tab's pane is a draft (issue #63). + pub draft: Option<&'a crate::draft::DraftPane>, pub state: &'a AppState, - pub spawn: &'a SpawnModal, pub help: &'a HelpOverlay, } @@ -131,6 +132,9 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { if let Some(p) = inputs.pane { p.render(f, pane_area); } + if let Some(d) = inputs.draft { + d.render(f, pane_area, pane_focused); + } map.pane = Some(pane_area); // Status bar — always visible, never eclipsed by overlays so the user can @@ -138,10 +142,6 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { render_status_bar(f, status_area, inputs.state); match inputs.state.overlay() { - Some(OverlayKind::Spawn) => { - inputs.spawn.render(f, area); - map.modal = Some(area); - } Some(OverlayKind::KillConfirm(id)) => { let kc = KillConfirm { label: id.to_string(), @@ -186,7 +186,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) { Focus::Pane => "pane", }; let hint = match state.mode { - Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit", + Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit", Mode::Insert => "^a: back to normal", }; let mode_style = match state.mode { @@ -266,4 +266,57 @@ mod tests { assert_eq!(map.region_at(15, 10), Region::Modal); assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal } + + /// Issue #63: an empty pane is a draft — the frame must show the spawn + /// form where the terminal would otherwise be. + #[test] + fn a_draft_pane_renders_the_spawn_form_in_its_pane_area() { + use crate::overlays::HelpOverlay; + use crate::state::PaneRef; + use std::collections::HashSet; + + let mut state = AppState::new(); + state.open_draft_pane(); + let draft = match &state.tabs[0].pane { + Some(PaneRef::Draft(d)) => d, + other => panic!("expected a draft pane, got {other:?}"), + }; + let sidebar = Sidebar { + sessions: Vec::new(), + highlight: None, + attached: HashSet::new(), + }; + let tab_bar = TabBar { + tab_names: vec!["1".into()], + active_idx: 0, + }; + let help = HelpOverlay { lines: Vec::new() }; + let inputs = RenderInputs { + sidebar: &sidebar, + tab_bar: &tab_bar, + pane: None, + draft: Some(draft), + state: &state, + help: &help, + }; + + let backend = ratatui::backend::TestBackend::new(80, 24); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + draw(f, inputs); + }) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + for s in ["draft", "cwd:", "command:", "[ Spawn ]"] { + assert!(out.contains(s), "missing {s:?}:\n{out}"); + } + } } diff --git a/crates/maestro-tui/src/state.rs b/crates/maestro-tui/src/state.rs index 4e9e32f..0bdfb44 100644 --- a/crates/maestro-tui/src/state.rs +++ b/crates/maestro-tui/src/state.rs @@ -1,14 +1,16 @@ //! `AppState` — the top-level mutable state of the running TUI. //! //! v1 keeps it small on purpose: a list of tabs (each bound to at most one -//! session), an active tab index, focus split between sidebar and pane, the -//! current input mode, and an optional overlay (spawn modal, kill confirm, -//! help, transient toast). +//! session or a draft form), an active tab index, focus split between sidebar +//! and pane, the current input mode, and an optional overlay (kill confirm, +//! help, transient toast). The spawn form is no longer an overlay — an empty +//! pane is a draft pane (issue #63). //! -//! v1.5 will replace `Tab::pane: Option` with a split tree. +//! v1.5 will replace `Tab::pane`'s single slot with a split tree. use uuid::Uuid; +use crate::draft::DraftPane; use crate::keymap::Mode; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -17,15 +19,31 @@ pub enum Focus { Pane, } +/// What a tab's pane slot holds: a live session, or an inline draft form. +#[derive(Debug, Clone, PartialEq)] +pub enum PaneRef { + Session(Uuid), + Draft(DraftPane), +} + #[derive(Debug, Clone)] pub struct Tab { pub name: String, - pub pane: Option, + pub pane: Option, +} + +impl Tab { + /// The session this tab shows, if it shows one. Draft panes have none. + pub fn session(&self) -> Option { + match &self.pane { + Some(PaneRef::Session(sid)) => Some(*sid), + _ => None, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum OverlayKind { - Spawn, KillConfirm(Uuid), Help, Toast(String), @@ -108,7 +126,8 @@ impl AppState { /// /// Returns the session that tab was showing, if any, so the caller can /// release its emulator. Without that the tab's WebSocket task and vt100 - /// parser outlive the tab forever. + /// parser outlive the tab forever. A draft pane holds no session and + /// releases nothing. pub fn close_tab(&mut self) -> Option { if self.tabs.len() <= 1 { return None; @@ -117,9 +136,10 @@ impl AppState { if self.active_tab_idx >= self.tabs.len() { self.active_tab_idx = self.tabs.len() - 1; } + let sid = closed.session()?; // Only release it if no other tab still shows the same session. - let still_shown = self.tabs.iter().any(|t| t.pane == closed.pane); - closed.pane.filter(|_| !still_shown) + let still_shown = self.tabs.iter().any(|t| t.session() == Some(sid)); + (!still_shown).then_some(sid) } pub fn next_tab(&mut self) { @@ -147,11 +167,37 @@ impl AppState { /// drop straight into Insert mode so keystrokes go to the session /// without an extra `i` press. The leader returns to Normal. pub fn attach_to_focused(&mut self, session_id: Uuid) { - self.active_tab_mut().pane = Some(session_id); + self.active_tab_mut().pane = Some(PaneRef::Session(session_id)); self.focus = Focus::Pane; self.mode = Mode::Insert; } + /// Open a draft form in a pane (issue #63): reuse the empty active pane, + /// otherwise open a fresh tab for it — v1 has no split tree, so "split" + /// means a new tab. Focus lands in the pane, Insert mode on, so typing + /// starts immediately, same flow as [`Self::attach_to_focused`]. + pub fn open_draft_pane(&mut self) { + if self.active_tab().pane.is_some() { + self.new_tab(); + } + self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default())); + self.focus = Focus::Pane; + self.mode = Mode::Insert; + } + + /// A draft submit succeeded: replace whatever the active tab showed with + /// the new session in place, matching the web client's behaviour (#57). + pub fn complete_spawn(&mut self, sid: Uuid) { + self.active_tab_mut().pane = Some(PaneRef::Session(sid)); + self.focus = Focus::Pane; + self.mode = Mode::Insert; + } + + /// The session shown by the active tab's pane, if any. + pub fn active_session(&self) -> Option { + self.active_tab().session() + } + /// Move the sidebar highlight by `delta` (positive = down) within the /// given ordered list of selectable session ids. Wraps. No-op on empty. pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) { @@ -288,7 +334,7 @@ mod tests { let mut state = AppState::new(); let sid = Uuid::from_u128(7); state.attach_to_focused(sid); - assert_eq!(state.tabs[0].pane, Some(sid)); + assert_eq!(state.tabs[0].session(), Some(sid)); assert_eq!(state.focus, Focus::Pane); // The user expects to type into the session right away; the leader // (default ctrl-a) is the way back. @@ -314,7 +360,7 @@ mod tests { let mut state = AppState::new(); let sid = Uuid::from_u128(3); state.new_tab(); - state.active_tab_mut().pane = Some(sid); + state.active_tab_mut().pane = Some(PaneRef::Session(sid)); assert_eq!(state.close_tab(), Some(sid)); } @@ -324,9 +370,9 @@ mod tests { fn close_tab_keeps_a_session_another_tab_still_shows() { let mut state = AppState::new(); let sid = Uuid::from_u128(4); - state.active_tab_mut().pane = Some(sid); + state.active_tab_mut().pane = Some(PaneRef::Session(sid)); state.new_tab(); - state.active_tab_mut().pane = Some(sid); + state.active_tab_mut().pane = Some(PaneRef::Session(sid)); assert_eq!(state.close_tab(), None); } @@ -334,7 +380,7 @@ mod tests { #[test] fn close_tab_is_a_no_op_on_the_last_tab() { let mut state = AppState::new(); - state.active_tab_mut().pane = Some(Uuid::from_u128(5)); + state.active_tab_mut().pane = Some(PaneRef::Session(Uuid::from_u128(5))); assert_eq!(state.close_tab(), None); assert_eq!(state.tabs.len(), 1); } @@ -347,4 +393,52 @@ mod tests { assert!(state.dismiss_overlay()); assert!(state.overlay().is_none()); } + + /// Issue #63: `s` over an empty pane turns that pane into a draft — + /// focused, Insert mode on, typing starts immediately. + #[test] + fn open_draft_reuses_the_empty_active_pane() { + let mut state = AppState::new(); + state.open_draft_pane(); + assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_)))); + assert_eq!(state.focus, Focus::Pane); + assert_eq!(state.mode, Mode::Insert); + assert_eq!(state.tabs.len(), 1); + } + + /// A tab already showing a session gets a fresh tab for the draft; v1 + /// has no split tree, so a new tab is the "split". + #[test] + fn open_draft_over_a_session_opens_a_new_tab() { + let mut state = AppState::new(); + state.attach_to_focused(Uuid::from_u128(9)); + state.open_draft_pane(); + assert_eq!(state.tabs.len(), 2); + assert_eq!(state.active_tab_idx, 1); + assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_)))); + // The session tab is untouched. + assert_eq!(state.tabs[0].session(), Some(Uuid::from_u128(9))); + } + + /// Web parity (#57): submitting fills the pane in place. + #[test] + fn complete_spawn_replaces_the_draft_with_the_session() { + let mut state = AppState::new(); + state.open_draft_pane(); + let sid = Uuid::from_u128(21); + state.complete_spawn(sid); + assert_eq!(state.active_tab().session(), Some(sid)); + assert_eq!(state.focus, Focus::Pane); + assert_eq!(state.mode, Mode::Insert); + } + + /// A closed draft tab holds no session, so nothing is released. + #[test] + fn close_tab_reports_nothing_for_a_draft_pane() { + let mut state = AppState::new(); + state.open_draft_pane(); + state.new_tab(); + state.active_tab_idx = 0; + assert_eq!(state.close_tab(), None); + } } diff --git a/crates/maestro-tui/src/text_input.rs b/crates/maestro-tui/src/text_input.rs index 55a3a4a..c3e1b44 100644 --- a/crates/maestro-tui/src/text_input.rs +++ b/crates/maestro-tui/src/text_input.rs @@ -14,7 +14,7 @@ use ratatui::style::Style; use crate::input::InputKey; -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct TextInput { value: String, /// Cursor position as a char offset into `value`.