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: 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
34 changes: 34 additions & 0 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,40 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
return yield* terminalManager.close(body);
}

case WS_METHODS.filesystemBrowse: {
const body = stripRequestTag(request.body);
const expanded = path.resolve(yield* expandHomePath(body.partialPath));
const endsWithSep = body.partialPath.endsWith("/") || body.partialPath === "~";
const parentDir = endsWithSep ? expanded : path.dirname(expanded);
const prefix = endsWithSep ? "" : path.basename(expanded);

const names = yield* fileSystem
.readDirectory(parentDir)
.pipe(Effect.catch(() => Effect.succeed([] as string[])));

const showHidden = prefix.startsWith(".");
const filtered = names
.filter((n) => n.startsWith(prefix) && (showHidden || !n.startsWith(".")))
.slice(0, 100);

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

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

case WS_METHODS.serverGetConfig:
const keybindingsConfig = yield* keybindingsManager.loadConfigState;
return {
Expand Down
251 changes: 251 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
const THREAD_ID = "thread-browser-test" as ThreadId;
const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const PROJECT_ID = "project-1" as ProjectId;
const SECOND_PROJECT_ID = "project-2" as ProjectId;
const NOW_ISO = "2026-03-04T12:00:00.000Z";
const BASE_TIME_MS = Date.parse(NOW_ISO);
const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>";
Expand Down Expand Up @@ -356,6 +357,30 @@
};
}

function createSnapshotWithSecondaryProject(): OrchestrationReadModel {
const snapshot = createSnapshotForTargetUser({
targetMessageId: "msg-user-secondary-project-target" as MessageId,
targetText: "secondary project",
});

return {
...snapshot,
projects: [
...snapshot.projects,
{
id: SECOND_PROJECT_ID,
title: "Docs Portal",
workspaceRoot: "/repo/clients/docs-portal",
defaultModel: "gpt-5",
scripts: [],
createdAt: NOW_ISO,
updatedAt: NOW_ISO,
deletedAt: null,
},
],
};
}

function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
const tag = body._tag;
if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
Expand Down Expand Up @@ -1141,6 +1166,232 @@
}
});

it("opens the command palette from the configurable shortcut and runs a command", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-command-palette-shortcut-test" as MessageId,
targetText: "command palette shortcut test",
}),
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" },
},
},
],
};
},
});

try {
const useMetaForMod = isMacPlatform(navigator.platform);
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "k",
metaKey: useMetaForMod,
ctrlKey: !useMetaForMod,
bubbles: true,
cancelable: true,
}),
);

await expect.element(page.getByTestId("command-palette")).toBeInTheDocument();
await expect.element(page.getByText("New thread")).toBeInTheDocument();
await page.getByText("New thread").click();

await waitForURL(
mounted.router,
(path) => UUID_ROUTE_RE.test(path),
"Route should have changed to a new draft thread UUID from the command palette.",
);
} finally {
await mounted.cleanup();
}
});

it("filters command palette results as the user types", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-command-palette-search-test" as MessageId,
targetText: "command palette search test",
}),
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" },
},
},
],
};
},
});

try {
const useMetaForMod = isMacPlatform(navigator.platform);
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "k",
metaKey: useMetaForMod,
ctrlKey: !useMetaForMod,
bubbles: true,
cancelable: true,
}),
);

await expect.element(page.getByTestId("command-palette")).toBeInTheDocument();
await page.getByPlaceholder("Search commands, projects, and threads...").fill("settings");
await expect.element(page.getByText("Open settings")).toBeInTheDocument();
await expect.element(page.getByText("New thread")).not.toBeInTheDocument();
} finally {
await mounted.cleanup();
}
});

it("does not match thread actions from contextual project names", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-command-palette-project-query-test" as MessageId,
targetText: "command palette project query test",
}),
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" },
},
},
],
};
},
});

try {
const useMetaForMod = isMacPlatform(navigator.platform);
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "k",
metaKey: useMetaForMod,
ctrlKey: !useMetaForMod,
bubbles: true,
cancelable: true,
}),
);

await expect.element(page.getByTestId("command-palette")).toBeInTheDocument();
await page.getByPlaceholder("Search commands, projects, and threads...").fill("project");
await expect.element(page.getByText("Project")).toBeInTheDocument();

Check failure on line 1323 in apps/web/src/components/ChatView.browser.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

[chromium] src/components/ChatView.browser.tsx > ChatView timeline estimator parity (full app) > does not match thread actions from contextual project names

Error: strict mode violation: getByText('Project') resolved to 11 elements: 1) <span class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">Projects</span> aka page.getByText('Projects') 2) <span class="flex-1 truncate text-xs font-medium text-foreground/90">Project</span> aka locator('button').filter({ hasText: 'Project' }).locator('span') 3) <span data-slot="badge" class="relative inline-flex items-center justify-center gap-1 whitespace-nowrap rounded-sm border font-medium outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-3.5 sm:[&_svg:not([class*='size-'])]:size-3 [&_svg]:pointer-events-none [&_svg]:shrink-0 [button&,a&]…>Project</span> aka locator('header').getByText('Project') 4) <span class="truncate text-sm text-foreground">Add project</span> aka getByText('Add project') 5) <span class="truncate text-muted-foreground/70 text-xs">Browse filesystem and add a project directory</span> aka getByText('Browse filesystem and add a') 6) <div id="base-ui-_r_144_" data-slot="command-group-label" class="px-2 py-1.5 font-medium text-muted-foreground text-xs">Projects</div> aka getByTestId('command-palette').getByText('Projects', { exact: true }) 7) <span class="truncate text-sm text-foreground">Project</span> aka getByTestId('command-palette').getByText('Project', { exact: true }) 8) <span class="truncate text-muted-foreground/70 text-xs">/repo/project</span> aka getByText('/repo/project') 9) <span class="truncate text-muted-foreground/70 text-xs">Project · #main · Current thread</span> aka getByText('Project · #main · Current') 10) <div role="status" aria-live="polite" aria-atomic="true" data-slot="command-empty" class="not-empty:p-2 text-center text-muted-foreground sm:text-sm not-empty:py-6 py-10 text-sm">No matching commands, projects, or threads.</div> aka getByText('No matching commands,') ... ❯ toBeInTheDocument src/components/ChatView.browser.tsx:1323:54 Caused by: Caused by: Error: Matcher did not succeed in time. ❯ src/components/ChatView.browser.tsx:1323:6
await expect.element(page.getByText("New thread")).not.toBeInTheDocument();
} finally {
await mounted.cleanup();
}
});

it("searches projects by path and opens a new thread using the default env mode", async () => {
localStorage.setItem(
"t3code:app-settings:v1",
JSON.stringify({ defaultThreadEnvMode: "worktree" }),
);

const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotWithSecondaryProject(),
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" },
},
},
],
};
},
});

try {
const useMetaForMod = isMacPlatform(navigator.platform);
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "k",
metaKey: useMetaForMod,
ctrlKey: !useMetaForMod,
bubbles: true,
cancelable: true,
}),
);

await expect.element(page.getByTestId("command-palette")).toBeInTheDocument();
await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs");
await expect.element(page.getByText("Docs Portal")).toBeInTheDocument();

Check failure on line 1377 in apps/web/src/components/ChatView.browser.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

[chromium] src/components/ChatView.browser.tsx > ChatView timeline estimator parity (full app) > searches projects by path and opens a new thread using the default env mode

Error: strict mode violation: getByText('Docs Portal') resolved to 2 elements: 1) <span class="flex-1 truncate text-xs font-medium text-foreground/90">Docs Portal</span> aka page.getByText('Docs Portal') 2) <span class="truncate text-sm text-foreground">Docs Portal</span> aka getByTestId('command-palette').getByText('Docs Portal') ❯ toBeInTheDocument src/components/ChatView.browser.tsx:1377:58 Caused by: Caused by: Error: Matcher did not succeed in time. ❯ src/components/ChatView.browser.tsx:1377:6
await expect.element(page.getByText("/repo/clients/docs-portal")).toBeInTheDocument();
await page.getByText("Docs Portal").click();

const nextPath = await waitForURL(
mounted.router,
(path) => UUID_ROUTE_RE.test(path) && path !== `/${THREAD_ID}`,
"Route should have changed to a new draft thread UUID from the project search result.",
);
const nextThreadId = nextPath.slice(1) as ThreadId;
const draftThread = useComposerDraftStore.getState().draftThreadsByThreadId[nextThreadId];
expect(draftThread?.projectId).toBe(SECOND_PROJECT_ID);
expect(draftThread?.envMode).toBe("worktree");
} finally {
await mounted.cleanup();
}
});

it("creates a fresh draft after the previous draft thread is promoted", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import { basenameOfPath } from "../vscode-icons";
import { useTheme } from "../hooks/useTheme";
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
import BranchToolbar from "./BranchToolbar";
import { useCommandPalette } from "./CommandPalette";
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
import PlanSidebar from "./PlanSidebar";
import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
Expand Down Expand Up @@ -197,6 +198,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const setStoreThreadError = useStore((store) => store.setError);
const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
const { settings } = useAppSettings();
const { open: commandPaletteOpen } = useCommandPalette();
const timestampFormat = settings.timestampFormat;
const navigate = useNavigate();
const rawSearch = useSearch({
Expand Down Expand Up @@ -1966,7 +1968,7 @@ export default function ChatView({ threadId }: ChatViewProps) {

useEffect(() => {
const handler = (event: globalThis.KeyboardEvent) => {
if (!activeThreadId || event.defaultPrevented) return;
if (!activeThreadId || commandPaletteOpen || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(terminalState.terminalOpen),
Expand Down Expand Up @@ -2042,6 +2044,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
keybindings,
onToggleDiff,
toggleTerminalVisibility,
commandPaletteOpen,
]);

const addComposerImages = (files: File[]) => {
Expand Down
Loading
Loading