From 247060de6814305a5efe392bbbac88707113c87d Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 2 Sep 2026 15:56:35 +0100 Subject: [PATCH] feat(maestro): open the spawn form on the project picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker now leads the form in both clients and takes focus on open, so Tab walks from it into the command tiles. Opening the form also preselects the project of the session you were last on — a worktree session seeds its base repository — so 'n' then Enter spawns another session there. --- crates/maestro-tui/src/app.rs | 64 +++++++++++- crates/maestro-tui/src/draft.rs | 180 +++++++++++++++++++++++--------- web/src/main.ts | 11 +- web/src/spawnForm.ts | 22 ++-- web/src/spawnFormEnter.test.ts | 25 ++++- web/src/spawnModal.test.ts | 10 ++ web/src/spawnModal.ts | 3 + web/src/spawnSimilar.test.ts | 22 +++- web/src/spawnSimilar.ts | 14 +++ 9 files changed, 285 insertions(+), 66 deletions(-) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 22d88d4..8ebfc6e 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -572,9 +572,27 @@ impl App { Some(None) } - /// Put the spawn form up with a fresh draft behind it. + /// 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 + /// worktree session seeds its *base repository*, not its checkout: the + /// daemon never adopts an existing worktree (docs/WORKTREES.md), same + /// rule as web/src/spawnSimilar.ts. fn open_spawn_modal(&mut self) { - self.draft = Some(DraftPane::default()); + let seed_cwd = self.state.active_session().and_then(|sid| { + self.catalog + .snapshot() + .sessions + .iter() + .find(|s| s.id == sid) + .map(|s| match &s.worktree { + Some(wt) => wt.base_repo.clone(), + None => s.cwd.clone(), + }) + }); + let mut draft = DraftPane::default(); + draft.seed_cwd = seed_cwd; + self.draft = Some(draft); self.state.open_spawn_modal(); } @@ -831,6 +849,9 @@ impl App { return; }; draft.home_dir = home_dir; + // The fast path resolved its own project; its pick wins over the + // focused session's, so the seed never lands on top of it. + draft.seed_cwd = None; if !presets.is_empty() { draft.set_presets(presets); } @@ -1640,6 +1661,41 @@ mod tests { assert_eq!(draft.command(), "claude"); } + /// Opening the form preselects the project of the session in the pane, so + /// `n` then Enter spawns another session there. + #[test] + fn opening_the_spawn_modal_seeds_the_focused_sessions_project() { + use maestro_protocol::{CatalogEvent, SessionWorktree}; + + let sid = Uuid::from_u128(21); + let mut app = make_app(); + app.catalog.apply_event(CatalogEvent::Snapshot { + sessions: vec![test_session(sid, "/repos/maestro")], + }); + app.state.attach_to_focused(sid); + app.dispatch(Action::OpenSpawnModal); + assert_eq!( + app.draft.as_ref().unwrap().seed_cwd.as_deref(), + Some(std::path::Path::new("/repos/maestro")) + ); + + // A worktree session seeds its base repository: the daemon never + // adopts an existing worktree (docs/WORKTREES.md). + let mut wt_session = test_session(sid, "/repos/maestro/worktrees/flugelhorn"); + wt_session.worktree = Some(SessionWorktree { + base_repo: "/repos/maestro".into(), + path: "/repos/maestro/worktrees/flugelhorn".into(), + }); + app.catalog.apply_event(CatalogEvent::Snapshot { + sessions: vec![wt_session], + }); + app.dispatch(Action::OpenSpawnModal); + assert_eq!( + app.draft.as_ref().unwrap().seed_cwd.as_deref(), + Some(std::path::Path::new("/repos/maestro")) + ); + } + /// A valid form flows through: pick a project (set directly — the picker /// itself is covered in `draft.rs`), select the custom tile, type a /// command, then submit from the Spawn row. @@ -1653,11 +1709,11 @@ mod tests { draft.base_repo = Some(std::path::PathBuf::from("/srv/work")); draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work")); draft.selected_preset = draft.presets.len() - 1; // custom - app.handle_draft_input(&InputKey::Tab); // preset → custom command + app.handle_draft_input(&InputKey::Tab); // dir picker → preset + app.handle_draft_input(&InputKey::Tab); // → custom command for c in "claude".chars() { app.handle_draft_input(&InputKey::char(c)); } - app.handle_draft_input(&InputKey::Tab); // → dir picker app.handle_draft_input(&InputKey::Tab); // → name for c in "crimson-otter".chars() { app.handle_draft_input(&InputKey::char(c)); diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index f8d941a..c9077b4 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -221,6 +221,11 @@ pub struct DraftPane { pub cwd_raw_visible: bool, pub cwd_raw: crate::text_input::TextInput, pub picked_cwd: Option, + /// Directory to preselect once the project scan lands — the project of + /// the session the operator was last looking at, so `n` then Enter + /// 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, /// 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. @@ -293,6 +298,7 @@ impl Default for DraftPane { cwd_raw_visible: false, cwd_raw: crate::text_input::TextInput::default(), picked_cwd: None, + seed_cwd: None, selected_group_key: None, worktree_choice: WorktreeChoice::New, name: crate::text_input::TextInput::default(), @@ -315,7 +321,9 @@ impl Default for DraftPane { branches_for: None, needs_branches: false, error: None, - focused_field: DraftField::Preset, + // The picker owns the keyboard on open: typing filters projects + // straight away, and Tab from here reaches the command tiles. + focused_field: DraftField::DirPicker, home_dir: String::new(), } } @@ -358,6 +366,26 @@ impl DraftPane { }) .collect(); self.highlighted = self.highlighted.min(self.entry_count().saturating_sub(1)); + self.apply_seed_cwd(); + } + + /// Preselect the seeded directory now that the scan has landed: highlight + /// its repository box and pick it, so Enter from the untouched picker + /// spawns there. A seed no box matches is dropped — the operator picks by + /// hand, exactly as before. + fn apply_seed_cwd(&mut self) { + let Some(seed) = self.seed_cwd.take() else { + return; + }; + let hit = self.visible_groups().iter().enumerate().find_map(|(i, g)| { + let matches = g.main.path == seed || g.worktrees.iter().any(|w| w.project.path == seed); + matches.then(|| (i, (*g).clone())) + }); + let Some((index, group)) = hit else { + return; + }; + self.highlighted = index; + self.select_group(&group); } /// Fill the local name batch from `GET /api/names`, seeding the field @@ -705,14 +733,14 @@ impl DraftPane { } fn field_order(&self) -> Vec { - let mut order = vec![DraftField::Preset]; - if self.is_custom() { - order.push(DraftField::CustomCommand); - } - order.push(DraftField::DirPicker); + let mut order = vec![DraftField::DirPicker]; if self.cwd_raw_visible { order.push(DraftField::CwdRaw); } + order.push(DraftField::Preset); + if self.is_custom() { + order.push(DraftField::CustomCommand); + } order.push(DraftField::Name); order.push(DraftField::Advanced); if !self.advanced_collapsed { @@ -1116,15 +1144,17 @@ impl DraftPane { + u16::from(self.error.is_some()); let list_capacity = (inner.height.saturating_sub(fixed)).max(1) as usize; - let mut constraints = vec![Constraint::Length(1)]; - if show_custom { - constraints.push(Constraint::Length(1)); - } - constraints.push(Constraint::Length(1)); + // The picker leads the form: it is where focus lands on open, and Tab + // walks down from it into the command tiles. + let mut constraints = vec![Constraint::Length(1)]; // dir filter constraints.push(Constraint::Length(list_capacity as u16)); if show_raw { constraints.push(Constraint::Length(1)); } + constraints.push(Constraint::Length(1)); // command tiles + if show_custom { + constraints.push(Constraint::Length(1)); + } constraints.push(Constraint::Length(1)); // name constraints.push(Constraint::Length(1)); // advanced header if show_wt_choice { @@ -1163,36 +1193,6 @@ impl DraftPane { // Tracks where the terminal cursor belongs for the focused text field. let mut cursor: Option<(u16, u16)> = None; - // -- command presets --------------------------------------------------- - let tiles_row = next_row(); - let mut tiles = vec![Span::raw(format!("{: 0 { - tiles.push(Span::raw(" ")); - } - let mut style = Style::default(); - if i == self.selected_preset { - style = style.add_modifier(Modifier::REVERSED); - } - tiles.push(Span::styled(format!("[{}]", preset.label), style)); - } - f.render_widget(Paragraph::new(Line::from(tiles)), tiles_row); - - // -- custom command (only while a custom tile is selected) ------------- - if show_custom { - let row = next_row(); - draw_gutter(f, row, ""); - let col = draw_text_field( - f, - &self.custom_command, - row, - field_style(DraftField::CustomCommand), - ); - if self.focused_field == DraftField::CustomCommand { - cursor = Some((col, row.y)); - } - } - // -- directory picker ---------------------------------------------------- let filter_row = next_row(); draw_gutter(f, filter_row, "dir:"); @@ -1280,6 +1280,36 @@ impl DraftPane { } } + // -- command presets --------------------------------------------------- + let tiles_row = next_row(); + let mut tiles = vec![Span::raw(format!("{: 0 { + tiles.push(Span::raw(" ")); + } + let mut style = Style::default(); + if i == self.selected_preset { + style = style.add_modifier(Modifier::REVERSED); + } + tiles.push(Span::styled(format!("[{}]", preset.label), style)); + } + f.render_widget(Paragraph::new(Line::from(tiles)), tiles_row); + + // -- custom command (only while a custom tile is selected) ------------- + if show_custom { + let row = next_row(); + draw_gutter(f, row, ""); + let col = draw_text_field( + f, + &self.custom_command, + row, + field_style(DraftField::CustomCommand), + ); + if self.focused_field == DraftField::CustomCommand { + cursor = Some((col, row.y)); + } + } + // -- name + reroll --------------------------------------------------------- let name_row = next_row(); draw_gutter(f, name_row, "name:"); @@ -1681,7 +1711,10 @@ mod tests { #[test] fn left_right_cycles_presets_wrapping_into_custom() { - let mut draft = DraftPane::default(); + let mut draft = DraftPane { + focused_field: DraftField::Preset, + ..DraftPane::default() + }; draft.on_input(&InputKey::Right); assert_eq!(draft.command(), "opencode"); draft.on_input(&InputKey::Left); @@ -1704,17 +1737,22 @@ mod tests { draft.selected_preset = draft.presets.len() - 1; assert!(draft.is_custom()); assert_eq!( - draft.field_order()[..2], - [DraftField::Preset, DraftField::CustomCommand], - "custom command sits between presets and the dir picker" + draft.field_order()[..3], + [ + DraftField::DirPicker, + DraftField::Preset, + DraftField::CustomCommand + ], + "the picker leads; custom command sits right after the tiles" ); + draft.next_field(); // DirPicker → Preset draft.next_field(); assert_eq!(draft.focused_field, DraftField::CustomCommand); typed(&mut draft, "bash -l"); assert_eq!(draft.command(), "bash -l"); // Tab walks past the hidden fields in order. draft.next_field(); - assert_eq!(draft.focused_field, DraftField::DirPicker); + assert_eq!(draft.focused_field, DraftField::Name); } #[test] @@ -1777,8 +1815,11 @@ mod tests { ], Utc::now(), ); - draft.next_field(); // Preset → DirPicker - assert_eq!(draft.focused_field, DraftField::DirPicker); + assert_eq!( + draft.focused_field, + DraftField::DirPicker, + "the form opens with the project filter focused" + ); typed(&mut draft, "maes"); assert_eq!( draft.visible_groups().len(), @@ -1890,6 +1931,47 @@ mod tests { assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit); } + #[test] + fn a_seeded_directory_is_preselected_so_one_enter_spawns_there() { + let mut draft = DraftPane { + seed_cwd: Some(PathBuf::from("/repos/other")), + ..DraftPane::default() + }; + draft.set_projects( + vec![ + project("maestro", "/repos/maestro", 1, Some("claude")), + project("other", "/repos/other", 2, Some("zsh")), + ], + Utc::now(), + ); + assert_eq!( + draft.picked_cwd.as_deref(), + Some(Path::new("/repos/other")), + "the seeded project is picked before a key is pressed" + ); + assert_eq!(draft.selection(), 1, "and its row is the highlighted one"); + assert_eq!( + draft.on_input(&InputKey::Enter), + DraftInputResult::Submit, + "one Enter spawns in the seeded project" + ); + assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/other"))); + } + + #[test] + fn a_seed_no_project_matches_leaves_the_picker_untouched() { + let mut draft = DraftPane { + seed_cwd: Some(PathBuf::from("/gone")), + ..DraftPane::default() + }; + draft.set_projects( + vec![project("maestro", "/repos/maestro", 1, None)], + Utc::now(), + ); + assert_eq!(draft.picked_cwd, None); + assert_eq!(draft.selection(), 0); + } + #[test] fn dir_picker_enter_on_other_directory_never_submits_directly_97() { let mut draft = DraftPane::default(); @@ -2140,7 +2222,7 @@ mod tests { 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(GUTTER as u16 + 2, 1)); + assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 0)); } #[test] diff --git a/web/src/main.ts b/web/src/main.ts index d9a0e60..db4541e 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -22,7 +22,7 @@ import { } from "./fastForward.js"; import { FALLBACK_PRESETS } from "./spawnForm.js"; import { openSpawnModal } from "./spawnModal.js"; -import { spawnSimilarSeed } from "./spawnSimilar.js"; +import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js"; import { mountHud, type Hud } from "./hud.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { createMultiView, type MultiView } from "./multiview.js"; @@ -243,7 +243,14 @@ function requestOpenSpawn(): void { // (#100). "Spawn similar" and the fast-forward key below deliberately keep // the old behaviour: both act on the session in front of you, so a split // beside it is the point. - openSpawnModal({ onSuccess: (session) => mountSpawned(session, { newTab: true }) }); + // The session you were last on preselects its project: `n` then Enter spawns + // another session there without touching the picker. + const focused = mv?.focusedSessionId() ?? null; + const current = focused ? store.snapshot().sessions.get(focused) : undefined; + openSpawnModal({ + seed: current ? spawnSimilarProjectSeed(current) : undefined, + onSuccess: (session) => mountSpawned(session, { newTab: true }), + }); } document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn()); document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => { diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 5202db3..1d81a24 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -122,12 +122,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { const form = document.createElement("form"); form.className = "spawn-form"; form.innerHTML = ` -
- Command -
- -
Project
+
+ Command +
+ +
Name
@@ -896,8 +896,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { e.preventDefault(); if (target === filterInput) { - const top = visibleGroups(filterInput.value)[0]; - if (top && top.main.path !== selectedGroup?.main.path) selectProject(top); + // Resolve the filter to a project only when the current pick isn't one + // of the matches: a filter typed past it means the operator moved on, + // while a pick that still matches (including the seeded one the form + // opens with) keeps its worktree choice. + const visible = visibleGroups(filterInput.value); + const stillMatches = + selectedGroup != null && visible.some((g) => g.main.path === selectedGroup?.main.path); + if (!stillMatches && visible[0]) selectProject(visible[0]); } form.requestSubmit(); }); diff --git a/web/src/spawnFormEnter.test.ts b/web/src/spawnFormEnter.test.ts index d856603..638a927 100644 --- a/web/src/spawnFormEnter.test.ts +++ b/web/src/spawnFormEnter.test.ts @@ -68,9 +68,12 @@ describe("spawnForm Enter-to-submit (#97)", () => { for (let i = 0; i < 40; i++) await Promise.resolve(); }; - async function mount(projects: Project[]): Promise { + async function mount( + projects: Project[], + seed?: Parameters[0]["seed"], + ): Promise { stubFetch(projects); - handle = createSpawnForm({ onSuccess() {} }); + handle = createSpawnForm({ seed, onSuccess() {} }); document.body.appendChild(handle.el); await drain(); return handle.el; @@ -232,6 +235,24 @@ describe("spawnForm Enter-to-submit (#97)", () => { expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true); }); + it("submits in the seeded project rather than the freshest one", async () => { + // What `n` then Enter must do: the modal opens parked on the project of + // the session you were last on (OTHER here), even though MAESTRO's + // worktree is the freshest row in the list. + const form = await mount([MAESTRO, WT_A, OTHER], { + cwd: OTHER.path, + cwdRawVisible: false, + }); + expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(form.querySelector(".project-filter")!); + + expect(spy).toHaveBeenCalledTimes(1); + expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true); + }); + it("does not clobber an already-chosen worktree when the filter still matches it", async () => { const form = await mount([MAESTRO, WT_A]); box(form, MAESTRO.path).click(); diff --git a/web/src/spawnModal.test.ts b/web/src/spawnModal.test.ts index eb7a15f..0184a5e 100644 --- a/web/src/spawnModal.test.ts +++ b/web/src/spawnModal.test.ts @@ -52,6 +52,16 @@ describe("spawn modal", () => { expect(host.querySelector(".form-actions .cancel")).toBeTruthy(); }); + it("opens with the project filter focused, ahead of the command tiles", async () => { + openSpawnModal({ onSuccess: () => {} }); + await flush(); + + const form = modal()!.querySelector("form.spawn-form")!; + expect(document.activeElement).toBe(form.querySelector(".project-filter")); + const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent); + expect(fields.slice(0, 2)).toEqual(["Project", "Command"]); + }); + it("titles a seeded open as a clone", async () => { openSpawnModal({ seed: { command: "claude" }, onSuccess: () => {} }); await flush(); diff --git a/web/src/spawnModal.ts b/web/src/spawnModal.ts index 0093c1a..b90af6c 100644 --- a/web/src/spawnModal.ts +++ b/web/src/spawnModal.ts @@ -59,6 +59,9 @@ export function openSpawnModal(opts: SpawnModalOptions): void { }, }); dialog.appendChild(form.el); + // The project filter owns the keyboard on open: typing narrows the list and + // Tab from here reaches the command tiles, matching the TUI's draft pane. + form.el.querySelector(".project-filter")?.focus(); function close(): void { if (closeCurrent === close) closeCurrent = null; diff --git a/web/src/spawnSimilar.test.ts b/web/src/spawnSimilar.test.ts index e80e9ac..f23320f 100644 --- a/web/src/spawnSimilar.test.ts +++ b/web/src/spawnSimilar.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { spawnSimilarSeed } from "./spawnSimilar.js"; +import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js"; import type { Session } from "./types.js"; function session(overrides: Partial = {}): Session { @@ -43,3 +43,23 @@ describe("spawnSimilarSeed (#28)", () => { expect(seed.name).toBeUndefined(); }); }); + +describe("spawnSimilarProjectSeed", () => { + it("seeds the project alone, leaving command and worktree at their defaults", () => { + const seed = spawnSimilarProjectSeed(session()); + expect(seed.cwd).toBe("/repos/app"); + expect(seed.cwdRawVisible).toBe(false); + expect(seed.command).toBeUndefined(); + expect(seed.worktreeEnabled).toBeUndefined(); + }); + + it("seeds a worktree session's base repository", () => { + const seed = spawnSimilarProjectSeed( + session({ + cwd: "/repos/app-worktrees/feature", + worktree: { path: "/repos/app-worktrees/feature", base_repo: "/repos/app" }, + }), + ); + expect(seed.cwd).toBe("/repos/app"); + }); +}); diff --git a/web/src/spawnSimilar.ts b/web/src/spawnSimilar.ts index 4b3abbb..74c51b9 100644 --- a/web/src/spawnSimilar.ts +++ b/web/src/spawnSimilar.ts @@ -28,3 +28,17 @@ export function spawnSimilarSeed(s: Session): SpawnFormSeed { cwdRawVisible: false, }; } + +/** The project half of a clone: the session's repository, nothing else. + * + * The plain spawn modal opens on this so the picker is already parked on the + * project you were last looking at — one Enter spawns there — while the + * command, worktree decision and name stay at the form's own defaults. Same + * base-repository rule as `spawnSimilarSeed`. */ +export function spawnSimilarProjectSeed(s: Session): SpawnFormSeed { + const wt = s.worktree ?? null; + return { + cwd: wt ? wt.base_repo : s.cwd, + cwdRawVisible: false, + }; +}