diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 7ecb642..d864582 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -23,7 +23,7 @@ use uuid::Uuid; use crate::action::Action; use crate::catalog::CatalogStore; -use crate::command_bar::{BarOutcome, BarTarget, CommandBar}; +use crate::command_bar::{BarOutcome, BarTarget, CommandBar, SessionActionId}; use crate::config::TuiConfig; use crate::daemon_link::DaemonLink; use crate::draft::{DraftInputResult, DraftPane}; @@ -479,6 +479,25 @@ impl App { self.close_command_bar(); (OverlayDecision::Consumed, vec![Effect::FastForwardIn(dir)]) } + // The action page raises the flow this client already has + // — for kill that is `KillConfirm`, which the TUI puts in + // front of every kill (#160). The bar names the target; + // it never reimplements the flow. + BarOutcome::Open(BarTarget::SessionAction { session, action }) => { + self.close_command_bar(); + let effects = match action { + SessionActionId::Kill => { + self.confirm_kill(session); + Vec::new() + } + SessionActionId::Rename => { + self.prompt_rename(session); + Vec::new() + } + SessionActionId::SpawnSimilar => self.open_spawn_similar(session), + }; + (OverlayDecision::Consumed, effects) + } } } Some(OverlayKind::Help) => { @@ -555,24 +574,12 @@ impl App { } Action::KillHighlighted => { if let Some(sid) = self.pick_sidebar_target() { - self.state.show_overlay(OverlayKind::KillConfirm(sid)); + self.confirm_kill(sid); } } Action::RenameHighlighted => { if let Some(sid) = self.pick_sidebar_target() { - // Pre-fill with the custom name only: a session that has - // none starts empty rather than with its command already - // typed in, which Enter would then pin as the name. - self.rename_input = TextInput::with_value( - self.catalog - .snapshot() - .sessions - .iter() - .find(|s| s.id == sid) - .and_then(crate::sidebar::custom_name_for) - .unwrap_or_default(), - ); - self.state.show_overlay(OverlayKind::Rename(sid)); + self.prompt_rename(sid); } } } @@ -858,6 +865,30 @@ impl App { Some(None) } + /// Raise the kill confirmation for one session (`docs/WORKTREES.md`). + /// Shared by the keymap's own kill and the command bar's action page + /// (#155), which picks the action and hands off rather than reimplementing + /// it; the blockers the dialog names are read at render time. + fn confirm_kill(&mut self, sid: Uuid) { + self.state.show_overlay(OverlayKind::KillConfirm(sid)); + } + + /// Raise the rename prompt for one session, pre-filled with its custom + /// name only: a session that has none starts empty rather than with its + /// command already typed in, which Enter would then pin as the name. + fn prompt_rename(&mut self, sid: Uuid) { + self.rename_input = TextInput::with_value( + self.catalog + .snapshot() + .sessions + .iter() + .find(|s| s.id == sid) + .and_then(crate::sidebar::custom_name_for) + .unwrap_or_default(), + ); + self.state.show_overlay(OverlayKind::Rename(sid)); + } + /// Put the spawn form up with a fresh draft behind it, preselecting the /// project of the session the operator was last on — so `n` then Enter /// spawns another session there, the way "spawn similar" would. A @@ -871,6 +902,30 @@ impl App { self.state.open_spawn_modal(); } + /// "Spawn similar" (#155): the spawn form seeded from one session's + /// directory and command, rather than from whatever the operator was last + /// looking at. Same base-repository rule as the plain seed, and the name + /// is left out on purpose — a clone is a new session and takes its own + /// generated handle, like `web/src/spawnSimilar.ts`. + fn open_spawn_similar(&mut self, sid: Uuid) -> Vec { + let session = self + .catalog + .snapshot() + .sessions + .iter() + .find(|s| s.id == sid) + .cloned(); + self.open_spawn_modal(); + if let (Some(session), Some(draft)) = (session, self.draft.as_mut()) { + draft.seed_cwd = Some(match &session.worktree { + Some(wt) => wt.base_repo.clone(), + None => session.cwd.clone(), + }); + draft.seed_command = (!session.command.is_empty()).then(|| session.command.clone()); + } + vec![Effect::LoadDraft] + } + /// The project of the session the operator was last on — its base /// repository for a worktree session, its cwd otherwise. Shared by the /// plain spawn modal seed and fast-forward's project pick (#67 review): @@ -3379,4 +3434,79 @@ mod tests { }] ); } + + // ---- the session action page (#155) ---- + + /// Drill into the second row's session, then pick an action by name. + fn drill_and_pick(app: &mut App, action: &str) -> (OverlayDecision, Vec) { + app.dispatch(Action::OpenCommandBar); + app.handle_overlay_input(&InputKey::Down); + app.handle_overlay_input(&InputKey::Tab); + type_into_bar(app, action); + app.handle_overlay_input(&InputKey::Enter) + } + + /// The bar picks the action and hands off: kill goes through the confirm + /// this client puts in front of every kill, not straight to the daemon. + #[test] + fn the_action_page_raises_the_existing_kill_confirm() { + let mut app = make_app(); + let (alpha, _bravo) = seed_two_sessions(&mut app); + let (decision, effects) = drill_and_pick(&mut app, "kill"); + assert_eq!(decision, OverlayDecision::Consumed); + assert!(effects.is_empty(), "the confirm is the whole effect"); + assert_eq!(app.state.overlay(), Some(&OverlayKind::KillConfirm(alpha))); + assert!(app.command_bar.is_none()); + } + + #[test] + fn the_action_page_raises_the_existing_rename_prompt_prefilled() { + let mut app = make_app(); + let (alpha, _bravo) = seed_two_sessions(&mut app); + let (decision, effects) = drill_and_pick(&mut app, "rename"); + assert_eq!(decision, OverlayDecision::Consumed); + assert!(effects.is_empty()); + assert_eq!(app.state.overlay(), Some(&OverlayKind::Rename(alpha))); + assert_eq!(app.rename_input.value(), "alpha"); + assert!(app.command_bar.is_none()); + } + + /// Spawn similar opens the spawn form seeded from that session's directory + /// and command — not from whatever the operator was last looking at. + #[test] + fn the_action_page_raises_the_spawn_form_seeded_from_the_session() { + let mut app = make_app(); + seed_two_sessions(&mut app); + let (decision, effects) = drill_and_pick(&mut app, "spawn"); + assert_eq!(decision, OverlayDecision::Consumed); + assert_eq!(effects, vec![Effect::LoadDraft]); + assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn)); + let draft = app.draft.as_ref().unwrap(); + assert_eq!(draft.seed_cwd, Some(std::path::PathBuf::from("/"))); + assert_eq!(draft.seed_command.as_deref(), Some("bash")); + assert!(app.command_bar.is_none()); + } + + /// Backing out is free: the result-list query survives the round trip. + #[test] + fn escape_on_the_action_page_returns_to_the_result_list() { + let mut app = make_app(); + seed_two_sessions(&mut app); + app.dispatch(Action::OpenCommandBar); + type_into_bar(&mut app, "alp"); + app.handle_overlay_input(&InputKey::Tab); + assert!(app.command_bar.as_ref().unwrap().on_action_page()); + + let (decision, _) = app.handle_overlay_input(&InputKey::Esc); + assert_eq!(decision, OverlayDecision::Consumed); + let bar = app.command_bar.as_ref().expect("escape backs out, not out"); + assert!(!bar.on_action_page()); + assert_eq!(bar.query(), "alp"); + assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar)); + + // A second escape is the way out again. + app.handle_overlay_input(&InputKey::Esc); + assert!(app.command_bar.is_none()); + assert!(app.state.overlay().is_none()); + } } diff --git a/crates/maestro-tui/src/command_bar.rs b/crates/maestro-tui/src/command_bar.rs index 7a8d4d7..3b2081b 100644 --- a/crates/maestro-tui/src/command_bar.rs +++ b/crates/maestro-tui/src/command_bar.rs @@ -2,8 +2,9 @@ //! result list, opened from Normal or Insert mode, closed with Escape. //! //! Three kinds live here: sessions and commands, ranked together by default, -//! and directories, which need the `~` sigil (#154). The session action page -//! (#155) and the leader's literal Ctrl-K (#156) land later. +//! and directories, which need the `~` sigil (#154). `tab` on a session drills +//! into its action page (#155), the same overlay over three actions. The +//! leader's literal Ctrl-K (#156) lands later. //! //! Ranking is [`crate::matcher`]'s job and nothing here reorders it — the //! groups are a rendering of the ranked list, in the order their best-scoring @@ -46,8 +47,42 @@ pub enum BarTarget { /// Fast-forward spawn in this directory, absolute — the abbreviated form /// is what the row shows and what the matcher ranked. Directory(PathBuf), + /// A row of the action page: run this action against this session (#155). + SessionAction { + session: Uuid, + action: SessionActionId, + }, } +/// The actions a session's action page offers, and no others: the bar picks +/// one and hands off to the flow that already implements it +/// (`docs/COMMAND-BAR.md`, "Drill-in"). Mirrors the web's `SessionActionId`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionActionId { + Kill, + Rename, + SpawnSimilar, +} + +impl SessionActionId { + /// The row's text, which is also the only field the matcher sees. + pub fn label(self) -> &'static str { + match self { + Self::Kill => "Kill session", + Self::Rename => "Rename session", + Self::SpawnSimilar => "Spawn similar", + } + } +} + +/// The page's actions in the order an empty query lists them, since the +/// matcher keeps input order. +pub const SESSION_ACTIONS: [SessionActionId; 3] = [ + SessionActionId::Kill, + SessionActionId::Rename, + SessionActionId::SpawnSimilar, +]; + /// The first character of the query, when it is one of the two that narrow the /// list to a single kind. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -255,10 +290,23 @@ pub struct CommandBar { sessions: Vec, commands: Vec, directories: Vec, + /// The action page while it is up (#155), and the result-list query it was + /// drilled into from — backing out restores that query, so the round trip + /// costs the operator nothing. + page: Option, + stashed: String, rows: Vec, highlight: usize, } +/// The session action page while it is up: the name the overlay titles itself +/// with, and the three action rows the query line runs over. +#[derive(Debug)] +struct ActionPage { + name: String, + actions: Vec, +} + impl CommandBar { /// Open the bar over the sessions the catalog has right now and the live /// keymap for the current mode. Directories arrive later @@ -293,6 +341,8 @@ impl CommandBar { sessions, commands, directories: Vec::new(), + page: None, + stashed: String::new(), rows: Vec::new(), highlight: 0, }; @@ -381,7 +431,15 @@ impl CommandBar { /// "Keys") does not claim is ordinary editing. pub fn handle_key(&mut self, key: &InputKey) -> BarOutcome { match key { - InputKey::Esc => BarOutcome::Close, + // On the action page Escape is one level up, not the way out. + InputKey::Esc => { + if self.page.is_some() { + self.leave_page(); + BarOutcome::Stay + } else { + BarOutcome::Close + } + } InputKey::Enter => match self.selected() { Some(row) => BarOutcome::Open(row.target.clone()), // Nothing to open: the bar stays up rather than closing on a @@ -402,9 +460,19 @@ impl CommandBar { self.move_highlight(1); BarOutcome::Stay } - // Tab drills into the highlighted session's action page (#155). - // Until then it is inert rather than a literal tab in the query. - InputKey::Tab | InputKey::BackTab => BarOutcome::Stay, + // Tab drills into the highlighted session's action page (#155); + // shift-tab is inert rather than a literal tab in the query. + InputKey::Tab => { + self.drill_in(); + BarOutcome::Stay + } + InputKey::BackTab => BarOutcome::Stay, + // Ordinary editing, except at an empty query on the action page, + // where it is the second way back to the result list. + InputKey::Backspace if self.page.is_some() && self.input.value().is_empty() => { + self.leave_page(); + BarOutcome::Stay + } other => { let before = self.input.value().to_string(); self.input.handle_key(other); @@ -416,15 +484,75 @@ impl CommandBar { } } + /// `tab`: drill into the highlighted session's action page. No effect on + /// any other kind, and none on the page itself, which has no session rows. + fn drill_in(&mut self) { + if self.page.is_some() { + return; + } + let Some(row) = self.selected() else { + return; + }; + let BarTarget::Session(session) = row.target else { + return; + }; + let name = row.name.clone(); + let actions = SESSION_ACTIONS + .iter() + .map(|&action| BarEntry { + candidate: MatchCandidate::Command(CommandCandidate { + label: action.label().to_string(), + }), + target: BarTarget::SessionAction { session, action }, + dead: false, + chords: Vec::new(), + }) + .collect(); + self.page = Some(ActionPage { name, actions }); + self.stashed = self.input.value().to_string(); + self.input = TextInput::default(); + self.recompute(); + } + + /// Escape, or backspace at an empty query: back to the result list with + /// the query the drill-in was made from. + fn leave_page(&mut self) { + self.page = None; + self.input = TextInput::with_value(std::mem::take(&mut self.stashed)); + self.recompute(); + } + + /// Whether the action page is up. The session it acts on is in every one + /// of its rows' targets. + pub fn on_action_page(&self) -> bool { + self.page.is_some() + } + + /// What the matcher sees. On the action page the whole query is the query: + /// a sigil narrows the result list to one kind, and the page is one kind + /// already. + fn parsed_query(&self) -> BarQuery<'_> { + match self.page { + Some(_) => BarQuery { + sigil: None, + query: self.input.value(), + }, + None => parse_bar_query(self.input.value()), + } + } + /// The sigil in force for the query as it stands. pub fn sigil(&self) -> Option { - parse_bar_query(self.input.value()).sigil + self.parsed_query().sigil } /// The candidates the query line asks for. A sigil narrows to one kind; /// with no sigil the list is mixed — sessions and commands ranked together /// — except on an empty query, where the bar is a session switcher. fn active_entries(&self, parsed: BarQuery<'_>) -> Vec<&BarEntry> { + if let Some(page) = &self.page { + return page.actions.iter().collect(); + } match parsed.sigil { Some(BarSigil::Command) => self.commands.iter().collect(), Some(BarSigil::Directory) => self.directories.iter().collect(), @@ -438,7 +566,7 @@ impl CommandBar { /// different result. fn recompute(&mut self) { let rows: Vec = { - let parsed = parse_bar_query(self.input.value()); + let parsed = self.parsed_query(); let entries = self.active_entries(parsed); let candidates: Vec = entries.iter().map(|e| e.candidate.clone()).collect(); @@ -482,12 +610,16 @@ impl CommandBar { let height = parent.height.saturating_sub(4).clamp(5, 20); let area = centered(parent, 72, height); f.render_widget(Clear, area); + // The page names the session being acted on, so the target is never + // ambiguous. + let title = match (&self.page, self.sigil()) { + (Some(page), _) => format!(" act on {} ", page.name), + (None, Some(BarSigil::Command)) => " run a command ".to_string(), + (None, Some(BarSigil::Directory)) => " spawn in ".to_string(), + (None, None) => " go to ".to_string(), + }; let block = Block::default() - .title(match self.sigil() { - Some(BarSigil::Command) => " run a command ", - Some(BarSigil::Directory) => " spawn in ", - None => " go to ", - }) + .title(title) .borders(Borders::ALL) .style(pal.card.base()) .border_style(pal.border_style(true)); @@ -510,7 +642,11 @@ impl CommandBar { .render(query, f.buffer_mut(), pal.card.text_style()); f.render_widget( Paragraph::new(Line::from(Span::styled( - "[enter] open [esc] close", + if self.page.is_some() { + "[enter] run [esc] back" + } else { + "[enter] open [esc] close" + }, pal.card.dim_style(), ))), row(inner.height - 1), @@ -540,8 +676,9 @@ impl CommandBar { let mut lines: Vec> = Vec::new(); let mut highlight_line = 0usize; // While a sigil is in force there is one kind, so the header is - // dropped — it would name what the whole list already is. - let headers = self.sigil().is_none(); + // dropped — it would name what the whole list already is. The action + // page is one kind too. + let headers = self.sigil().is_none() && self.page.is_none(); for group in self.groups() { if headers { lines.push(Line::from(Span::styled( @@ -1219,4 +1356,163 @@ mod tests { } assert_eq!(bold, vec!["w", "s"], "bold cells: {bold:?}"); } + + // ---- the session action page (#155) ---- + + fn targets(rows: &[BarRow]) -> Vec { + rows.iter().map(|r| r.target.clone()).collect() + } + + #[test] + fn tab_on_a_session_opens_its_action_page() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + bar.handle_key(&InputKey::Tab); + assert!(bar.on_action_page()); + assert_eq!( + labels(bar.rows()), + vec!["Kill session", "Rename session", "Spawn similar"] + ); + assert_eq!( + targets(bar.rows()), + SESSION_ACTIONS + .iter() + .map(|&action| BarTarget::SessionAction { + session: Uuid::from_u128(1), + action, + }) + .collect::>() + ); + } + + /// Tab has no effect on any other kind, and none on the page itself, which + /// has no session rows. + #[test] + fn tab_on_a_command_or_directory_row_does_nothing() { + let keymap = vec![("x".to_string(), Action::KillHighlighted)]; + let mut bar = CommandBar::new(&[named(1, "alpha")], &keymap, "/home/me"); + typed(&mut bar, ">"); + bar.handle_key(&InputKey::Tab); + assert!(!bar.on_action_page()); + assert_eq!(bar.query(), ">"); + + let mut bar = CommandBar::new(&[], &[], "/home/me"); + bar.set_directories(&[PathBuf::from("/home/me/dev/api")]); + typed(&mut bar, "~"); + bar.handle_key(&InputKey::Tab); + assert!(!bar.on_action_page()); + } + + #[test] + fn tab_on_the_action_page_does_not_drill_further() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + bar.handle_key(&InputKey::Tab); + bar.handle_key(&InputKey::Tab); + assert!(bar.on_action_page()); + assert_eq!(labels(bar.rows()).len(), 3); + } + + /// The page drills into the highlighted session, not the first one. + #[test] + fn the_page_acts_on_the_highlighted_session() { + let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me"); + bar.move_highlight(1); + bar.handle_key(&InputKey::Tab); + assert_eq!( + bar.rows()[0].target, + BarTarget::SessionAction { + session: Uuid::from_u128(2), + action: SessionActionId::Kill, + } + ); + } + + #[test] + fn the_action_page_stays_fuzzy_searchable() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + bar.handle_key(&InputKey::Tab); + typed(&mut bar, "ren"); + assert_eq!(labels(bar.rows()), vec!["Rename session"]); + assert_eq!( + bar.selected().map(|r| r.target.clone()), + Some(BarTarget::SessionAction { + session: Uuid::from_u128(1), + action: SessionActionId::Rename, + }) + ); + } + + /// A sigil narrows the result list to one kind; the page is one kind + /// already, so `>` there is a query character like any other. + #[test] + fn a_sigil_is_an_ordinary_character_on_the_action_page() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + bar.handle_key(&InputKey::Tab); + typed(&mut bar, ">"); + assert_eq!(bar.sigil(), None); + assert!(bar.rows().is_empty()); + } + + #[test] + fn escape_returns_to_the_result_list_with_the_query_intact() { + let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me"); + typed(&mut bar, "alp"); + assert_eq!(bar.handle_key(&InputKey::Tab), BarOutcome::Stay); + assert_eq!(bar.query(), "", "the page starts on an empty query"); + // Escape is one level up, not the way out. + assert_eq!(bar.handle_key(&InputKey::Esc), BarOutcome::Stay); + assert!(!bar.on_action_page()); + assert_eq!(bar.query(), "alp"); + assert_eq!(labels(bar.rows()), vec!["alpha"]); + // Off the page it closes the bar again. + assert_eq!(bar.handle_key(&InputKey::Esc), BarOutcome::Close); + } + + #[test] + fn backspace_at_an_empty_query_returns_to_the_result_list() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + typed(&mut bar, "alp"); + bar.handle_key(&InputKey::Tab); + bar.handle_key(&InputKey::Backspace); + assert!(!bar.on_action_page()); + assert_eq!(bar.query(), "alp"); + } + + /// Backspace with something typed is ordinary editing of the page's own + /// query, not the way out. + #[test] + fn backspace_on_a_non_empty_page_query_edits_it() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + typed(&mut bar, "alp"); + bar.handle_key(&InputKey::Tab); + typed(&mut bar, "kx"); + bar.handle_key(&InputKey::Backspace); + assert!(bar.on_action_page()); + assert_eq!(bar.query(), "k"); + assert_eq!(labels(bar.rows()), vec!["Kill session"]); + } + + #[test] + fn enter_on_the_page_opens_the_action_for_that_session() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + bar.handle_key(&InputKey::Tab); + typed(&mut bar, "spawn"); + assert_eq!( + bar.handle_key(&InputKey::Enter), + BarOutcome::Open(BarTarget::SessionAction { + session: Uuid::from_u128(1), + action: SessionActionId::SpawnSimilar, + }) + ); + } + + #[test] + fn the_action_page_titles_itself_with_the_session_it_acts_on() { + let mut bar = CommandBar::new(&[named(1, "amber-falcon")], &[], "/home/me"); + bar.handle_key(&InputKey::Tab); + let rendered = render_to_string(&bar, 90, 20); + assert!(rendered.contains("act on amber-falcon"), "{rendered}"); + assert!(rendered.contains("Kill session"), "{rendered}"); + // One kind, so the group header is dropped the way a sigil drops it. + assert!(!rendered.contains("Commands"), "{rendered}"); + } } diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index eb2d869..e89e562 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -228,6 +228,10 @@ pub struct DraftPane { /// respawns there without touching the picker. Consumed by the first /// [`Self::set_projects`]; `None` once applied or when nothing seeded it. pub seed_cwd: Option, + /// Command to preselect once the preset list lands — "spawn similar"'s + /// half of the clone (#155). Consumed by the first [`Self::set_presets`]; + /// `None` once applied or when nothing seeded it. + pub seed_command: Option, /// The repository picked in step one ([`group_key`] of its main box), /// `None` until a box is picked. Looked up fresh in `groups` rather than /// cached, so a project-list refresh can't leave it stale. @@ -291,6 +295,7 @@ impl Default for DraftPane { id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), presets, selected_preset: 0, + seed_command: None, custom_command: crate::text_input::TextInput::default(), groups: Vec::new(), filter: crate::text_input::TextInput::default(), @@ -342,6 +347,27 @@ impl DraftPane { .iter() .position(|p| !p.command.is_empty()) .unwrap_or(0); + self.apply_seed_command(); + } + + /// Land a seeded command on the tile that runs it: its own preset when the + /// daemon offers one, otherwise the custom escape hatch with the command + /// typed in. + fn apply_seed_command(&mut self) { + let Some(command) = self.seed_command.take() else { + return; + }; + match self.presets.iter().position(|p| p.command == command) { + Some(index) => self.selected_preset = index, + None => { + self.selected_preset = self + .presets + .iter() + .position(|p| p.command.is_empty()) + .unwrap_or(self.selected_preset); + self.custom_command = crate::text_input::TextInput::with_value(command); + } + } } /// Replace the project snapshot: sort fresh-first (#54), group by @@ -1688,6 +1714,48 @@ mod tests { assert_eq!(draft.presets.last().unwrap().id, "custom"); } + /// "Spawn similar" (#155) seeds the cloned session's command: it lands on + /// the tile that runs it when the daemon offers one. + #[test] + fn a_seeded_command_selects_its_own_preset() { + let mut draft = DraftPane { + seed_command: Some("aider".into()), + ..DraftPane::default() + }; + draft.set_presets(vec![ + SpawnPreset { + id: "claude".into(), + label: "Claude".into(), + command: "claude".into(), + }, + SpawnPreset { + id: "aider".into(), + label: "Aider".into(), + command: "aider".into(), + }, + ]); + assert_eq!(draft.command(), "aider"); + assert!(!draft.is_custom()); + assert_eq!(draft.seed_command, None, "consumed by the first load"); + } + + /// A command no preset runs falls back to the custom tile with the command + /// typed in, so the clone still spawns what it cloned. + #[test] + fn a_seeded_command_with_no_preset_fills_the_custom_tile() { + let mut draft = DraftPane { + seed_command: Some("bash -l".into()), + ..DraftPane::default() + }; + draft.set_presets(vec![SpawnPreset { + id: "claude".into(), + label: "Claude".into(), + command: "claude".into(), + }]); + assert!(draft.is_custom()); + assert_eq!(draft.command(), "bash -l"); + } + #[test] fn left_right_cycles_presets_wrapping_into_custom() { let mut draft = DraftPane {