From 31ad61b81d2e241ccdc2650e532f9c2b713c71e4 Mon Sep 17 00:00:00 2001 From: aShanki Date: Wed, 6 May 2026 20:07:56 +1000 Subject: [PATCH] Add tab completion to project path picker --- apps/server/scripts/cli.ts | 2 - apps/web/src/components/ChatView.browser.tsx | 73 +++++++++++++++++++ .../components/CommandPalette.logic.test.ts | 39 ++++++++++ .../src/components/CommandPalette.logic.ts | 60 +++++++++++++++ apps/web/src/components/CommandPalette.tsx | 32 ++++++++ 5 files changed, 204 insertions(+), 2 deletions(-) diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index efaa2b3b6c..18b634cfd4 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -151,8 +151,6 @@ const buildCmd = Command.make( cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", - // Windows needs shell mode to resolve `.cmd` shims on PATH. - shell: process.platform === "win32", }), ); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 578dc7c045..5a5064f497 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -4572,6 +4572,79 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("completes browse directories with Tab while typing a path", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-add-project-tab" as MessageId, + targetText: "command palette add project tab", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Development/") { + return { + parentPath: "~/Development/", + entries: [{ name: "codex", fullPath: "~/Development/codex" }], + }; + } + + return { + parentPath: "~/", + entries: [ + { name: "Desktop", fullPath: "~/Desktop" }, + { name: "Development", fullPath: "~/Development" }, + ], + }; + } + + return undefined; + }, + }); + + try { + await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Add project", { exact: true }).click(); + await palette.getByText("Local folder", { exact: true }).click(); + + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Dev"); + await dispatchInputKey(browseInput, { key: "Tab" }); + await expect.element(browseInput).toHaveValue("~/Development/"); + + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/co"); + await dispatchInputKey(browseInput, { key: "Tab" }); + await expect.element(browseInput).toHaveValue("~/Development/codex/"); + } finally { + await mounted.cleanup(); + } + }); + it("shows clone destination controls after resolving an add project repository", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 38b44f3f6a..74a6ef8387 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -4,12 +4,15 @@ import type { Thread } from "../types"; import { buildThreadActionItems, filterCommandPaletteGroups, + resolveBrowseTabCompletion, type CommandPaletteGroup, } from "./CommandPalette.logic"; const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); +const browseEntry = (name: string) => ({ name, fullPath: `~/Development/${name}` }); + function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), @@ -164,3 +167,39 @@ describe("buildThreadActionItems", () => { expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]); }); }); + +describe("resolveBrowseTabCompletion", () => { + it("enters a single matching directory", () => { + expect( + resolveBrowseTabCompletion({ + filteredEntries: [browseEntry("Documents")], + browseFilterQuery: "doc", + highlightedEntry: null, + exactEntry: null, + }), + ).toEqual({ _tag: "enterDirectory", name: "Documents" }); + }); + + it("extends to the common prefix when multiple directories match", () => { + expect( + resolveBrowseTabCompletion({ + filteredEntries: [browseEntry("Projects"), browseEntry("ProjectTemplates")], + browseFilterQuery: "pr", + highlightedEntry: null, + exactEntry: null, + }), + ).toEqual({ _tag: "replaceLeaf", leaf: "Project" }); + }); + + it("prefers the highlighted directory over common-prefix completion", () => { + const projects = browseEntry("Projects"); + expect( + resolveBrowseTabCompletion({ + filteredEntries: [projects, browseEntry("ProjectTemplates")], + browseFilterQuery: "pr", + highlightedEntry: projects, + exactEntry: null, + }), + ).toEqual({ _tag: "enterDirectory", name: "Projects" }); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 3f4997e215..3d3bfd04a3 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -52,6 +52,66 @@ export interface CommandPaletteView { export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; +export type BrowseTabCompletion = + | { + readonly _tag: "enterDirectory"; + readonly name: string; + } + | { + readonly _tag: "replaceLeaf"; + readonly leaf: string; + }; + +function commonPrefix(values: ReadonlyArray): string { + const [firstValue, ...restValues] = values; + if (!firstValue) { + return ""; + } + + let prefixLength = firstValue.length; + for (const value of restValues) { + let index = 0; + const maxLength = Math.min(prefixLength, value.length); + while (index < maxLength && firstValue[index]?.toLowerCase() === value[index]?.toLowerCase()) { + index += 1; + } + prefixLength = index; + if (prefixLength === 0) { + break; + } + } + + return firstValue.slice(0, prefixLength); +} + +export function resolveBrowseTabCompletion(input: { + filteredEntries: ReadonlyArray; + browseFilterQuery: string; + highlightedEntry: FilesystemBrowseEntry | null; + exactEntry: FilesystemBrowseEntry | null; +}): BrowseTabCompletion | null { + const preferredEntry = + input.highlightedEntry ?? + input.exactEntry ?? + (input.filteredEntries.length === 1 ? (input.filteredEntries[0] ?? null) : null); + + if (preferredEntry) { + return { _tag: "enterDirectory", name: preferredEntry.name }; + } + + if (input.filteredEntries.length === 0) { + return null; + } + + const prefix = commonPrefix(input.filteredEntries.map((entry) => entry.name)); + if (prefix.length > input.browseFilterQuery.length) { + return { _tag: "replaceLeaf", leaf: prefix }; + } + + const firstEntry = input.filteredEntries[0]; + return firstEntry ? { _tag: "enterDirectory", name: firstEntry.name } : null; +} + export function filterBrowseEntries(input: { browseEntries: ReadonlyArray; browseFilterQuery: string; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 027688a284..3673da813f 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -96,6 +96,7 @@ import { getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, + resolveBrowseTabCompletion, } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; @@ -1321,6 +1322,32 @@ function OpenCommandPaletteDialog() { setBrowseGeneration((generation) => generation + 1); } + function completeBrowsePathFromTab(): boolean { + if (!isBrowsing || relativePathNeedsActiveProject || isBrowsePending) { + return false; + } + + const completion = resolveBrowseTabCompletion({ + filteredEntries: filteredBrowseEntries, + browseFilterQuery, + highlightedEntry: highlightedBrowseEntry, + exactEntry: exactBrowseEntry, + }); + if (!completion) { + return false; + } + + setHighlightedItemValue(null); + if (completion._tag === "enterDirectory") { + setQuery(appendBrowsePathSegment(query, completion.name)); + setBrowseGeneration((generation) => generation + 1); + return true; + } + + setQuery(`${getBrowseDirectoryPath(query)}${completion.leaf}`); + return true; + } + // Resolve the add-project path from browse data when available. When the // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the // directory itself. Otherwise the user typed a partial leaf name, so we need @@ -1440,6 +1467,11 @@ function OpenCommandPaletteDialog() { } function handleKeyDown(event: KeyboardEvent): void { + if (event.key === "Tab" && completeBrowsePathFromTab()) { + event.preventDefault(); + return; + } + if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { event.preventDefault(); void submitAddProjectCloneFlow();