diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index e3be8baac540444f8581f49b3f4190e33b4086d5..7734d4ebe90179f5babb1724f2180587d36aadb9 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -22,7 +22,9 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+j", "command": "terminal.toggle" }, { "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+shift+o", "command": "chat.new" }, + { "key": "mod+shift+n", "command": "chat.newSameGitState" }, { "key": "mod+o", "command": "editor.openFavorite" } ] ``` @@ -46,7 +48,9 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.toggle`: open/close terminal drawer - `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) - `chat.new`: create a new chat thread for the active project +- `chat.newSameGitState`: create a new chat thread preserving the active thread's branch/worktree state - `editor.openFavorite`: open current project/worktree in the last-used editor ### Key Syntax diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index d761588e93872b3fa084d7cefde627f87a3728b5..18ecbabc03970e539c00a3e22c5652f89a6266bf 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -1,6 +1,10 @@ import { assert, describe, it } from "vitest"; -import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings"; +import { + DEFAULT_KEYBINDINGS, + compileResolvedKeybindingRule, + parseKeybindingShortcut, +} from "./keybindings"; describe("server keybindings", () => { it("parses shortcuts including plus key", () => { @@ -66,4 +70,16 @@ describe("server keybindings", () => { }), ); }); + + it("defines defaults for terminal.close and chat.newSameGitState", () => { + assert.deepInclude(DEFAULT_KEYBINDINGS, { + key: "mod+w", + command: "terminal.close", + when: "terminalFocus", + }); + assert.deepInclude(DEFAULT_KEYBINDINGS, { + key: "mod+shift+n", + command: "chat.newSameGitState", + }); + }); }); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index b9a0b4d7854f1b03b22b3c9e1eb65f51431d3222..5750b4f4bea65f97d48c2468831def0d8476b368 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -28,7 +28,9 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+j", command: "terminal.toggle" }, { 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+shift+o", command: "chat.new" }, + { key: "mod+shift+n", command: "chat.newSameGitState" }, { key: "mod+o", command: "editor.openFavorite" }, ]; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5114a24468f6291b1180b87ac2239b019a96d841..d308ec63f5d21607cdfa2be7bee2b59c09e28303 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -52,6 +52,7 @@ import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { isOpenFavoriteEditorShortcut, + isTerminalCloseShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalToggleShortcut, @@ -554,6 +555,14 @@ export default function ChatView() { return; } + if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) { + event.preventDefault(); + event.stopPropagation(); + if (!activeThread?.terminalOpen) return; + closeTerminal(activeThread.activeTerminalId); + return; + } + if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return; event.preventDefault(); event.stopPropagation(); @@ -570,7 +579,9 @@ export default function ChatView() { return () => window.removeEventListener("keydown", handler); }, [ activeThread?.terminalOpen, + activeThread?.activeTerminalId, activeThreadId, + closeTerminal, createNewTerminal, dispatch, splitTerminal, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 60392d004292a620ba887a3789aa112c1bae9f23..2a3deef38b05c89bb07949449bb611b8140f776b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -7,7 +7,7 @@ import { useTheme } from "../hooks/useTheme"; import { DEFAULT_MODEL } from "../model-logic"; import { derivePendingApprovals } from "../session-logic"; import { useStore } from "../store"; -import { isChatNewShortcut } from "../keybindings"; +import { isChatNewSameGitStateShortcut, isChatNewShortcut } from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -140,7 +140,13 @@ export default function Sidebar() { }, [state.threads]); const handleNewThread = useCallback( - (projectId: string) => { + ( + projectId: string, + options?: { + branch?: string | null; + worktreePath?: string | null; + }, + ) => { dispatch({ type: "ADD_THREAD", thread: { @@ -166,8 +172,8 @@ export default function Sidebar() { events: [], error: null, createdAt: new Date().toISOString(), - branch: null, - worktreePath: null, + branch: options?.branch ?? null, + worktreePath: options?.worktreePath ?? null, }, }); }, @@ -381,12 +387,21 @@ export default function Sidebar() { useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { - if (!isChatNewShortcut(event, keybindings)) return; - const activeThread = state.threads.find((t) => t.id === state.activeThreadId); + if (isChatNewSameGitStateShortcut(event, keybindings)) { + const projectId = activeThread?.projectId ?? state.projects[0]?.id; + if (!projectId) return; + event.preventDefault(); + handleNewThread(projectId, { + branch: activeThread?.branch ?? null, + worktreePath: activeThread?.worktreePath ?? null, + }); + return; + } + + if (!isChatNewShortcut(event, keybindings)) return; const projectId = activeThread?.projectId ?? state.projects[0]?.id; if (!projectId) return; - event.preventDefault(); handleNewThread(projectId); }; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 4ceb1b920ac8fc780477032c217513d3fcc37175..25df37f4316b54dd55753dcc57a91dade6c8d589 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -7,10 +7,12 @@ import { type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { + isChatNewSameGitStateShortcut, formatShortcutLabel, isChatNewShortcut, isOpenFavoriteEditorShortcut, isTerminalClearShortcut, + isTerminalCloseShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalToggleShortcut, @@ -83,7 +85,13 @@ const DEFAULT_BINDINGS = compile([ command: "terminal.new", whenAst: whenIdentifier("terminalFocus"), }, + { + shortcut: modShortcut("w"), + command: "terminal.close", + whenAst: whenIdentifier("terminalFocus"), + }, { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, + { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newSameGitState" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, ]); @@ -101,8 +109,8 @@ describe("isTerminalToggleShortcut", () => { }); }); -describe("split/new terminal shortcuts", () => { - it("requires terminalFocus for default split/new bindings", () => { +describe("split/new/close terminal shortcuts", () => { + it("requires terminalFocus for default split/new/close bindings", () => { assert.isFalse( isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", @@ -115,6 +123,12 @@ describe("split/new terminal shortcuts", () => { context: { terminalFocus: false }, }), ); + assert.isFalse( + isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: false }, + }), + ); }); it("matches split/new when terminalFocus is true", () => { @@ -130,6 +144,12 @@ describe("split/new terminal shortcuts", () => { context: { terminalFocus: true }, }), ); + assert.isTrue( + isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), + ); }); it("supports when expressions", () => { @@ -225,6 +245,27 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches chat.newSameGitState shortcut", () => { + assert.isTrue( + isChatNewSameGitStateShortcut( + event({ key: "n", metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + }, + ), + ); + assert.isTrue( + isChatNewSameGitStateShortcut( + event({ key: "n", ctrlKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "Linux", + }, + ), + ); + }); + it("matches editor.openFavorite shortcut", () => { assert.isTrue( isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 4efcd6fee399372b7230c508e6d47e257796a031..070dcae67b1585f3c4b2a09287423c26c0c33306 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -190,6 +190,14 @@ export function isTerminalNewShortcut( return matchesCommandShortcut(event, keybindings, "terminal.new", options); } +export function isTerminalCloseShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.close", options); +} + export function isChatNewShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, @@ -198,6 +206,14 @@ export function isChatNewShortcut( return matchesCommandShortcut(event, keybindings, "chat.new", options); } +export function isChatNewSameGitStateShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "chat.newSameGitState", options); +} + export function isOpenFavoriteEditorShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 3427e581c2aadab9dc25f8f71edd7785c226caaa..7e782a3dbd9f40176795b832045f6d250b4e3c9b 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -14,6 +14,12 @@ describe("keybindings contracts", () => { command: "terminal.toggle", }); assert.strictEqual(parsed.command, "terminal.toggle"); + + const parsedClose = keybindingRuleSchema.parse({ + key: "mod+w", + command: "terminal.close", + }); + assert.strictEqual(parsedClose.command, "terminal.close"); }); it("rejects invalid command values", () => { diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 921fcbd1dd2bf6921117bebb42c18b33531ef7f0..62dc84062959d5a8a0e4d461b3f66502121a4cbf 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -4,7 +4,9 @@ export const keybindingCommandSchema = z.enum([ "terminal.toggle", "terminal.split", "terminal.new", + "terminal.close", "chat.new", + "chat.newSameGitState", "editor.openFavorite", ]);