Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7bef645
feat(web): add extensible command palette
binbandit Mar 15, 2026
17073d5
perf(web): avoid closed-state command palette work
binbandit Mar 15, 2026
869181f
fix(web): align command palette search and shortcut
binbandit Mar 15, 2026
897b9ed
command palette add thread timestamps and projects (#1)
huxcrux Mar 15, 2026
e7ffca4
feat(contracts): add filesystem.browse WS method and request schema
eggfriedrice24 Mar 15, 2026
cefe926
feat(contracts): add browseFilesystem to NativeApi interface
eggfriedrice24 Mar 15, 2026
ea52bb3
feat(server): add filesystem browse endpoint with directory listing
eggfriedrice24 Mar 15, 2026
c6a9033
feat(web): wire browseFilesystem to WS transport
eggfriedrice24 Mar 15, 2026
7bc39e6
feat(web): add project browser with filesystem browsing to command pa…
eggfriedrice24 Mar 15, 2026
1b13b76
fix(web): add windows path support and prevent stale debounce actions…
eggfriedrice24 Mar 15, 2026
f546911
Merge upstream/main into t3code/extensible-command-palette
binbandit Mar 20, 2026
366dae5
Merge upstream/main into t3code/extensible-command-palette
binbandit Mar 20, 2026
a1b5299
feat: update command palette styles and state management
Noojuno Mar 16, 2026
bd703a0
Fix command palette path handling
Noojuno Mar 19, 2026
75258e1
Add command palette project browsing
Noojuno Mar 19, 2026
36a8834
Refactor command palette and fix browser tests
Noojuno Mar 19, 2026
90efe4a
Fix command palette path browsing
Noojuno Mar 20, 2026
b1068fe
Merge branch 'main' into t3code/extensible-command-palette
juliusmarminge Mar 24, 2026
22ffaf3
Improve command palette filesystem browsing
Noojuno Mar 24, 2026
2bf6f66
Improve command palette filesystem browsing
Noojuno Mar 24, 2026
5bb9824
Fix command palette browse-up guard
Noojuno Mar 24, 2026
9af2ae9
Merge remote-tracking branch 'origin/main' into t3code/extensible-com…
Noojuno Mar 24, 2026
5ff0686
fix: use directory path for browse-up check after merge regression
Noojuno Mar 24, 2026
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: 2 additions & 0 deletions KEYBINDINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`](
{ "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },
{ "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" },
{ "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" },
{ "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" },
{ "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" },
{ "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" },
{ "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" },
Expand Down Expand Up @@ -50,6 +51,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged
- `terminal.split`: split terminal (in focused terminal context by default)
- `terminal.new`: create new terminal (in focused terminal context by default)
- `terminal.close`: close/kill the focused terminal (in focused terminal context by default)
- `commandPalette.toggle`: open or close the global command palette
- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state
- `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`))
- `editor.openFavorite`: open current project/worktree in the last-used editor
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ key: "mod+n", command: "terminal.new", when: "terminalFocus" },
{ key: "mod+w", command: "terminal.close", when: "terminalFocus" },
{ key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
{ key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
Copy link
Contributor

Choose a reason for hiding this comment

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

I think there's probably justification for triggering the command pallette even when the terminal is open. I doubt mod+k is gonna be a terminal keyboard shortcut, but I could see myself working in the terminal and wanting to navigate away and it being annoying to click out to focus the window

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah true, this wasn't something i've considered, as I have previously seen the two as two different states of interaction.

  1. Working with the ai and the threads
  2. Working with the terminal and running additional commands rather than asking ai to do it.

Copy link
Contributor

Choose a reason for hiding this comment

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

The main time I would run into it I think is if I'm working on two worktrees of the same thing (like T3 Code).

I will usually have my dev command running into the terminal on the worktree I was last testing, then I'll kill that command and want to navigate to the next worktree I need to check on and run the command there. Being able to Ctrl+K would be useful there (and also how I've been doing it in my command pallette branch)

{ key: "mod+n", command: "chat.new", when: "!terminalFocus" },
{ key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
{ key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
Expand Down
164 changes: 164 additions & 0 deletions apps/server/src/wsServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1593,6 +1593,170 @@ describe("WebSocket Server", () => {
});
});

it("supports filesystem.browse with directory-only results", async () => {
const workspace = makeTempDir("t3code-ws-filesystem-browse-");
fs.mkdirSync(path.join(workspace, "components"), { recursive: true });
fs.mkdirSync(path.join(workspace, "composables"), { recursive: true });
fs.writeFileSync(path.join(workspace, "composer.ts"), "export {};\n", "utf8");

server = await createTestServer({ cwd: "/test" });
const addr = server.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;

const [ws] = await connectAndAwaitWelcome(port);
connections.push(ws);

const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, {
partialPath: path.join(workspace, "comp"),
});

expect(response.error).toBeUndefined();
expect(response.result).toEqual({
parentPath: workspace,
entries: [
{
name: "components",
fullPath: path.join(workspace, "components"),
},
{
name: "composables",
fullPath: path.join(workspace, "composables"),
},
],
});
});

it("includes hidden directories when browsing a full directory", async () => {
const workspace = makeTempDir("t3code-ws-filesystem-browse-hidden-");
fs.mkdirSync(path.join(workspace, ".config"), { recursive: true });
fs.mkdirSync(path.join(workspace, "docs"), { recursive: true });

server = await createTestServer({ cwd: "/test" });
const addr = server.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;

const [ws] = await connectAndAwaitWelcome(port);
connections.push(ws);

const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, {
partialPath: `${workspace}/`,
});

expect(response.error).toBeUndefined();
expect(response.result).toEqual({
parentPath: workspace,
entries: [
{
name: ".config",
fullPath: path.join(workspace, ".config"),
},
{
name: "docs",
fullPath: path.join(workspace, "docs"),
},
],
});
});

it("skips unreadable or broken browse entries instead of failing the request", async () => {
if (process.platform === "win32") {
return;
}

const workspace = makeTempDir("t3code-ws-filesystem-browse-broken-entry-");
fs.mkdirSync(path.join(workspace, "docs"), { recursive: true });
fs.symlinkSync(path.join(workspace, "missing-target"), path.join(workspace, "broken-link"));

server = await createTestServer({ cwd: "/test" });
const addr = server.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;

const [ws] = await connectAndAwaitWelcome(port);
connections.push(ws);

const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, {
partialPath: `${workspace}/`,
});

expect(response.error).toBeUndefined();
expect(response.result).toEqual({
parentPath: workspace,
entries: [
{
name: "docs",
fullPath: path.join(workspace, "docs"),
},
],
});
});

it("resolves relative filesystem.browse paths against the provided cwd", async () => {
const workspace = makeTempDir("t3code-ws-filesystem-browse-relative-");
fs.mkdirSync(path.join(workspace, "apps"), { recursive: true });
fs.mkdirSync(path.join(workspace, "docs"), { recursive: true });

server = await createTestServer({ cwd: "/test" });
const addr = server.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;

const [ws] = await connectAndAwaitWelcome(port);
connections.push(ws);

const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, {
partialPath: "../d",
cwd: path.join(workspace, "apps"),
});

expect(response.error).toBeUndefined();
expect(response.result).toEqual({
parentPath: workspace,
entries: [
{
name: "docs",
fullPath: path.join(workspace, "docs"),
},
],
});
});

it("rejects relative filesystem.browse paths without a cwd", async () => {
server = await createTestServer({ cwd: "/test" });
const addr = server.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;

const [ws] = await connectAndAwaitWelcome(port);
connections.push(ws);

const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, {
partialPath: "./docs",
});

expect(response.result).toBeUndefined();
expect(response.error?.message).toContain(
"Relative filesystem browse paths require a current project.",
);
});

it("rejects windows-style filesystem.browse paths on non-windows hosts", async () => {
if (process.platform === "win32") {
return;
}

server = await createTestServer({ cwd: "/test" });
const addr = server.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;

const [ws] = await connectAndAwaitWelcome(port);
connections.push(ws);

const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, {
partialPath: "C:\\Work\\Repo",
});

expect(response.result).toBeUndefined();
expect(response.error?.message).toContain("Windows-style paths are only supported on Windows.");
});

it("supports projects.writeFile within the workspace root", async () => {
const workspace = makeTempDir("t3code-ws-write-file-");

Expand Down
94 changes: 94 additions & 0 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,40 @@ const isServerNotRunningError = (error: Error): boolean => {
);
};

function isWindowsDrivePath(value: string): boolean {
return /^[a-zA-Z]:([/\\]|$)/.test(value);
}

function isWindowsAbsolutePath(value: string): boolean {
return value.startsWith("\\\\") || isWindowsDrivePath(value);
}

function isExplicitRelativePath(value: string): boolean {
return (
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith(".\\") ||
value.startsWith("..\\")
);
}

function resolveFilesystemBrowseInputPath(input: {
cwd: string | undefined;
path: Path.Path;
partialPath: string;
}): Effect.Effect<string | null, never, Path.Path> {
return Effect.gen(function* () {
if (!isExplicitRelativePath(input.partialPath)) {
return input.path.resolve(yield* expandHomePath(input.partialPath));
}
if (!input.cwd) {
return null;
}
const expandedCwd = yield* expandHomePath(input.cwd);
return input.path.resolve(expandedCwd, input.partialPath);
});
}

function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void {
socket.end(
`HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` +
Expand Down Expand Up @@ -866,6 +900,66 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
return yield* terminalManager.close(body);
}

case WS_METHODS.filesystemBrowse: {
const body = stripRequestTag(request.body);
if (process.platform !== "win32" && isWindowsAbsolutePath(body.partialPath)) {
return yield* new RouteRequestError({
message: "Windows-style paths are only supported on Windows.",
});
}
const resolvedInputPath = yield* resolveFilesystemBrowseInputPath({
cwd: body.cwd,
path,
partialPath: body.partialPath,
});
if (resolvedInputPath === null) {
return yield* new RouteRequestError({
message: "Relative filesystem browse paths require a current project.",
});
}

const expanded = resolvedInputPath;
const endsWithSep = /[\\/]$/.test(body.partialPath) || body.partialPath === "~";
const parentDir = endsWithSep ? expanded : path.dirname(expanded);
const prefix = endsWithSep ? "" : path.basename(expanded);

const names = yield* fileSystem.readDirectory(parentDir).pipe(
Effect.mapError(
(cause) =>
new RouteRequestError({
message: `Unable to browse '${parentDir}': ${Cause.pretty(Cause.fail(cause)).trim()}`,
}),
),
);

const showHidden = endsWithSep || prefix.startsWith(".");
const lowerPrefix = prefix.toLowerCase();
const filtered = names
.filter(
(name) =>
name.toLowerCase().startsWith(lowerPrefix) && (showHidden || !name.startsWith(".")),
)
.toSorted((left, right) => left.localeCompare(right));

const entries = yield* Effect.forEach(
filtered,
(name) =>
fileSystem.stat(path.join(parentDir, name)).pipe(
Effect.match({
onFailure: () => null,
onSuccess: (s) =>
s.type === "Directory" ? { name, fullPath: path.join(parentDir, name) } : null,
}),
),
{ concurrency: 16 },
);

return {
parentPath: parentDir,
entries: entries.filter(Boolean),
};
}

case WS_METHODS.serverGetConfig:
const keybindingsConfig = yield* keybindingsManager.loadConfigState;
return {
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/commandPaletteStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { create } from "zustand";

interface CommandPaletteStore {
open: boolean;
setOpen: (open: boolean) => void;
toggleOpen: () => void;
}

export const useCommandPaletteStore = create<CommandPaletteStore>((set) => ({
open: false,
setOpen: (open) => set({ open }),
toggleOpen: () => set((state) => ({ open: !state.open })),
}));
Loading