From f89be4a716c719b79fd8e5203093ce0a83ad734a Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 2 Sep 2026 14:52:36 +0100 Subject: [PATCH] feat(maestro): spawn form back in a modal in the TUI `s` (OpenSpawnModal, was NewDraftPane) puts the form up as a centered overlay instead of taking over the pane. The attached session stays attached behind it and comes back untouched on Escape; the modal owns every key while it is up, so `x` and `j` typed into a field no longer reach the keymap. Tab::pane is a session id again and PaneRef is gone. The form lives on App next to the rename buffer, so its loads no longer address a tab index that could not identify them. Co-Authored-By: Claude Opus 5 (1M context) --- crates/maestro-daemon/src/local_api.rs | 2 +- crates/maestro-daemon/src/projects.rs | 2 +- crates/maestro-tui/src/action.rs | 25 +- crates/maestro-tui/src/app.rs | 368 +++++++++++-------------- crates/maestro-tui/src/config.rs | 6 +- crates/maestro-tui/src/draft.rs | 10 +- crates/maestro-tui/src/fast_forward.rs | 6 +- crates/maestro-tui/src/overlays.rs | 42 ++- crates/maestro-tui/src/projects.rs | 2 +- crates/maestro-tui/src/render.rs | 45 +-- crates/maestro-tui/src/state.rs | 88 +++--- docs/INSTALL.md | 2 +- 12 files changed, 285 insertions(+), 313 deletions(-) diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 82bfc91..4071925 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -141,7 +141,7 @@ struct NamesQuery { } /// Fresh session handles for the spawn form's instant reroll (#53). The web -/// client fetches one batch per draft pane and rerolls locally from it. +/// client fetches one batch per spawn form and rerolls locally from it. async fn names(Query(q): Query) -> Json { let count = q.count.unwrap_or(1).clamp(1, maestro_names::MAX_BATCH); Json(NameBatch { diff --git a/crates/maestro-daemon/src/projects.rs b/crates/maestro-daemon/src/projects.rs index 78db18f..38176b3 100644 --- a/crates/maestro-daemon/src/projects.rs +++ b/crates/maestro-daemon/src/projects.rs @@ -32,7 +32,7 @@ pub const DEFAULT_HOME_IGNORE: &[&str] = /// triggers a rescan. /// /// A scan walks the roots and runs one `git worktree list` per hit, holding -/// the scanner lock throughout, and clients re-request whenever a draft pane +/// the scanner lock throughout, and clients re-request whenever a spawn form /// re-mounts — which `multiview.render()` does on unrelated events. Keep this /// comfortably longer than [`REFRESH_INTERVAL`] so the background timer is /// what refreshes, and a request only rescans when that timer has failed to. diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index 85bcf6b..d821a6d 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -43,9 +43,9 @@ pub enum Action { // Catalog actions on the highlighted sidebar row AttachHighlighted, ToggleAttachHighlighted, - NewDraftPane, + OpenSpawnModal, /// Fast-forward spawn (#67): spawn immediately with every default - /// resolved; unresolvable defaults open a pre-filled draft instead. + /// resolved; unresolvable defaults open a pre-filled form instead. FastForwardSpawn, KillHighlighted, /// Rename the highlighted session (#125): opens a prompt pre-filled with @@ -90,7 +90,7 @@ impl Action { Action::ToggleSidebarGroup => "Expand or collapse directory".into(), Action::AttachHighlighted => "Open selected item".into(), Action::ToggleAttachHighlighted => "Open selected item".into(), - Action::NewDraftPane => "Create a session".into(), + Action::OpenSpawnModal => "Create a session".into(), Action::FastForwardSpawn => "Create session with defaults".into(), Action::KillHighlighted => "Kill selected session".into(), Action::RenameHighlighted => "Rename selected session".into(), @@ -116,7 +116,7 @@ impl Action { "ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup), "AttachHighlighted" => return Ok(Action::AttachHighlighted), "ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted), - "NewDraftPane" => return Ok(Action::NewDraftPane), + "OpenSpawnModal" => return Ok(Action::OpenSpawnModal), "FastForwardSpawn" => return Ok(Action::FastForwardSpawn), "KillHighlighted" => return Ok(Action::KillHighlighted), "RenameHighlighted" => return Ok(Action::RenameHighlighted), @@ -179,7 +179,9 @@ mod tests { fn help_labels_are_plain_language() { assert_eq!(Action::EnterInsert.help_label(), "Send keys to session"); assert_eq!(Action::AttachHighlighted.help_label(), "Open selected item"); - assert!(!Action::NewDraftPane.help_label().contains("NewDraftPane")); + assert!(!Action::OpenSpawnModal + .help_label() + .contains("OpenSpawnModal")); } #[test] @@ -214,7 +216,7 @@ mod tests { assert_eq!(err, ActionParseError::Malformed("FocusPane(Left".into())); } - /// The fast-forward pair sits next to the draft-pane key (#67). + /// The fast-forward pair sits next to the spawn key (#67). #[test] fn fast_forward_spawn_round_trips() { assert_eq!( @@ -243,7 +245,7 @@ mod tests { Action::EnterInsert, Action::RenameHighlighted, Action::Quit, - Action::NewDraftPane, + Action::OpenSpawnModal, Action::ScrollPanePageDown, Action::FocusPane(Direction::Right), ] { @@ -252,13 +254,12 @@ mod tests { } } - /// The spawn modal is gone (issue #63); its old action string must not - /// resolve to anything. + /// Draft panes are gone; their old action string must not resolve. #[test] - fn the_removed_spawn_modal_action_no_longer_parses() { + fn the_removed_draft_pane_action_no_longer_parses() { assert_eq!( - Action::parse("OpenSpawnModal").unwrap_err(), - ActionParseError::Unknown("OpenSpawnModal".into()) + Action::parse("NewDraftPane").unwrap_err(), + ActionParseError::Unknown("NewDraftPane".into()) ); } } diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 81d136e..22d88d4 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`, a draft-pane submit firing +//! (`AttachHighlighted` spawning a `SessionTask`, a spawn-form submit firing //! a POST, `KillHighlighted` confirm firing a DELETE) live inline in -//! `dispatch` / the Insert-mode pane routing. +//! `dispatch` / the overlay routing. use std::collections::{HashMap, HashSet}; use std::io::Stdout; @@ -26,7 +26,7 @@ use uuid::Uuid; use crate::action::Action; use crate::catalog::CatalogStore; use crate::config::TuiConfig; -use crate::draft::DraftInputResult; +use crate::draft::{DraftInputResult, DraftPane}; use crate::emulators::SessionEmulators; use crate::input::{ canonicalise, ChordResult, ChordState, ClickTracker, InputKey, MouseKind, Region, @@ -37,7 +37,7 @@ 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, PaneRef, SidebarItem}; +use crate::state::{AppState, Focus, OverlayKind, SidebarItem}; use crate::text_input::TextInput; use crate::TuiError; @@ -58,6 +58,10 @@ pub struct App { help: HelpOverlay, chord: ChordState, daemon: DaemonClient, + /// The spawn form behind [`OverlayKind::Spawn`], kept here rather than in + /// `AppState` so the overlay enum stays comparable — the same reason the + /// rename buffer lives here. + draft: Option, /// Results of draft data loads, delivered back to the event loop. /// /// These are network calls — `/api/projects` on a cold cache walks all of @@ -99,22 +103,21 @@ pub enum Effect { id: Uuid, bytes: Vec, }, - /// Fetch presets / projects / names for a freshly opened draft (#64). - LoadDraft(usize), + /// Fetch presets / projects / names for a freshly opened form (#64). + LoadDraft, /// Fast-forward spawn (#67): resolve every default from the daemon and - /// spawn with no form; unresolvable defaults open a pre-filled draft. + /// spawn with no form; unresolvable defaults open a pre-filled form. FastForward, - /// A reroll drained the draft's local name batch (#64). - RefillNames(usize), - /// The draft's target changed: probe `/api/gitroot` so the worktree + /// A reroll drained the form's local name batch (#64). + RefillNames, + /// The form's target changed: probe `/api/gitroot` so the worktree /// section knows its base repo (#65). `seq` guards stale answers. ProbeGitRoot { - tab: usize, seq: u32, path: PathBuf, }, - /// Fetch the draft target's branches from `/api/branches` (#65). - LoadBranches(usize), + /// Fetch the form target's branches from `/api/branches` (#65). + LoadBranches, /// Rename a session (#125). An empty name clears the custom one, so the /// card falls back to the command. Rename { @@ -141,6 +144,7 @@ impl App { catalog, emulators: SessionEmulators::default(), state: AppState::new(), + draft: None, help, chord: ChordState::new(chord_timeout), daemon, @@ -222,14 +226,7 @@ impl App { self.perform(effects).await; } else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane) { - // Insert mode over a focused pane: a draft eats the key - // into its form, a session gets the raw bytes. - let effects = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_))) - { - self.handle_draft_input(&key) - } else { - self.forward_to_session(&key).into_iter().collect() - }; + let effects = self.forward_to_session(&key).into_iter().collect(); self.perform(effects).await; } Ok(false) @@ -273,13 +270,11 @@ impl App { Effect::Attach(id) => self.attach(id), Effect::Spawn(req) => self.submit_spawn(req).await, Effect::Kill(id) => self.submit_kill(id).await, - Effect::LoadDraft(tab_idx) => self.spawn_load_draft(tab_idx), + Effect::LoadDraft => self.spawn_load_draft(), Effect::FastForward => self.spawn_fast_forward(), - Effect::RefillNames(tab_idx) => self.spawn_refill_names(tab_idx), - Effect::ProbeGitRoot { tab, seq, path } => { - self.spawn_probe_git_root(tab, seq, path) - } - Effect::LoadBranches(tab) => self.spawn_load_branches(tab), + Effect::RefillNames => self.spawn_refill_names(), + Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path), + Effect::LoadBranches => self.spawn_load_branches(), Effect::Rename { id, name } => self.submit_rename(id, &name).await, Effect::Forward { id, bytes } => { if let Some(handle) = self.emulators.get(id) { @@ -292,6 +287,15 @@ impl App { fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec) { match self.state.overlay().cloned() { + // The form owns every key while it is up, Escape aside — the + // keymap must not see `s`, `j` or `x` as commands mid-typing. + Some(OverlayKind::Spawn) => match key { + InputKey::Esc => { + self.dismiss_spawn_modal(); + (OverlayDecision::Consumed, Vec::new()) + } + other => (OverlayDecision::Consumed, self.handle_draft_input(other)), + }, Some(OverlayKind::KillConfirm(id)) => match key { InputKey::Char { c: 'y', .. } => { self.state.dismiss_overlay(); @@ -363,12 +367,9 @@ impl App { }; self.state.show_overlay(OverlayKind::Help); } - Action::NewDraftPane => { - if let Some(sid) = self.state.active_session() { - self.release_session(sid); - } - self.state.open_draft_pane(); - effects.push(Effect::LoadDraft(self.state.active_tab_idx)); + Action::OpenSpawnModal => { + self.open_spawn_modal(); + effects.push(Effect::LoadDraft); } Action::FastForwardSpawn => effects.push(Effect::FastForward), Action::AttachHighlighted | Action::ToggleAttachHighlighted => { @@ -571,12 +572,24 @@ impl App { Some(None) } - /// Feed one keystroke into the active tab's draft form. Returns the spawn - /// effect when the user accepted a valid form; an invalid one names the - /// missing field inside the form (#64) instead of posting. + /// Put the spawn form up with a fresh draft behind it. + fn open_spawn_modal(&mut self) { + self.draft = Some(DraftPane::default()); + self.state.open_spawn_modal(); + } + + /// Close the form and drop its half-filled state — reopening starts clean, + /// as the web modal does. + fn dismiss_spawn_modal(&mut self) { + self.state.dismiss_overlay(); + self.draft = None; + } + + /// Feed one keystroke into the spawn form. Returns the spawn effect when + /// the user accepted a valid form; an invalid one names the missing field + /// inside the form (#64) instead of posting. fn handle_draft_input(&mut self, key: &InputKey) -> Vec { - let tab_idx = self.state.active_tab_idx; - let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else { + let Some(draft) = self.draft.as_mut() else { return Vec::new(); }; let mut effects = Vec::new(); @@ -590,48 +603,37 @@ impl App { } } if draft.take_name_refill() { - effects.push(Effect::RefillNames(tab_idx)); + effects.push(Effect::RefillNames); } if let Some((seq, path)) = draft.take_gitroot_probe(std::time::Instant::now()) { - effects.push(Effect::ProbeGitRoot { - tab: tab_idx, - seq, - path, - }); + effects.push(Effect::ProbeGitRoot { seq, path }); } if draft.take_branch_load().is_some() { - effects.push(Effect::LoadBranches(tab_idx)); + effects.push(Effect::LoadBranches); } effects } - /// Fire any draft gitroot probes whose debounce window has elapsed (#74). - /// The 30 Hz heartbeat calls this so a burst of typing issues its one - /// probe even after the last keystroke. + /// Fire any gitroot probe whose debounce window has elapsed (#74). The + /// 30 Hz heartbeat calls this so a burst of typing issues its one probe + /// even after the last keystroke. fn poll_due_probes_at(&mut self, now: std::time::Instant) -> Vec { - let mut effects = Vec::new(); - for tab_idx in 0..self.state.tabs.len() { - let Some(PaneRef::Draft(draft)) = self.state.tabs[tab_idx].pane.as_mut() else { - continue; - }; - if let Some((seq, path)) = draft.take_gitroot_probe(now) { - effects.push(Effect::ProbeGitRoot { - tab: tab_idx, - seq, - path, - }); - } - } - effects + let Some(draft) = self.draft.as_mut() else { + return Vec::new(); + }; + draft + .take_gitroot_probe(now) + .map(|(seq, path)| Effect::ProbeGitRoot { seq, path }) + .into_iter() + .collect() } - /// Populate a freshly opened draft: presets from `/api/info` (falling back + /// Populate a freshly opened form: presets from `/api/info` (falling back /// to the built-ins already in place), projects from `/api/projects`, and - /// a first name batch. Applies only if that tab still shows the draft — - /// a slow response must never clobber what replaced it. - /// Kick off the draft's initial data load without blocking the loop. - fn spawn_load_draft(&self, tab: usize) { - let Some(draft) = self.draft_id(tab) else { + /// a first name batch. Applies only if the same form is still up — a slow + /// response must never clobber the one that replaced it. + fn spawn_load_draft(&self) { + let Some(draft) = self.draft_id() else { return; }; let (daemon, http, base, tx) = self.loader_handles(); @@ -647,8 +649,8 @@ impl App { }); } - fn spawn_refill_names(&self, tab: usize) { - let Some(draft) = self.draft_id(tab) else { + fn spawn_refill_names(&self) { + let Some(draft) = self.draft_id() else { return; }; let (_, http, base, tx) = self.loader_handles(); @@ -658,8 +660,8 @@ impl App { }); } - fn spawn_probe_git_root(&self, tab: usize, seq: u32, path: PathBuf) { - let Some(draft) = self.draft_id(tab) else { + fn spawn_probe_git_root(&self, seq: u32, path: PathBuf) { + let Some(draft) = self.draft_id() else { return; }; let (daemon, _, _, tx) = self.loader_handles(); @@ -673,20 +675,11 @@ impl App { }); } - fn spawn_load_branches(&self, tab: usize) { - let Some(draft) = self.draft_id(tab) else { + fn spawn_load_branches(&self) { + let Some(draft) = self.draft_id() else { return; }; - let Some(cwd) = self - .state - .tabs - .get(tab) - .and_then(|t| t.pane.as_ref()) - .and_then(|p| match p { - PaneRef::Draft(d) => d.cwd(), - _ => None, - }) - else { + let Some(cwd) = self.draft.as_ref().and_then(DraftPane::cwd) else { return; }; let (_, http, base, tx) = self.loader_handles(); @@ -700,12 +693,9 @@ impl App { }); } - /// The draft pane living in `tab`, by identity. - fn draft_id(&self, tab: usize) -> Option { - match self.state.tabs.get(tab).and_then(|t| t.pane.as_ref()) { - Some(PaneRef::Draft(d)) => Some(d.id), - _ => None, - } + /// Identity of the form currently up, if any. + fn draft_id(&self) -> Option { + self.draft.as_ref().map(|d| d.id) } fn loader_handles( @@ -743,24 +733,9 @@ impl App { // Handled above; it targets no particular draft. DraftUpdate::FastForward { .. } => unreachable!(), }; - // Find it by identity: closing a tab shifts every index to its right, - // so an in-flight load addressed by index would be dropped or land on - // whatever pane inherited the slot (#67 review). - let Some(tab_idx) = self - .state - .tabs - .iter() - .position(|t| matches!(&t.pane, Some(PaneRef::Draft(d)) if d.id == want)) - else { - // The draft went away while the request was in flight. - return; - }; - let Some(PaneRef::Draft(draft)) = self - .state - .tabs - .get_mut(tab_idx) - .and_then(|t| t.pane.as_mut()) - else { + // Match by identity: dismissing the form and opening another while a + // load is in flight must not let the old answer land in the new form. + let Some(draft) = self.draft.as_mut().filter(|d| d.id == want) else { return; }; let mut follow_up_branches = false; @@ -799,7 +774,7 @@ impl App { DraftUpdate::FastForward { .. } => unreachable!(), } if follow_up_branches { - self.spawn_load_branches(tab_idx); + self.spawn_load_branches(); } } @@ -807,7 +782,7 @@ impl App { /// form. The most recent project, its last command (or the first /// non-custom preset), a freshly generated name, worktree on with a new /// branch named after that name. Anything unresolvable — no projects - /// known yet, no command, no name — downgrades to a draft pane seeded + /// known yet, no command, no name — downgrades to the spawn form seeded /// with whatever did resolve. /// Load what the fast path needs without blocking the loop — the same /// reason the draft loaders were moved off it (#67 review): a cold @@ -850,9 +825,9 @@ impl App { self.submit_spawn(req).await; return; } - // Unresolved defaults: a draft pre-filled with what did resolve. - self.state.open_draft_pane(); - let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else { + // Unresolved defaults: the form, pre-filled with what did resolve. + self.open_spawn_modal(); + let Some(draft) = self.draft.as_mut() else { return; }; draft.home_dir = home_dir; @@ -869,12 +844,13 @@ impl App { async fn submit_spawn(&mut self, req: CreateSessionRequest) { match self.daemon.create_session(&req).await { Ok(session) => { - // Fill the pane in place with the new session (web parity, - // #57) and attach to it right away. + // The form closes and the new session takes the pane, attached + // right away — the same landing the web modal gives it. let sid = session.id; if let Some(previous) = self.state.active_session() { self.release_session(previous); } + self.draft = None; self.state.complete_spawn(sid); self.attach(sid); } @@ -885,7 +861,7 @@ impl App { .daemon_message() .map(String::from) .unwrap_or_else(|| e.to_string()); - if let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() { + if let Some(draft) = self.draft.as_mut() { draft.error = Some(format!("spawn failed: {message}")); } else { self.state.toast(format!("spawn failed: {e}")); @@ -933,18 +909,13 @@ 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 pane_title = match self.state.active_tab().pane.as_ref() { - Some(PaneRef::Session(sid)) => snap + let pane_title = match active_sid { + Some(sid) => snap .sessions .iter() - .find(|session| session.id == *sid) + .find(|session| session.id == sid) .map(crate::sidebar::name_for) .unwrap_or_else(|| "session".into()), - Some(PaneRef::Draft(_)) => "new session".into(), None => "session".into(), }; @@ -954,7 +925,7 @@ impl App { pane_title: &pane_title, pane: pane.as_ref(), state: &self.state, - draft, + draft: self.draft.as_ref(), help: &self.help, rename_input: &self.rename_input, }; @@ -1235,36 +1206,52 @@ mod tests { assert!(help.contains("Create a session")); assert!(help.contains("Expand or collapse directory")); assert!(!help.contains("EnterInsert")); - assert!(!help.contains("NewDraftPane")); + assert!(!help.contains("OpenSpawnModal")); assert!(!help.contains("ToggleSidebarGroup")); } #[test] - fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() { + fn open_spawn_modal_puts_the_form_up_and_asks_for_its_data() { let mut app = make_app(); - 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"); + let effects = app.dispatch(Action::OpenSpawnModal); + assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn)); + assert!(app.draft.is_some()); + assert_eq!(effects, vec![Effect::LoadDraft]); } + /// The modal covers the attached session rather than evicting it: Escape + /// gives back exactly what was there, still attached. #[test] - fn new_draft_pane_over_a_session_reuses_the_pane() { + fn spawning_over_a_session_leaves_it_attached_behind_the_modal() { 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(), 1); - assert_eq!(app.state.active_tab_idx, 0); - assert!(matches!( - app.state.active_tab().pane, - Some(PaneRef::Draft(_)) - )); + app.dispatch(Action::OpenSpawnModal); + assert_eq!(app.state.active_session(), Some(sid)); + + app.handle_overlay_input(&InputKey::Esc); + assert!(app.state.overlay().is_none()); + assert!(app.draft.is_none()); + assert_eq!(app.state.active_session(), Some(sid)); + } + + /// While the form is up the keymap must not see its keystrokes: `x` is a + /// kill binding in Normal mode, and typing it into a name would fire it. + #[test] + fn the_modal_swallows_keys_the_keymap_would_otherwise_claim() { + let mut app = make_app(); + app.dispatch(Action::OpenSpawnModal); + + let (decision, effects) = app.handle_overlay_input(&InputKey::Char { + c: 'x', + ctrl: false, + alt: false, + shift: false, + }); + assert_eq!(decision, OverlayDecision::Consumed); + assert!(effects.is_empty()); + assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn)); } /// Seed the catalog with one session and highlight it. @@ -1507,11 +1494,11 @@ mod tests { } #[test] - fn new_draft_pane_schedules_a_data_load() { + fn opening_the_spawn_modal_schedules_a_data_load() { let mut app = make_app(); assert_eq!( - app.dispatch(Action::NewDraftPane), - vec![Effect::LoadDraft(0)] + app.dispatch(Action::OpenSpawnModal), + vec![Effect::LoadDraft] ); } @@ -1602,7 +1589,7 @@ mod tests { } /// No projects known yet: nothing can resolve, so the keystroke opens a - /// draft pane (focused, Insert mode) instead of guessing (#67). + /// spawn form instead of guessing (#67). #[tokio::test] async fn unresolvable_defaults_open_a_prefilled_draft_instead() { use axum::routing::get; @@ -1641,15 +1628,12 @@ mod tests { .expect("channel open"); app.apply_draft_update(update).await; - assert!( - matches!(app.state.active_tab().pane, Some(PaneRef::Draft(_))), - "the fallback is a draft pane" + assert_eq!( + app.state.overlay(), + Some(&OverlayKind::Spawn), + "the fallback is the spawn modal" ); - assert_eq!(app.state.focus, Focus::Pane); - assert_eq!(app.state.mode, Mode::Insert); - let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else { - panic!("expected a draft pane"); - }; + let draft = app.draft.as_ref().expect("a form behind the modal"); // Whatever did resolve (nothing project-side) is seeded; the // generated name still lands so the form is ready to go. assert_eq!(draft.name.value(), "amber-falcon"); @@ -1660,12 +1644,10 @@ mod tests { /// itself is covered in `draft.rs`), select the custom tile, type a /// command, then submit from the Spawn row. #[test] - fn typing_into_the_draft_then_enter_carries_the_typed_fields() { + fn typing_into_the_form_then_enter_carries_the_typed_fields() { let mut app = make_app(); - app.dispatch(Action::NewDraftPane); - let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { - panic!("expected a draft pane"); - }; + app.dispatch(Action::OpenSpawnModal); + let draft = app.draft.as_mut().unwrap(); draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work")); draft.base_repo = Some(std::path::PathBuf::from("/srv/work")); @@ -1703,10 +1685,8 @@ mod tests { #[test] fn typing_a_raw_path_schedules_a_debounced_gitroot_probe() { let mut app = make_app(); - app.dispatch(Action::NewDraftPane); - let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { - panic!("expected a draft pane"); - }; + app.dispatch(Action::OpenSpawnModal); + let draft = app.draft.as_mut().unwrap(); draft.cwd_raw_visible = true; draft.focused_field = DraftField::CwdRaw; @@ -1720,7 +1700,7 @@ mod tests { let due = std::time::Instant::now() + std::time::Duration::from_millis(crate::draft::GITROOT_PROBE_DEBOUNCE_MS) * 10; match app.poll_due_probes_at(due).as_slice() { - [Effect::ProbeGitRoot { tab: 0, path, .. }] => { + [Effect::ProbeGitRoot { path, .. }] => { assert_eq!(path, &std::path::PathBuf::from("/opt/tooling")); } other => panic!("expected one ProbeGitRoot effect, got {other:?}"), @@ -1736,10 +1716,8 @@ mod tests { #[test] fn switching_to_existing_branch_mode_schedules_a_branch_load() { let mut app = make_app(); - app.dispatch(Action::NewDraftPane); - let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { - panic!("expected a draft pane"); - }; + app.dispatch(Action::OpenSpawnModal); + let draft = app.draft.as_mut().unwrap(); draft.picked_cwd = Some(std::path::PathBuf::from("/srv/repo")); draft.base_repo = Some(std::path::PathBuf::from("/srv/repo")); draft.probed_cwd = Some(std::path::PathBuf::from("/srv/repo")); @@ -1747,7 +1725,7 @@ mod tests { let effects = app.handle_draft_input(&InputKey::Right); assert!( - matches!(effects.as_slice(), [Effect::LoadBranches(0)]), + matches!(effects.as_slice(), [Effect::LoadBranches]), "{effects:?}" ); } @@ -1757,31 +1735,24 @@ mod tests { #[test] fn submitting_an_empty_form_reports_the_missing_field_instead_of_posting() { let mut app = make_app(); - app.dispatch(Action::NewDraftPane); + app.dispatch(Action::OpenSpawnModal); // Walk to Submit without picking anything. for _ in 0..4 { app.handle_draft_input(&InputKey::Tab); } assert!(app.handle_draft_input(&InputKey::Enter).is_empty()); - let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else { - panic!("expected a draft pane"); - }; assert_eq!( - draft.error.as_deref(), + app.draft.as_ref().unwrap().error.as_deref(), Some("Pick a working directory."), "the refusal shows in the form" ); // With a directory but an empty custom command, it names the command. - let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { - panic!(); - }; + let draft = app.draft.as_mut().unwrap(); draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work")); draft.selected_preset = draft.presets.len() - 1; // custom, empty text assert!(app.handle_draft_input(&InputKey::Enter).is_empty()); - let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else { - panic!(); - }; + let draft = app.draft.as_ref().unwrap(); assert_eq!( draft.error.as_deref(), Some("Pick a command (or fill the custom command).") @@ -1802,7 +1773,7 @@ mod tests { Duration::from_millis(500), ), ); - app.dispatch(Action::NewDraftPane); + app.dispatch(Action::OpenSpawnModal); let req = CreateSessionRequest { cwd: std::path::PathBuf::from("/srv/work"), command: "bash".into(), @@ -1812,17 +1783,16 @@ mod tests { }; app.submit_spawn(req).await; - let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else { - panic!("expected a draft pane"); - }; + let draft = app.draft.as_ref().expect("the form stays up on failure"); let error = draft .error .as_deref() .expect("spawn failure must reach the form"); assert!(error.starts_with("spawn failed"), "{error}"); - assert!( - matches!(app.state.overlay(), None | Some(OverlayKind::Toast(_))), - "no kill-confirm style overlay took over" + assert_eq!( + app.state.overlay(), + Some(&OverlayKind::Spawn), + "the form stays up so the error is readable next to the fields" ); } @@ -1866,8 +1836,8 @@ mod tests { tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() }); let mut app = make_app_at(&base); - let effects = app.dispatch(Action::NewDraftPane); - assert!(matches!(effects.as_slice(), [Effect::LoadDraft(0)])); + let effects = app.dispatch(Action::OpenSpawnModal); + assert!(matches!(effects.as_slice(), [Effect::LoadDraft])); // `perform` only spawns the load — it must not block the event loop on // the network (#67 review). The result arrives on the draft channel, // which the run loop drains; do that by hand here. @@ -1878,9 +1848,7 @@ mod tests { .expect("channel open"); app.apply_draft_update(update).await; - let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else { - panic!("expected a draft pane"); - }; + let draft = app.draft.as_ref().unwrap(); assert_eq!(draft.command(), "aider"); assert_eq!(draft.groups.len(), 1); assert_eq!(draft.groups[0].main.name, "maestro"); @@ -1888,9 +1856,9 @@ mod tests { assert_eq!(draft.name.value(), "crimson-otter"); } - /// Keys fed while no draft exists must be ignored, not panic. + /// Keys fed while the form is not up must be ignored, not panic. #[test] - fn draft_input_is_a_no_op_without_a_draft() { + fn draft_input_is_a_no_op_without_a_form() { let mut app = make_app(); assert!(app.handle_draft_input(&InputKey::char('x')).is_empty()); } @@ -1923,14 +1891,6 @@ 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()); - } - #[test] fn pane_scrollback_moves_and_user_input_returns_to_live_output() { use crate::session_task::{SessionHandle, SessionState}; @@ -1948,7 +1908,7 @@ mod tests { state: Arc::new(AtomicU8::new(SessionState::Live as u8)), }; let mut app = make_app(); - app.state.active_tab_mut().pane = Some(PaneRef::Session(sid)); + app.state.active_tab_mut().pane = Some(sid); app.emulators.insert(sid, handle); app.scroll_active_pane(3); diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index b688d82..7983d26 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -48,8 +48,8 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("c", "ToggleSidebarGroup"), // Catalog actions ("enter", "AttachHighlighted"), - ("s", "NewDraftPane"), - // Fast-forward spawn (#67): the draft-pane key's neighbour does the same + ("s", "OpenSpawnModal"), + // Fast-forward spawn (#67): the spawn key's neighbour does the same // thing with every default resolved and no form. ("S", "FastForwardSpawn"), ("x", "KillHighlighted"), @@ -204,7 +204,7 @@ mod tests { ); assert_eq!( cfg.keymap.resolve(Mode::Normal, "s"), - Some(Action::NewDraftPane), + Some(Action::OpenSpawnModal), ); assert_eq!( cfg.keymap.resolve(Mode::Normal, "S"), diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index 420842d..f8d941a 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -1,9 +1,9 @@ -//! Draft pane — the spawn form living in an ordinary pane (issues #63/#64). +//! The spawn form (issues #63/#64), drawn inside the spawn modal. //! -//! 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. +//! Submitting hands the caller a built request; the modal closes and the new +//! session takes the pane, matching the web client's spawn modal. Field +//! editing goes through [`crate::text_input::TextInput`]; Tab / BackTab walk +//! the fields. //! //! Field groups (#64), mirroring the web form (`web/src/spawnForm.ts`): //! command presets from `/api/info` (with an implicit `custom` free-text diff --git a/crates/maestro-tui/src/fast_forward.rs b/crates/maestro-tui/src/fast_forward.rs index daa879a..b799b55 100644 --- a/crates/maestro-tui/src/fast_forward.rs +++ b/crates/maestro-tui/src/fast_forward.rs @@ -1,11 +1,11 @@ //! Fast-forward spawn (#67): one keystroke, every default resolved, no form. //! -//! The defaults mirror what a draft pane would settle on after the operator +//! The defaults mirror what the spawn form would settle on after the operator //! picked the most recent project and kept their hands off everything else: //! its last command (or the first non-custom preset), a freshly generated //! name, and a worktree with a branch named after that name. Any default //! that cannot be resolved — no projects yet, no command, no generated -//! name — downgrades the keystroke to a draft pane pre-filled with whatever +//! name — downgrades the keystroke to the spawn form pre-filled with whatever //! did resolve ([`FastForwardDefaults::apply_to_draft`]). use std::path::PathBuf; @@ -85,7 +85,7 @@ impl FastForwardDefaults { }) } - /// Seed a fresh draft pane with whatever did resolve — the fallback when + /// Seed a fresh spawn form with whatever did resolve — the fallback when /// [`Self::build_request`] is `None`. The operator fills in the rest. pub fn apply_to_draft(&self, draft: &mut DraftPane) { if let Some(project) = &self.project { diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index 3dadffc..a3140f1 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -1,8 +1,5 @@ -//! Overlay widgets: kill-confirm prompt, rename prompt, help overlay, -//! transient toast. -//! -//! The spawn form is no longer an overlay (issue #63) — it lives in draft -//! panes ([`crate::draft::DraftPane`]). +//! Overlay widgets: spawn modal, kill-confirm prompt, rename prompt, help +//! overlay, transient toast. //! //! All overlays are centered popups drawn over the rest of the UI; the //! `Clear` widget wipes the underlying buffer before drawing. @@ -15,6 +12,27 @@ use ratatui::Frame; use crate::text_input::TextInput; +/// Chrome for the spawn form. The form itself ([`crate::draft::DraftPane`]) +/// draws into the rect this returns, so the two can be sized independently. +#[derive(Debug, Clone, Copy)] +pub struct SpawnModal; + +impl SpawnModal { + /// Draw the frame and return the area the form should render into. + pub fn render(&self, f: &mut Frame, parent: Rect) -> (Rect, Rect) { + // Tall enough for the whole form — presets, picker, name and the + // advanced section — without pushing past small terminals. + let area = centered(parent, 72, parent.height.saturating_sub(4).min(26)); + f.render_widget(Clear, area); + let block = Block::default() + .title(" new session ") + .borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + (area, inner) + } +} + #[derive(Debug, Clone)] pub struct KillConfirm { pub label: String, @@ -168,6 +186,20 @@ mod tests { out } + #[test] + fn spawn_modal_frames_an_inner_area_inside_its_border() { + let mut inner = None; + let rendered = render_to_string(90, 30, |f| { + let (area, i) = SpawnModal.render(f, f.area()); + inner = Some((area, i)); + f.render_widget(Paragraph::new("form goes here"), i); + }); + let (area, i) = inner.unwrap(); + assert!(i.x > area.x && i.y > area.y, "{area:?} vs {i:?}"); + assert!(rendered.contains("new session"), "{rendered}"); + assert!(rendered.contains("form goes here"), "{rendered}"); + } + #[test] fn kill_confirm_renders_prompt() { let modal = KillConfirm { diff --git a/crates/maestro-tui/src/projects.rs b/crates/maestro-tui/src/projects.rs index 79d5e1a..88ab0af 100644 --- a/crates/maestro-tui/src/projects.rs +++ b/crates/maestro-tui/src/projects.rs @@ -1,4 +1,4 @@ -//! Pure helpers behind the draft pane's project picker (#64): staleness, +//! Pure helpers behind the spawn form's project picker (#64): staleness, //! recency ordering, worktree grouping, and free-text filtering. Mirrors //! `web/src/projectList.ts` so both clients order and dim identically — the //! daemon reports the timestamps; what "stale" means is a client decision diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 931b6de..171b19d 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, RenamePrompt, Toast}; +use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, SpawnModal, Toast}; use crate::palette; use crate::pane::Pane; use crate::sidebar::Sidebar; @@ -77,7 +77,7 @@ pub struct RenderInputs<'a> { pub home_dir: &'a str, pub pane_title: &'a str, pub pane: Option<&'a Pane>, - /// Inline spawn form when the pane is a draft (issue #63). + /// The spawn form behind [`OverlayKind::Spawn`], when one is up. pub draft: Option<&'a crate::draft::DraftPane>, pub state: &'a AppState, pub help: &'a HelpOverlay, @@ -139,9 +139,6 @@ 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); - } // `RegionMap::pane` is also the PTY resize source. The title bar is // chrome; every remaining cell belongs to the emulator. map.pane = Some(pane_area); @@ -151,6 +148,15 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { render_status_bar(f, status_area, inputs.state); match inputs.state.overlay() { + Some(OverlayKind::Spawn) => { + if let Some(d) = inputs.draft { + let (area, inner) = SpawnModal.render(f, area); + // The modal owns the keyboard while it is up, so the form + // always draws focused. + d.render(f, inner, true); + map.modal = Some(area); + } + } Some(OverlayKind::KillConfirm(id)) => { // #83: surface what the watcher last saw in the worktree, in // InTheWay's own words, before the operator commits to the kill. @@ -224,7 +230,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: new draft • ?: help • ^Wq: quit", + Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit", Mode::Insert => "^a: return to sidebar", }; let mode_style = palette::active().mode_style(state.mode == Mode::Insert); @@ -325,20 +331,16 @@ mod tests { ); } - /// Issue #63: an empty pane is a draft — the frame must show the spawn - /// form where the terminal would otherwise be. + /// The spawn form is a modal: the frame shows it over whatever the pane + /// was displaying, framed by its own border. #[test] - fn a_draft_pane_renders_the_spawn_form_in_its_pane_area() { + fn the_spawn_overlay_renders_the_form_in_a_modal() { 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:?}"), - }; + state.open_spawn_modal(); + let draft = crate::draft::DraftPane::default(); let sidebar = Sidebar { sessions: Vec::new(), highlight: None, @@ -348,9 +350,9 @@ mod tests { let inputs = RenderInputs { sidebar: &sidebar, home_dir: "", - pane_title: "new session", + pane_title: "session", pane: None, - draft: Some(draft), + draft: Some(&draft), state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), @@ -358,11 +360,10 @@ mod tests { let backend = ratatui::backend::TestBackend::new(80, 24); let mut terminal = ratatui::Terminal::new(backend).unwrap(); - terminal - .draw(|f| { - draw(f, inputs); - }) - .unwrap(); + let mut map = None; + terminal.draw(|f| map = Some(draw(f, inputs))).unwrap(); + // Mouse clicks inside the form must not fall through to the pane. + assert!(map.unwrap().modal.is_some()); let buf = terminal.backend().buffer().clone(); let mut out = String::new(); for y in 0..buf.area.height { diff --git a/crates/maestro-tui/src/state.rs b/crates/maestro-tui/src/state.rs index 154d680..1e3d7cc 100644 --- a/crates/maestro-tui/src/state.rs +++ b/crates/maestro-tui/src/state.rs @@ -1,9 +1,8 @@ //! `AppState` — the top-level mutable state of the running TUI. //! -//! v1 keeps it small on purpose: one pane containing a session or draft form, -//! 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 keeps it small on purpose: one pane containing a session, focus split +//! between sidebar and pane, the current input mode, and an optional overlay +//! (spawn form, kill confirm, rename, help, transient toast). //! //! v1.5 will replace `Tab::pane`'s single slot with a split tree. @@ -12,7 +11,6 @@ use std::path::PathBuf; use uuid::Uuid; -use crate::draft::DraftPane; use crate::keymap::Mode; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -31,34 +29,24 @@ pub enum SidebarItem { Session(Uuid), } -/// What a tab's pane slot holds: a live session, or an inline draft form. -// The draft variant dwarfs `Session(Uuid)`; the pane is moved rarely enough -// that boxing every access costs more than the copy does. -#[allow(clippy::large_enum_variant)] -#[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. + /// The session this tab shows, if it shows one. pub fn session(&self) -> Option { - match &self.pane { - Some(PaneRef::Session(sid)) => Some(*sid), - _ => None, - } + self.pane } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum OverlayKind { + /// The spawn form. Its [`crate::draft::DraftPane`] lives on `App` for the + /// same reason the rename buffer does — so this stays comparable. + Spawn, KillConfirm(Uuid), /// Rename prompt for this session (#125). The edit buffer lives on `App`, /// so this stays comparable. @@ -138,26 +126,23 @@ 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(PaneRef::Session(session_id)); + self.active_tab_mut().pane = Some(session_id); self.focus = Focus::Pane; self.mode = Mode::Insert; } - /// Replace the pane with a draft form. 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) { - self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default())); - self.focus = Focus::Pane; - self.mode = Mode::Insert; + /// Put the spawn form up. Whatever the pane was showing stays behind it, + /// so dismissing gives that back untouched — the modal owns the keyboard + /// while it is up, so focus and mode are left alone. + pub fn open_spawn_modal(&mut self) { + self.show_overlay(OverlayKind::Spawn); } - /// A draft submit succeeded: replace whatever the active tab showed with - /// the new session in place, matching the web client's behaviour (#57). + /// A spawn submit succeeded: the modal closes and the new session takes + /// the pane, attached and ready to type into. 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; + self.dismiss_overlay(); + self.attach_to_focused(sid); } /// The session shown by the active tab's pane, if any. @@ -385,35 +370,28 @@ mod tests { 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. + /// The modal covers the pane rather than replacing it: an attached + /// session is still there once the form is dismissed. #[test] - fn open_draft_reuses_the_empty_active_pane() { + fn open_spawn_modal_leaves_the_pane_alone() { 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); + let sid = Uuid::from_u128(9); + state.attach_to_focused(sid); + state.open_spawn_modal(); + assert_eq!(state.overlay(), Some(&OverlayKind::Spawn)); + assert_eq!(state.active_tab().session(), Some(sid)); + + state.dismiss_overlay(); + assert_eq!(state.active_tab().session(), Some(sid)); } #[test] - fn open_draft_over_a_session_reuses_the_pane() { + fn complete_spawn_dismisses_the_modal_and_attaches() { let mut state = AppState::new(); - state.attach_to_focused(Uuid::from_u128(9)); - state.open_draft_pane(); - assert_eq!(state.tabs.len(), 1); - assert_eq!(state.active_tab_idx, 0); - assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_)))); - } - - /// 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(); + state.open_spawn_modal(); let sid = Uuid::from_u128(21); state.complete_spawn(sid); + assert!(state.overlay().is_none()); assert_eq!(state.active_tab().session(), Some(sid)); assert_eq!(state.focus, Focus::Pane); assert_eq!(state.mode, Mode::Insert); diff --git a/docs/INSTALL.md b/docs/INSTALL.md index d51e120..acf9a4e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -140,7 +140,7 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w` [tui.keymap.normal] "i" = "EnterInsert" -"s" = "NewDraftPane" +"s" = "OpenSpawnModal" "x" = "KillHighlighted" "r" = "RenameHighlighted" "?" = "ShowHelp"