Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions apps/server/scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
);

Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): Thread {
return {
id: ThreadId.make("thread-1"),
Expand Down Expand Up @@ -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" });
});
});
60 changes: 60 additions & 0 deletions apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): 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<FilesystemBrowseEntry>;
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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ambiguous Tab picks first directory

Medium Severity

resolveBrowseTabCompletion enters the first filtered directory when multiple matches have no longer common prefix and nothing is highlighted. In browse mode no item is auto-highlighted, so an ambiguous Tab can replace the user's path with an arbitrary directory instead of leaving it unchanged.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 31ad61b. Configure here.

}

export function filterBrowseEntries(input: {
browseEntries: ReadonlyArray<FilesystemBrowseEntry>;
browseFilterQuery: string;
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1321,6 +1322,32 @@ function OpenCommandPaletteDialog() {
setBrowseGeneration((generation) => generation + 1);
}

function completeBrowsePathFromTab(): boolean {
if (!isBrowsing || relativePathNeedsActiveProject || isBrowsePending) {
return false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pending Tab still blurs input

Medium Severity

completeBrowsePathFromTab returns false while browse results are loading, so handleKeyDown allows the browser’s default Tab behavior. On slower filesystem browse requests, pressing Tab after typing a path can move focus out of the input instead of keeping the path picker ready for completion.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 31ad61b. Configure here.

}

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
Expand Down Expand Up @@ -1440,6 +1467,11 @@ function OpenCommandPaletteDialog() {
}

function handleKeyDown(event: KeyboardEvent<HTMLInputElement>): void {
if (event.key === "Tab" && completeBrowsePathFromTab()) {
event.preventDefault();
return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shift Tab triggers completion

Low Severity

handleKeyDown treats Shift+Tab the same as plain Tab. When a completion is available, reverse tabbing mutates the browse path instead of moving focus backward, which breaks expected keyboard navigation in the dialog.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 31ad61b. Configure here.

}

if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") {
event.preventDefault();
void submitAddProjectCloneFlow();
Expand Down
Loading