| author | |
| committer | |
| log | c42d6a6a6489c89d56830cdba7fee752afef5c14 |
| tree | b7dee09d52d94618d54d2559af562a6702f088df |
| parent | 5242bbbaded1389ba117dae8089f6d7f505bc2a5 |
- Extend keybinding contracts/defaults with `terminal.close` and `chat.newSameGitState`
- Wire web handlers for `mod+w` to close focused terminal and `mod+shift+n` to open a thread preserving branch/worktree
- Update docs and keybinding tests across server, web, and contracts9 files changed, 123 insertions(+), 10 deletions(-)
KEYBINDINGS.md+4| ... | ... | @@ -22,7 +22,9 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( |
| 22 | 22 | { "key": "mod+j", "command": "terminal.toggle" }, |
| 23 | 23 | { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, |
| 24 | 24 | { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, |
| 25 | { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, | |
| 25 | 26 | { "key": "mod+shift+o", "command": "chat.new" }, |
| 27 | { "key": "mod+shift+n", "command": "chat.newSameGitState" }, | |
| 26 | 28 | { "key": "mod+o", "command": "editor.openFavorite" } |
| 27 | 29 | ] |
| 28 | 30 | ``` |
| ... | ... | @@ -46,7 +48,9 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged |
| 46 | 48 | - `terminal.toggle`: open/close terminal drawer |
| 47 | 49 | - `terminal.split`: split terminal (in focused terminal context by default) |
| 48 | 50 | - `terminal.new`: create new terminal (in focused terminal context by default) |
| 51 | - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) | |
| 49 | 52 | - `chat.new`: create a new chat thread for the active project |
| 53 | - `chat.newSameGitState`: create a new chat thread preserving the active thread's branch/worktree state | |
| 50 | 54 | - `editor.openFavorite`: open current project/worktree in the last-used editor |
| 51 | 55 | |
| 52 | 56 | ### Key Syntax |
apps/server/src/keybindings.test.ts+17-1| ... | ... | @@ -1,6 +1,10 @@ |
| 1 | 1 | import { assert, describe, it } from "vitest"; |
| 2 | 2 | |
| 3 | import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings"; | |
| 3 | import { | |
| 4 | DEFAULT_KEYBINDINGS, | |
| 5 | compileResolvedKeybindingRule, | |
| 6 | parseKeybindingShortcut, | |
| 7 | } from "./keybindings"; | |
| 4 | 8 | |
| 5 | 9 | describe("server keybindings", () => { |
| 6 | 10 | it("parses shortcuts including plus key", () => { |
| ... | ... | @@ -66,4 +70,16 @@ describe("server keybindings", () => { |
| 66 | 70 | }), |
| 67 | 71 | ); |
| 68 | 72 | }); |
| 73 | ||
| 74 | it("defines defaults for terminal.close and chat.newSameGitState", () => { | |
| 75 | assert.deepInclude(DEFAULT_KEYBINDINGS, { | |
| 76 | key: "mod+w", | |
| 77 | command: "terminal.close", | |
| 78 | when: "terminalFocus", | |
| 79 | }); | |
| 80 | assert.deepInclude(DEFAULT_KEYBINDINGS, { | |
| 81 | key: "mod+shift+n", | |
| 82 | command: "chat.newSameGitState", | |
| 83 | }); | |
| 84 | }); | |
| 69 | 85 | }); |
apps/server/src/keybindings.ts+2| ... | ... | @@ -28,7 +28,9 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 28 | 28 | { key: "mod+j", command: "terminal.toggle" }, |
| 29 | 29 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, |
| 30 | 30 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, |
| 31 | { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, | |
| 31 | 32 | { key: "mod+shift+o", command: "chat.new" }, |
| 33 | { key: "mod+shift+n", command: "chat.newSameGitState" }, | |
| 32 | 34 | { key: "mod+o", command: "editor.openFavorite" }, |
| 33 | 35 | ]; |
| 34 | 36 |
apps/web/src/components/ChatView.tsx+11| ... | ... | @@ -52,6 +52,7 @@ import BranchToolbar from "./BranchToolbar"; |
| 52 | 52 | import GitActionsControl from "./GitActionsControl"; |
| 53 | 53 | import { |
| 54 | 54 | isOpenFavoriteEditorShortcut, |
| 55 | isTerminalCloseShortcut, | |
| 55 | 56 | isTerminalNewShortcut, |
| 56 | 57 | isTerminalSplitShortcut, |
| 57 | 58 | isTerminalToggleShortcut, |
| ... | ... | @@ -554,6 +555,14 @@ export default function ChatView() { |
| 554 | 555 | return; |
| 555 | 556 | } |
| 556 | 557 | |
| 558 | if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) { | |
| 559 | event.preventDefault(); | |
| 560 | event.stopPropagation(); | |
| 561 | if (!activeThread?.terminalOpen) return; | |
| 562 | closeTerminal(activeThread.activeTerminalId); | |
| 563 | return; | |
| 564 | } | |
| 565 | ||
| 557 | 566 | if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return; |
| 558 | 567 | event.preventDefault(); |
| 559 | 568 | event.stopPropagation(); |
| ... | ... | @@ -570,7 +579,9 @@ export default function ChatView() { |
| 570 | 579 | return () => window.removeEventListener("keydown", handler); |
| 571 | 580 | }, [ |
| 572 | 581 | activeThread?.terminalOpen, |
| 582 | activeThread?.activeTerminalId, | |
| 573 | 583 | activeThreadId, |
| 584 | closeTerminal, | |
| 574 | 585 | createNewTerminal, |
| 575 | 586 | dispatch, |
| 576 | 587 | splitTerminal, |
apps/web/src/components/Sidebar.tsx+22-7| ... | ... | @@ -7,7 +7,7 @@ import { useTheme } from "../hooks/useTheme"; |
| 7 | 7 | import { DEFAULT_MODEL } from "../model-logic"; |
| 8 | 8 | import { derivePendingApprovals } from "../session-logic"; |
| 9 | 9 | import { useStore } from "../store"; |
| 10 | import { isChatNewShortcut } from "../keybindings"; | |
| 10 | import { isChatNewSameGitStateShortcut, isChatNewShortcut } from "../keybindings"; | |
| 11 | 11 | import { |
| 12 | 12 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 13 | 13 | DEFAULT_THREAD_TERMINAL_ID, |
| ... | ... | @@ -140,7 +140,13 @@ export default function Sidebar() { |
| 140 | 140 | }, [state.threads]); |
| 141 | 141 | |
| 142 | 142 | const handleNewThread = useCallback( |
| 143 | (projectId: string) => { | |
| 143 | ( | |
| 144 | projectId: string, | |
| 145 | options?: { | |
| 146 | branch?: string | null; | |
| 147 | worktreePath?: string | null; | |
| 148 | }, | |
| 149 | ) => { | |
| 144 | 150 | dispatch({ |
| 145 | 151 | type: "ADD_THREAD", |
| 146 | 152 | thread: { |
| ... | ... | @@ -166,8 +172,8 @@ export default function Sidebar() { |
| 166 | 172 | events: [], |
| 167 | 173 | error: null, |
| 168 | 174 | createdAt: new Date().toISOString(), |
| 169 | branch: null, | |
| 170 | worktreePath: null, | |
| 175 | branch: options?.branch ?? null, | |
| 176 | worktreePath: options?.worktreePath ?? null, | |
| 171 | 177 | }, |
| 172 | 178 | }); |
| 173 | 179 | }, |
| ... | ... | @@ -381,12 +387,21 @@ export default function Sidebar() { |
| 381 | 387 | |
| 382 | 388 | useEffect(() => { |
| 383 | 389 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 384 | if (!isChatNewShortcut(event, keybindings)) return; | |
| 385 | ||
| 386 | 390 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 391 | if (isChatNewSameGitStateShortcut(event, keybindings)) { | |
| 392 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; | |
| 393 | if (!projectId) return; | |
| 394 | event.preventDefault(); | |
| 395 | handleNewThread(projectId, { | |
| 396 | branch: activeThread?.branch ?? null, | |
| 397 | worktreePath: activeThread?.worktreePath ?? null, | |
| 398 | }); | |
| 399 | return; | |
| 400 | } | |
| 401 | ||
| 402 | if (!isChatNewShortcut(event, keybindings)) return; | |
| 387 | 403 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; |
| 388 | 404 | if (!projectId) return; |
| 389 | ||
| 390 | 405 | event.preventDefault(); |
| 391 | 406 | handleNewThread(projectId); |
| 392 | 407 | }; |
apps/web/src/keybindings.test.ts+43-2| ... | ... | @@ -7,10 +7,12 @@ import { |
| 7 | 7 | type ResolvedKeybindingsConfig, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import { |
| 10 | isChatNewSameGitStateShortcut, | |
| 10 | 11 | formatShortcutLabel, |
| 11 | 12 | isChatNewShortcut, |
| 12 | 13 | isOpenFavoriteEditorShortcut, |
| 13 | 14 | isTerminalClearShortcut, |
| 15 | isTerminalCloseShortcut, | |
| 14 | 16 | isTerminalNewShortcut, |
| 15 | 17 | isTerminalSplitShortcut, |
| 16 | 18 | isTerminalToggleShortcut, |
| ... | ... | @@ -83,7 +85,13 @@ const DEFAULT_BINDINGS = compile([ |
| 83 | 85 | command: "terminal.new", |
| 84 | 86 | whenAst: whenIdentifier("terminalFocus"), |
| 85 | 87 | }, |
| 88 | { | |
| 89 | shortcut: modShortcut("w"), | |
| 90 | command: "terminal.close", | |
| 91 | whenAst: whenIdentifier("terminalFocus"), | |
| 92 | }, | |
| 86 | 93 | { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, |
| 94 | { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newSameGitState" }, | |
| 87 | 95 | { shortcut: modShortcut("o"), command: "editor.openFavorite" }, |
| 88 | 96 | ]); |
| 89 | 97 | |
| ... | ... | @@ -101,8 +109,8 @@ describe("isTerminalToggleShortcut", () => { |
| 101 | 109 | }); |
| 102 | 110 | }); |
| 103 | 111 | |
| 104 | describe("split/new terminal shortcuts", () => { | |
| 105 | it("requires terminalFocus for default split/new bindings", () => { | |
| 112 | describe("split/new/close terminal shortcuts", () => { | |
| 113 | it("requires terminalFocus for default split/new/close bindings", () => { | |
| 106 | 114 | assert.isFalse( |
| 107 | 115 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { |
| 108 | 116 | platform: "MacIntel", |
| ... | ... | @@ -115,6 +123,12 @@ describe("split/new terminal shortcuts", () => { |
| 115 | 123 | context: { terminalFocus: false }, |
| 116 | 124 | }), |
| 117 | 125 | ); |
| 126 | assert.isFalse( | |
| 127 | isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, { | |
| 128 | platform: "Linux", | |
| 129 | context: { terminalFocus: false }, | |
| 130 | }), | |
| 131 | ); | |
| 118 | 132 | }); |
| 119 | 133 | |
| 120 | 134 | it("matches split/new when terminalFocus is true", () => { |
| ... | ... | @@ -130,6 +144,12 @@ describe("split/new terminal shortcuts", () => { |
| 130 | 144 | context: { terminalFocus: true }, |
| 131 | 145 | }), |
| 132 | 146 | ); |
| 147 | assert.isTrue( | |
| 148 | isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, { | |
| 149 | platform: "Linux", | |
| 150 | context: { terminalFocus: true }, | |
| 151 | }), | |
| 152 | ); | |
| 133 | 153 | }); |
| 134 | 154 | |
| 135 | 155 | it("supports when expressions", () => { |
| ... | ... | @@ -225,6 +245,27 @@ describe("chat/editor shortcuts", () => { |
| 225 | 245 | ); |
| 226 | 246 | }); |
| 227 | 247 | |
| 248 | it("matches chat.newSameGitState shortcut", () => { | |
| 249 | assert.isTrue( | |
| 250 | isChatNewSameGitStateShortcut( | |
| 251 | event({ key: "n", metaKey: true, shiftKey: true }), | |
| 252 | DEFAULT_BINDINGS, | |
| 253 | { | |
| 254 | platform: "MacIntel", | |
| 255 | }, | |
| 256 | ), | |
| 257 | ); | |
| 258 | assert.isTrue( | |
| 259 | isChatNewSameGitStateShortcut( | |
| 260 | event({ key: "n", ctrlKey: true, shiftKey: true }), | |
| 261 | DEFAULT_BINDINGS, | |
| 262 | { | |
| 263 | platform: "Linux", | |
| 264 | }, | |
| 265 | ), | |
| 266 | ); | |
| 267 | }); | |
| 268 | ||
| 228 | 269 | it("matches editor.openFavorite shortcut", () => { |
| 229 | 270 | assert.isTrue( |
| 230 | 271 | isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, { |
apps/web/src/keybindings.ts+16| ... | ... | @@ -190,6 +190,14 @@ export function isTerminalNewShortcut( |
| 190 | 190 | return matchesCommandShortcut(event, keybindings, "terminal.new", options); |
| 191 | 191 | } |
| 192 | 192 | |
| 193 | export function isTerminalCloseShortcut( | |
| 194 | event: ShortcutEventLike, | |
| 195 | keybindings: ResolvedKeybindingsConfig, | |
| 196 | options?: ShortcutMatchOptions, | |
| 197 | ): boolean { | |
| 198 | return matchesCommandShortcut(event, keybindings, "terminal.close", options); | |
| 199 | } | |
| 200 | ||
| 193 | 201 | export function isChatNewShortcut( |
| 194 | 202 | event: ShortcutEventLike, |
| 195 | 203 | keybindings: ResolvedKeybindingsConfig, |
| ... | ... | @@ -198,6 +206,14 @@ export function isChatNewShortcut( |
| 198 | 206 | return matchesCommandShortcut(event, keybindings, "chat.new", options); |
| 199 | 207 | } |
| 200 | 208 | |
| 209 | export function isChatNewSameGitStateShortcut( | |
| 210 | event: ShortcutEventLike, | |
| 211 | keybindings: ResolvedKeybindingsConfig, | |
| 212 | options?: ShortcutMatchOptions, | |
| 213 | ): boolean { | |
| 214 | return matchesCommandShortcut(event, keybindings, "chat.newSameGitState", options); | |
| 215 | } | |
| 216 | ||
| 201 | 217 | export function isOpenFavoriteEditorShortcut( |
| 202 | 218 | event: ShortcutEventLike, |
| 203 | 219 | keybindings: ResolvedKeybindingsConfig, |
packages/contracts/src/keybindings.test.ts+6| ... | ... | @@ -14,6 +14,12 @@ describe("keybindings contracts", () => { |
| 14 | 14 | command: "terminal.toggle", |
| 15 | 15 | }); |
| 16 | 16 | assert.strictEqual(parsed.command, "terminal.toggle"); |
| 17 | ||
| 18 | const parsedClose = keybindingRuleSchema.parse({ | |
| 19 | key: "mod+w", | |
| 20 | command: "terminal.close", | |
| 21 | }); | |
| 22 | assert.strictEqual(parsedClose.command, "terminal.close"); | |
| 17 | 23 | }); |
| 18 | 24 | |
| 19 | 25 | it("rejects invalid command values", () => { |
packages/contracts/src/keybindings.ts+2| ... | ... | @@ -4,7 +4,9 @@ export const keybindingCommandSchema = z.enum([ |
| 4 | 4 | "terminal.toggle", |
| 5 | 5 | "terminal.split", |
| 6 | 6 | "terminal.new", |
| 7 | "terminal.close", | |
| 7 | 8 | "chat.new", |
| 9 | "chat.newSameGitState", | |
| 8 | 10 | "editor.openFavorite", |
| 9 | 11 | ]); |
| 10 | 12 |