diff --git a/packages/tui/src/component/dialog-open.tsx b/packages/tui/src/component/dialog-open.tsx index 2102ea99ec5..b19caf7f880 100644 --- a/packages/tui/src/component/dialog-open.tsx +++ b/packages/tui/src/component/dialog-open.tsx @@ -151,6 +151,7 @@ export function DialogOpen() { options={options()} current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined} focusCurrent={false} + sectionNavigation={true} preserveSelection={selectionMoved()} onMove={() => setSelectionMoved(true)} onFilter={setFilter} diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index fbcf9da936d..03b10e3d2d3 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -40,6 +40,7 @@ export interface DialogSelectProps { bindings?: readonly KeymapCommand[] current?: T focusCurrent?: boolean + sectionNavigation?: boolean } type DialogSelectActionBase = { @@ -327,6 +328,15 @@ export function DialogSelect(props: DialogSelectProps) { moveTo(moveSelection(store.selected, { count: flat().length, delta: direction, policy: "wrap" }), true) } + function moveSection(direction: 1 | -1) { + if (props.locked) return + const sections = grouped().filter(([_, options]) => options.length > 0) + if (sections.length === 0) return + const current = sections.findIndex(([category]) => category === selected()?.category) + const section = sections[(current + direction + sections.length) % sections.length] + moveTo(flat().indexOf(section[1][0]), true) + } + function moveTo(next: number, center = false, preserve = true) { setFocusedAction(undefined) setStore("selected", next) @@ -488,6 +498,22 @@ export function DialogSelect(props: DialogSelectProps) { ] : []), ...(props.bindings ?? []), + ...(props.sectionNavigation + ? [ + { + bind: "alt+up", + title: "Previous section", + group: "Dialog", + run: () => moveSection(-1), + }, + { + bind: "alt+down", + title: "Next section", + group: "Dialog", + run: () => moveSection(1), + }, + ] + : []), ], } }) diff --git a/packages/tui/test/cli/tui/dialog-open.test.tsx b/packages/tui/test/cli/tui/dialog-open.test.tsx index a3e76726ff2..0937178b6f2 100644 --- a/packages/tui/test/cli/tui/dialog-open.test.tsx +++ b/packages/tui/test/cli/tui/dialog-open.test.tsx @@ -186,6 +186,94 @@ test("preserves a moved project when sessions arrive", async () => { } }) +test("option arrows jump between sections", async () => { + const handler: FetchHandler = (url) => { + if (url.pathname === "/api/session") + return json({ + data: [ + { + id: "ses_recent", + projectID: "proj_recent", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 2 }, + title: "Recent session", + location: { directory: "/tmp/opencode/recent" }, + }, + ], + cursor: {}, + }) + if (url.pathname === "/api/project") + return json([ + { + id: "proj_recent", + canonical: "/tmp/opencode/recent", + name: "Recent project", + time: { created: 1, updated: 2 }, + sandboxes: [], + }, + ]) + return undefined + } + + const next = await renderOpen(handler) + try { + await next.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Recent project")) + next.app.mockInput.pressArrow("down", { meta: true }) + next.app.mockInput.pressEnter() + await next.app.waitFor(() => next.route.data.type === "home") + expect(next.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/recent" } }) + } finally { + await next.dispose() + } + + const previous = await renderOpen(handler) + try { + await previous.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Recent project")) + previous.app.mockInput.pressArrow("up", { meta: true }) + previous.app.mockInput.pressEnter() + await previous.app.waitFor(() => previous.route.data.type === "home") + expect(previous.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/recent" } }) + } finally { + await previous.dispose() + } +}) + +test("option arrows stay in the only visible section", async () => { + const fixture = await renderOpen((url) => { + if (url.pathname === "/api/session") return json({ data: [], cursor: {} }) + if (url.pathname !== "/api/project") return undefined + return json([ + { + id: "proj_effect", + canonical: "/tmp/effect", + name: "Effect", + time: { created: 1, updated: 2 }, + sandboxes: [], + }, + { + id: "proj_opencode", + canonical: "/tmp/opencode", + name: "OpenCode", + time: { created: 1, updated: 1 }, + sandboxes: [], + }, + ]) + }) + + try { + await fixture.app.waitForFrame((frame) => frame.includes("Effect") && frame.includes("OpenCode")) + await fixture.app.mockInput.typeText("Effect") + await fixture.app.waitForFrame((frame) => frame.includes("Effect") && !frame.includes("OpenCode")) + fixture.app.mockInput.pressArrow("down", { meta: true }) + fixture.app.mockInput.pressEnter() + await fixture.app.waitFor(() => fixture.route.data.type === "home") + expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/effect" } }) + } finally { + await fixture.dispose() + } +}) + async function renderOpen( handler: FetchHandler, beforeOpen?: (contexts: {