diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index a47867e..6ba960c 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -601,9 +601,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(); } @@ -860,6 +878,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); } @@ -1676,6 +1697,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. @@ -1689,11 +1745,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 a7f74da..23ac046 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -24,7 +24,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"; @@ -255,7 +255,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, + }; +}