| 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,7 +22,9 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( |
| 22 | { "key": "mod+j", "command": "terminal.toggle" }, | 22 | { "key": "mod+j", "command": "terminal.toggle" }, |
| 23 | { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, | 23 | { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, |
| 24 | { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, | 24 | { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, |
| 25 | { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, | ||
| 25 | { "key": "mod+shift+o", "command": "chat.new" }, | 26 | { "key": "mod+shift+o", "command": "chat.new" }, |
| 27 | { "key": "mod+shift+n", "command": "chat.newSameGitState" }, | ||
| 26 | { "key": "mod+o", "command": "editor.openFavorite" } | 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,7 +48,9 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged |
| 46 | - `terminal.toggle`: open/close terminal drawer | 48 | - `terminal.toggle`: open/close terminal drawer |
| 47 | - `terminal.split`: split terminal (in focused terminal context by default) | 49 | - `terminal.split`: split terminal (in focused terminal context by default) |
| 48 | - `terminal.new`: create new terminal (in focused terminal context by default) | 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 | - `chat.new`: create a new chat thread for the active project | 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 | - `editor.openFavorite`: open current project/worktree in the last-used editor | 54 | - `editor.openFavorite`: open current project/worktree in the last-used editor |
| 51 | 55 | ||
| 52 | ### Key Syntax | 56 | ### Key Syntax |
apps/server/src/keybindings.test.ts+17-1| ... | @@ -1,6 +1,10 @@ | ... | @@ -1,6 +1,10 @@ |
| 1 | import { assert, describe, it } from "vitest"; | 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 | describe("server keybindings", () => { | 9 | describe("server keybindings", () => { |
| 6 | it("parses shortcuts including plus key", () => { | 10 | it("parses shortcuts including plus key", () => { |
| ... | @@ -66,4 +70,16 @@ describe("server keybindings", () => { | ... | @@ -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,7 +28,9 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 28 | { key: "mod+j", command: "terminal.toggle" }, | 28 | { key: "mod+j", command: "terminal.toggle" }, |
| 29 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | 29 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, |
| 30 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | 30 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, |
| 31 | { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, | ||
| 31 | { key: "mod+shift+o", command: "chat.new" }, | 32 | { key: "mod+shift+o", command: "chat.new" }, |
| 33 | { key: "mod+shift+n", command: "chat.newSameGitState" }, | ||
| 32 | { key: "mod+o", command: "editor.openFavorite" }, | 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,6 +52,7 @@ import BranchToolbar from "./BranchToolbar"; |
| 52 | import GitActionsControl from "./GitActionsControl"; | 52 | import GitActionsControl from "./GitActionsControl"; |
| 53 | import { | 53 | import { |
| 54 | isOpenFavoriteEditorShortcut, | 54 | isOpenFavoriteEditorShortcut, |
| 55 | isTerminalCloseShortcut, | ||
| 55 | isTerminalNewShortcut, | 56 | isTerminalNewShortcut, |
| 56 | isTerminalSplitShortcut, | 57 | isTerminalSplitShortcut, |
| 57 | isTerminalToggleShortcut, | 58 | isTerminalToggleShortcut, |
| ... | @@ -554,6 +555,14 @@ export default function ChatView() { | ... | @@ -554,6 +555,14 @@ export default function ChatView() { |
| 554 | return; | 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 | if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return; | 566 | if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return; |
| 558 | event.preventDefault(); | 567 | event.preventDefault(); |
| 559 | event.stopPropagation(); | 568 | event.stopPropagation(); |
| ... | @@ -570,7 +579,9 @@ export default function ChatView() { | ... | @@ -570,7 +579,9 @@ export default function ChatView() { |
| 570 | return () => window.removeEventListener("keydown", handler); | 579 | return () => window.removeEventListener("keydown", handler); |
| 571 | }, [ | 580 | }, [ |
| 572 | activeThread?.terminalOpen, | 581 | activeThread?.terminalOpen, |
| 582 | activeThread?.activeTerminalId, | ||
| 573 | activeThreadId, | 583 | activeThreadId, |
| 584 | closeTerminal, | ||
| 574 | createNewTerminal, | 585 | createNewTerminal, |
| 575 | dispatch, | 586 | dispatch, |
| 576 | splitTerminal, | 587 | splitTerminal, |
apps/web/src/components/Sidebar.tsx+22-7| ... | @@ -7,7 +7,7 @@ import { useTheme } from "../hooks/useTheme"; | ... | @@ -7,7 +7,7 @@ import { useTheme } from "../hooks/useTheme"; |
| 7 | import { DEFAULT_MODEL } from "../model-logic"; | 7 | import { DEFAULT_MODEL } from "../model-logic"; |
| 8 | import { derivePendingApprovals } from "../session-logic"; | 8 | import { derivePendingApprovals } from "../session-logic"; |
| 9 | import { useStore } from "../store"; | 9 | import { useStore } from "../store"; |
| 10 | import { isChatNewShortcut } from "../keybindings"; | 10 | import { isChatNewSameGitStateShortcut, isChatNewShortcut } from "../keybindings"; |
| 11 | import { | 11 | import { |
| 12 | DEFAULT_THREAD_TERMINAL_HEIGHT, | 12 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 13 | DEFAULT_THREAD_TERMINAL_ID, | 13 | DEFAULT_THREAD_TERMINAL_ID, |
| ... | @@ -140,7 +140,13 @@ export default function Sidebar() { | ... | @@ -140,7 +140,13 @@ export default function Sidebar() { |
| 140 | }, [state.threads]); | 140 | }, [state.threads]); |
| 141 | 141 | ||
| 142 | const handleNewThread = useCallback( | 142 | const handleNewThread = useCallback( |
| 143 | (projectId: string) => { | 143 | ( |
| 144 | projectId: string, | ||
| 145 | options?: { | ||
| 146 | branch?: string | null; | ||
| 147 | worktreePath?: string | null; | ||
| 148 | }, | ||
| 149 | ) => { | ||
| 144 | dispatch({ | 150 | dispatch({ |
| 145 | type: "ADD_THREAD", | 151 | type: "ADD_THREAD", |
| 146 | thread: { | 152 | thread: { |
| ... | @@ -166,8 +172,8 @@ export default function Sidebar() { | ... | @@ -166,8 +172,8 @@ export default function Sidebar() { |
| 166 | events: [], | 172 | events: [], |
| 167 | error: null, | 173 | error: null, |
| 168 | createdAt: new Date().toISOString(), | 174 | createdAt: new Date().toISOString(), |
| 169 | branch: null, | 175 | branch: options?.branch ?? null, |
| 170 | worktreePath: null, | 176 | worktreePath: options?.worktreePath ?? null, |
| 171 | }, | 177 | }, |
| 172 | }); | 178 | }); |
| 173 | }, | 179 | }, |
| ... | @@ -381,12 +387,21 @@ export default function Sidebar() { | ... | @@ -381,12 +387,21 @@ export default function Sidebar() { |
| 381 | 387 | ||
| 382 | useEffect(() => { | 388 | useEffect(() => { |
| 383 | const onWindowKeyDown = (event: KeyboardEvent) => { | 389 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 384 | if (!isChatNewShortcut(event, keybindings)) return; | ||
| 385 | |||
| 386 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | 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 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; | 403 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; |
| 388 | if (!projectId) return; | 404 | if (!projectId) return; |
| 389 | |||
| 390 | event.preventDefault(); | 405 | event.preventDefault(); |
| 391 | handleNewThread(projectId); | 406 | handleNewThread(projectId); |
| 392 | }; | 407 | }; |
apps/web/src/keybindings.test.ts+43-2| ... | @@ -7,10 +7,12 @@ import { | ... | @@ -7,10 +7,12 @@ import { |
| 7 | type ResolvedKeybindingsConfig, | 7 | type ResolvedKeybindingsConfig, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import { | 9 | import { |
| 10 | isChatNewSameGitStateShortcut, | ||
| 10 | formatShortcutLabel, | 11 | formatShortcutLabel, |
| 11 | isChatNewShortcut, | 12 | isChatNewShortcut, |
| 12 | isOpenFavoriteEditorShortcut, | 13 | isOpenFavoriteEditorShortcut, |
| 13 | isTerminalClearShortcut, | 14 | isTerminalClearShortcut, |
| 15 | isTerminalCloseShortcut, | ||
| 14 | isTerminalNewShortcut, | 16 | isTerminalNewShortcut, |
| 15 | isTerminalSplitShortcut, | 17 | isTerminalSplitShortcut, |
| 16 | isTerminalToggleShortcut, | 18 | isTerminalToggleShortcut, |
| ... | @@ -83,7 +85,13 @@ const DEFAULT_BINDINGS = compile([ | ... | @@ -83,7 +85,13 @@ const DEFAULT_BINDINGS = compile([ |
| 83 | command: "terminal.new", | 85 | command: "terminal.new", |
| 84 | whenAst: whenIdentifier("terminalFocus"), | 86 | whenAst: whenIdentifier("terminalFocus"), |
| 85 | }, | 87 | }, |
| 88 | { | ||
| 89 | shortcut: modShortcut("w"), | ||
| 90 | command: "terminal.close", | ||
| 91 | whenAst: whenIdentifier("terminalFocus"), | ||
| 92 | }, | ||
| 86 | { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, | 93 | { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, |
| 94 | { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newSameGitState" }, | ||
| 87 | { shortcut: modShortcut("o"), command: "editor.openFavorite" }, | 95 | { shortcut: modShortcut("o"), command: "editor.openFavorite" }, |
| 88 | ]); | 96 | ]); |
| 89 | 97 | ||
| ... | @@ -101,8 +109,8 @@ describe("isTerminalToggleShortcut", () => { | ... | @@ -101,8 +109,8 @@ describe("isTerminalToggleShortcut", () => { |
| 101 | }); | 109 | }); |
| 102 | }); | 110 | }); |
| 103 | 111 | ||
| 104 | describe("split/new terminal shortcuts", () => { | 112 | describe("split/new/close terminal shortcuts", () => { |
| 105 | it("requires terminalFocus for default split/new bindings", () => { | 113 | it("requires terminalFocus for default split/new/close bindings", () => { |
| 106 | assert.isFalse( | 114 | assert.isFalse( |
| 107 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { | 115 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { |
| 108 | platform: "MacIntel", | 116 | platform: "MacIntel", |
| ... | @@ -115,6 +123,12 @@ describe("split/new terminal shortcuts", () => { | ... | @@ -115,6 +123,12 @@ describe("split/new terminal shortcuts", () => { |
| 115 | context: { terminalFocus: false }, | 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 | it("matches split/new when terminalFocus is true", () => { | 134 | it("matches split/new when terminalFocus is true", () => { |
| ... | @@ -130,6 +144,12 @@ describe("split/new terminal shortcuts", () => { | ... | @@ -130,6 +144,12 @@ describe("split/new terminal shortcuts", () => { |
| 130 | context: { terminalFocus: true }, | 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 | it("supports when expressions", () => { | 155 | it("supports when expressions", () => { |
| ... | @@ -225,6 +245,27 @@ describe("chat/editor shortcuts", () => { | ... | @@ -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 | it("matches editor.openFavorite shortcut", () => { | 269 | it("matches editor.openFavorite shortcut", () => { |
| 229 | assert.isTrue( | 270 | assert.isTrue( |
| 230 | isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, { | 271 | isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, { |
apps/web/src/keybindings.ts+16| ... | @@ -190,6 +190,14 @@ export function isTerminalNewShortcut( | ... | @@ -190,6 +190,14 @@ export function isTerminalNewShortcut( |
| 190 | return matchesCommandShortcut(event, keybindings, "terminal.new", options); | 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 | export function isChatNewShortcut( | 201 | export function isChatNewShortcut( |
| 194 | event: ShortcutEventLike, | 202 | event: ShortcutEventLike, |
| 195 | keybindings: ResolvedKeybindingsConfig, | 203 | keybindings: ResolvedKeybindingsConfig, |
| ... | @@ -198,6 +206,14 @@ export function isChatNewShortcut( | ... | @@ -198,6 +206,14 @@ export function isChatNewShortcut( |
| 198 | return matchesCommandShortcut(event, keybindings, "chat.new", options); | 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 | export function isOpenFavoriteEditorShortcut( | 217 | export function isOpenFavoriteEditorShortcut( |
| 202 | event: ShortcutEventLike, | 218 | event: ShortcutEventLike, |
| 203 | keybindings: ResolvedKeybindingsConfig, | 219 | keybindings: ResolvedKeybindingsConfig, |
packages/contracts/src/keybindings.test.ts+6| ... | @@ -14,6 +14,12 @@ describe("keybindings contracts", () => { | ... | @@ -14,6 +14,12 @@ describe("keybindings contracts", () => { |
| 14 | command: "terminal.toggle", | 14 | command: "terminal.toggle", |
| 15 | }); | 15 | }); |
| 16 | assert.strictEqual(parsed.command, "terminal.toggle"); | 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 | it("rejects invalid command values", () => { | 25 | it("rejects invalid command values", () => { |
packages/contracts/src/keybindings.ts+2| ... | @@ -4,7 +4,9 @@ export const keybindingCommandSchema = z.enum([ | ... | @@ -4,7 +4,9 @@ export const keybindingCommandSchema = z.enum([ |
| 4 | "terminal.toggle", | 4 | "terminal.toggle", |
| 5 | "terminal.split", | 5 | "terminal.split", |
| 6 | "terminal.new", | 6 | "terminal.new", |
| 7 | "terminal.close", | ||
| 7 | "chat.new", | 8 | "chat.new", |
| 9 | "chat.newSameGitState", | ||
| 8 | "editor.openFavorite", | 10 | "editor.openFavorite", |
| 9 | ]); | 11 | ]); |
| 10 | 12 |