From 5b0a0c2001e061737fc826aee263642268c30848 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 15:38:44 +0100 Subject: [PATCH 1/3] feat(maestro): Enter submits the spawn form from any field (#97) A project-box or worktree-choice picker button that isn't yet the active pick takes the Enter to choose it instead; the next Enter, once it's active, submits. The project filter resolves to the top filtered project rather than sending whatever cwd was last set. The raw-cwd autocomplete's own Enter (accept the highlight) keeps working via e.defaultPrevented. --- web/src/spawnForm.ts | 36 ++++- web/src/spawnFormEnter.test.ts | 240 +++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 web/src/spawnFormEnter.test.ts diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 42a0ca1..39712cd 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -549,11 +549,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { if (match) selectPreset(match.id); } + /** Groups with at least one member (itself or a worktree) matching `query`. */ + function visibleGroups(query: string): ProjectGroup[] { + return groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0); + } + function renderProjects(): void { projectsEl.replaceChildren(); groups = groupProjects(sortProjects(discovered, new Date())); - const query = filterInput.value; - const visible = groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0); + const visible = visibleGroups(filterInput.value); for (const group of visible) projectsEl.appendChild(projectBox(group)); if (discovered.length > 0 && visible.length === 0) { const empty = document.createElement("p"); @@ -862,6 +866,34 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { // ---- live dir autocomplete on the raw cwd input (NAP-329) ------------ const autocomplete = attachDirAutocomplete(cwdRaw); + // ---- Enter submits from anywhere (#97) -------------------------------- + // A project-box/wt-choice that isn't yet the active pick takes the Enter to + // choose it instead; the next Enter, now that it's active, submits. + // dirAutocomplete's own Enter (accept the highlight) already preventDefaults + // when it handles the key, which the `e.defaultPrevented` guard defers to. + form.addEventListener("keydown", (e) => { + if (e.key !== "Enter" || e.defaultPrevented) return; + const target = e.target as HTMLElement; + + if (target instanceof HTMLButtonElement) { + if (!target.classList.contains("project-box") && !target.classList.contains("wt-choice")) return; + e.preventDefault(); + if (!target.classList.contains("is-active")) { + target.click(); + return; + } + form.requestSubmit(); + return; + } + + e.preventDefault(); + if (target === filterInput) { + const top = visibleGroups(filterInput.value)[0]; + if (top && top.main.path !== selectedGroup?.main.path) selectProject(top); + } + form.requestSubmit(); + }); + // ---- submit ---------------------------------------------------------- form.addEventListener("submit", async (e) => { e.preventDefault(); diff --git a/web/src/spawnFormEnter.test.ts b/web/src/spawnFormEnter.test.ts new file mode 100644 index 0000000..a05c4b3 --- /dev/null +++ b/web/src/spawnFormEnter.test.ts @@ -0,0 +1,240 @@ +// @vitest-environment happy-dom +// Enter submits the draft form from any field, at any point (#97). The two +// exceptions: a picker button that isn't yet the active choice takes the +// Enter to *choose* it instead (the next Enter submits), and the project +// filter must resolve to the highlighted project rather than sending +// whatever cwd was already set. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js"; +import type { Project } from "./types.js"; + +const MAESTRO: Project = { + path: "/home/user/tea/maestro", + name: "maestro", + is_worktree: false, + last_activity: "2026-08-20T10:00:00Z", +}; + +const WT_A: Project = { + path: "/home/user/tea/maestro/worktrees/bulgarians", + name: "bulgarians", + is_worktree: true, + base_repo: MAESTRO.path, + last_activity: "2026-08-22T10:00:00Z", +}; + +const OTHER: Project = { + path: "/home/user/tea/rev", + name: "rev", + is_worktree: false, + last_activity: "2026-08-19T10:00:00Z", +}; + +function stubFetch(projects: Project[]): void { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const body = url.includes("/api/projects") + ? projects + : url.includes("/api/names") + ? { names: ["amber-fox"] } + : url.includes("/api/branches") + ? [] + : url.includes("/api/dirs") + ? { path: "/opt", entries: ["nomatch-zzz"], truncated: false } + : { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }; + return new Response(JSON.stringify(body), { status: 200 }); + }), + ); +} + +describe("spawnForm Enter-to-submit (#97)", () => { + let handle: SpawnFormHandle | null = null; + + beforeEach(() => { + vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") }); + }); + + afterEach(() => { + handle?.destroy(); + handle = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + const drain = async () => { + for (let i = 0; i < 40; i++) await Promise.resolve(); + }; + + async function mount(projects: Project[]): Promise { + stubFetch(projects); + handle = createSpawnForm({ onSuccess() {} }); + document.body.appendChild(handle.el); + await drain(); + return handle.el; + } + + function submitSpy(): ReturnType { + return vi.fn(); + } + + function pressEnter(el: HTMLElement): { defaultPrevented: boolean } { + const evt = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }); + el.dispatchEvent(evt); + return { defaultPrevented: evt.defaultPrevented }; + } + + const box = (form: HTMLFormElement, path: string) => + form.querySelector(`.project-box[data-cwd="${path}"]`)!; + + describe("submits immediately from ordinary fields", () => { + it("name field", async () => { + const form = await mount([MAESTRO]); + box(form, MAESTRO.path).click(); + await drain(); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(form.querySelector("input[name=name]")!); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("worktree branch field", async () => { + const form = await mount([MAESTRO]); + box(form, MAESTRO.path).click(); + await drain(); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(form.querySelector("input[name=worktree_branch]")!); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("custom command field", async () => { + const form = await mount([MAESTRO]); + box(form, MAESTRO.path).click(); + await drain(); + form.querySelector('.cmd-tiles .tile[data-id="custom"]')!.click(); + const raw = form.querySelector(".cmd-raw")!; + raw.value = "bash -l"; + raw.dispatchEvent(new Event("input")); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(raw); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("raw cwd field once the autocomplete dropdown is closed", async () => { + const form = await mount([MAESTRO]); + form.querySelector(".project-other")!.click(); + const raw = form.querySelector("input[name=cwd_raw]")!; + raw.value = "/opt/xyz-nothing-here"; + raw.dispatchEvent(new Event("input")); + await vi.advanceTimersByTimeAsync(300); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(raw); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("does not submit while the raw cwd autocomplete has a highlighted suggestion", async () => { + const form = await mount([MAESTRO]); + form.querySelector(".project-other")!.click(); + const raw = form.querySelector("input[name=cwd_raw]")!; + raw.focus(); + raw.value = "/opt/"; + raw.dispatchEvent(new Event("input", { bubbles: true })); + await vi.advanceTimersByTimeAsync(150); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + const { defaultPrevented } = pressEnter(raw); + + expect(defaultPrevented).toBe(true); + expect(spy).not.toHaveBeenCalled(); + expect(raw.value).toBe("/opt/nomatch-zzz/"); + }); + }); + + describe("two-step picker: first Enter chooses, second submits", () => { + it("project box", async () => { + const form = await mount([MAESTRO]); + const b = box(form, MAESTRO.path); + b.focus(); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + const first = pressEnter(b); + expect(first.defaultPrevented).toBe(true); + expect(b.classList.contains("is-active")).toBe(true); + expect(spy).not.toHaveBeenCalled(); + + pressEnter(b); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("worktree choice", async () => { + const form = await mount([MAESTRO, WT_A]); + box(form, MAESTRO.path).click(); + await drain(); + const choice = form.querySelector(`.wt-choice[data-cwd="${WT_A.path}"]`)!; + expect(choice.classList.contains("is-active")).toBe(false); // "+ new" is the default + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(choice); + expect(choice.classList.contains("is-active")).toBe(true); + expect(spy).not.toHaveBeenCalled(); + + pressEnter(choice); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe("project filter", () => { + it("submits with the highlighted (first matching) project, not the filter text", async () => { + const form = await mount([MAESTRO, OTHER]); + const filter = form.querySelector(".project-filter")!; + filter.value = "rev"; + filter.dispatchEvent(new Event("input")); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(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(); + await drain(); + form.querySelector(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click(); + await drain(); + + const filter = form.querySelector(".project-filter")!; + filter.value = "maestro"; + filter.dispatchEvent(new Event("input")); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(filter); + + expect(spy).toHaveBeenCalledTimes(1); + expect( + form.querySelector(`.wt-choice[data-cwd="${WT_A.path}"]`)!.classList.contains( + "is-active", + ), + ).toBe(true); + }); + }); +}); From 8a6cb81d3a5cfd22859ac7c95a7085fcb6b42965 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 15:50:28 +0100 Subject: [PATCH 2/3] feat(maestro): Enter submits the TUI draft pane from any field (#97) DirPicker mirrors the web filter/picker split: typing narrows and the next Enter submits with the highlighted project directly (nothing was chosen yet); an explicit Up/Down move onto a different row is mid-selection, so Enter there only chooses it and a further Enter (now the active pick) submits. WorktreeChoice, BranchExisting, Preset and BranchMode already apply their highlight eagerly on Up/Down/Left/Right, so Enter always submits there. Advanced and WorktreeToggle keep activating on Enter, matching the web form's own button controls. --- crates/maestro-tui/src/draft.rs | 177 ++++++++++++++++++++++++++++---- 1 file changed, 157 insertions(+), 20 deletions(-) diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index 9a4c360..cbc074c 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -199,6 +199,12 @@ pub struct DraftPane { /// Highlighted entry within the visible step-one list; the implicit /// `Other directory…` entry sits at index `visible_groups().len()`. pub highlighted: usize, + /// Set by an explicit Up/Down move, cleared by typing (#97): typing + /// mirrors the web form's plain filter input, where Enter always + /// resolves and submits; an explicit arrow move mirrors Tab landing on + /// a not-yet-active picker button there, where a first Enter only + /// chooses. See `dir_picker_choice_active` at the Enter match arm. + dir_picker_navigated: bool, pub cwd_raw_visible: bool, pub cwd_raw: crate::text_input::TextInput, pub picked_cwd: Option, @@ -270,6 +276,7 @@ impl Default for DraftPane { groups: Vec::new(), filter: crate::text_input::TextInput::default(), highlighted: 0, + dir_picker_navigated: false, cwd_raw_visible: false, cwd_raw: crate::text_input::TextInput::default(), picked_cwd: None, @@ -769,6 +776,7 @@ impl DraftPane { if count == 0 { return; } + self.dir_picker_navigated = true; self.highlighted = ((self.selection() as i32) + delta).rem_euclid(count) as usize; } @@ -804,6 +812,17 @@ impl DraftPane { rows } + /// Whether the highlighted step-one row is already the picked group + /// (#97): once true, a further Enter submits instead of re-picking it. + /// Always false on the trailing "Other directory…" row — picking it is + /// never a no-op, so it always takes the Enter to reveal the raw field. + fn dir_picker_choice_active(&self) -> bool { + let visible = self.visible_groups(); + let sel = self.selection(); + sel < visible.len() + && self.selected_group_key.as_ref() == Some(&group_key(&visible[sel].main)) + } + /// Act on the highlighted step-one box: reveals step two, defaulting per /// rule 4. The trailing entry reveals the free-text path input and /// clears both steps (rule 3). @@ -933,27 +952,33 @@ impl DraftPane { self.prev_field(); DraftInputResult::Consumed } - InputKey::Enter => match self.focused_field { - DraftField::Submit => DraftInputResult::Submit, - DraftField::DirPicker => { - self.select_entry(); + // Enter submits from any field (#97), mirroring web/src/spawnForm.ts. + // Two fields keep their own Enter instead: `DirPicker`, whose + // highlighted row isn't the picked one until a first Enter chooses + // it (the next Enter, now that it's active, falls through to + // submit); and the disclosure/toggle pair (`Advanced`, + // `WorktreeToggle`), which activate on Enter like a button rather + // than submitting, same as the web form's own `