| author | |
| committer | |
| log | bc22a71f10acf5d56ebc6f7b13966a7fc8fdc504 |
| tree | 94c0959b2e6bb3a028d2794a075b6bc3cbd933f7 |
| parent | c42d6a6a6489c89d56830cdba7fee752afef5c14 |
- Replace `chat.newSameGitState` with `chat.newLocal` across contracts, server/web defaults, docs, and tests
- Make `chat.new` preserve branch/worktree state while `chat.newLocal` creates a local thread
- Show terminal close shortcut labels in drawer action tooltips and tab close controls10 files changed, 76 insertions(+), 57 deletions(-)
KEYBINDINGS.md+3-3| ... | ... | @@ -24,7 +24,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( |
| 24 | 24 | { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, |
| 25 | 25 | { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, |
| 26 | 26 | { "key": "mod+shift+o", "command": "chat.new" }, |
| 27 | { "key": "mod+shift+n", "command": "chat.newSameGitState" }, | |
| 27 | { "key": "mod+shift+n", "command": "chat.newLocal" }, | |
| 28 | 28 | { "key": "mod+o", "command": "editor.openFavorite" } |
| 29 | 29 | ] |
| 30 | 30 | ``` |
| ... | ... | @@ -49,8 +49,8 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged |
| 49 | 49 | - `terminal.split`: split terminal (in focused terminal context by default) |
| 50 | 50 | - `terminal.new`: create new terminal (in focused terminal context by default) |
| 51 | 51 | - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) |
| 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 | |
| 52 | - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state | |
| 53 | - `chat.newLocal`: create a new local chat thread for the active project (no worktree context) | |
| 54 | 54 | - `editor.openFavorite`: open current project/worktree in the last-used editor |
| 55 | 55 | |
| 56 | 56 | ### Key Syntax |
apps/server/src/keybindings.test.ts+2-2| ... | ... | @@ -71,7 +71,7 @@ describe("server keybindings", () => { |
| 71 | 71 | ); |
| 72 | 72 | }); |
| 73 | 73 | |
| 74 | it("defines defaults for terminal.close and chat.newSameGitState", () => { | |
| 74 | it("defines defaults for terminal.close and chat.newLocal", () => { | |
| 75 | 75 | assert.deepInclude(DEFAULT_KEYBINDINGS, { |
| 76 | 76 | key: "mod+w", |
| 77 | 77 | command: "terminal.close", |
| ... | ... | @@ -79,7 +79,7 @@ describe("server keybindings", () => { |
| 79 | 79 | }); |
| 80 | 80 | assert.deepInclude(DEFAULT_KEYBINDINGS, { |
| 81 | 81 | key: "mod+shift+n", |
| 82 | command: "chat.newSameGitState", | |
| 82 | command: "chat.newLocal", | |
| 83 | 83 | }); |
| 84 | 84 | }); |
| 85 | 85 | }); |
apps/server/src/keybindings.ts+1-1| ... | ... | @@ -30,7 +30,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 30 | 30 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, |
| 31 | 31 | { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, |
| 32 | 32 | { key: "mod+shift+o", command: "chat.new" }, |
| 33 | { key: "mod+shift+n", command: "chat.newSameGitState" }, | |
| 33 | { key: "mod+shift+n", command: "chat.newLocal" }, | |
| 34 | 34 | { key: "mod+o", command: "editor.openFavorite" }, |
| 35 | 35 | ]; |
| 36 | 36 |
apps/web/src/components/ChatView.tsx+5| ... | ... | @@ -279,6 +279,10 @@ export default function ChatView() { |
| 279 | 279 | () => shortcutLabelForCommand(keybindings, "terminal.new"), |
| 280 | 280 | [keybindings], |
| 281 | 281 | ); |
| 282 | const closeTerminalShortcutLabel = useMemo( | |
| 283 | () => shortcutLabelForCommand(keybindings, "terminal.close"), | |
| 284 | [keybindings], | |
| 285 | ); | |
| 282 | 286 | |
| 283 | 287 | const envLocked = Boolean( |
| 284 | 288 | activeThread && |
| ... | ... | @@ -1464,6 +1468,7 @@ export default function ChatView() { |
| 1464 | 1468 | onNewTerminal={createNewTerminal} |
| 1465 | 1469 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} |
| 1466 | 1470 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} |
| 1471 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | |
| 1467 | 1472 | onActiveTerminalChange={activateTerminal} |
| 1468 | 1473 | onCloseTerminal={closeTerminal} |
| 1469 | 1474 | onHeightChange={(height) => |
apps/web/src/components/Sidebar.tsx+7-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 { isChatNewSameGitStateShortcut, isChatNewShortcut } from "../keybindings"; | |
| 10 | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; | |
| 11 | 11 | import { |
| 12 | 12 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 13 | 13 | DEFAULT_THREAD_TERMINAL_ID, |
| ... | ... | @@ -388,14 +388,11 @@ export default function Sidebar() { |
| 388 | 388 | useEffect(() => { |
| 389 | 389 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 390 | 390 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 391 | if (isChatNewSameGitStateShortcut(event, keybindings)) { | |
| 391 | if (isChatNewLocalShortcut(event, keybindings)) { | |
| 392 | 392 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; |
| 393 | 393 | if (!projectId) return; |
| 394 | 394 | event.preventDefault(); |
| 395 | handleNewThread(projectId, { | |
| 396 | branch: activeThread?.branch ?? null, | |
| 397 | worktreePath: activeThread?.worktreePath ?? null, | |
| 398 | }); | |
| 395 | handleNewThread(projectId); | |
| 399 | 396 | return; |
| 400 | 397 | } |
| 401 | 398 | |
| ... | ... | @@ -403,7 +400,10 @@ export default function Sidebar() { |
| 403 | 400 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; |
| 404 | 401 | if (!projectId) return; |
| 405 | 402 | event.preventDefault(); |
| 406 | handleNewThread(projectId); | |
| 403 | handleNewThread(projectId, { | |
| 404 | branch: activeThread?.branch ?? null, | |
| 405 | worktreePath: activeThread?.worktreePath ?? null, | |
| 406 | }); | |
| 407 | 407 | }; |
| 408 | 408 | |
| 409 | 409 | window.addEventListener("keydown", onWindowKeyDown); |
apps/web/src/components/ThreadTerminalDrawer.tsx+44-36| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { FitAddon } from "@xterm/addon-fit"; |
| 2 | import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react"; | |
| 2 | import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; | |
| 3 | 3 | import { type NativeApi } from "@t3tools/contracts"; |
| 4 | 4 | import { Terminal, type ITheme } from "@xterm/xterm"; |
| 5 | 5 | import { |
| ... | ... | @@ -18,10 +18,7 @@ import { |
| 18 | 18 | preferredTerminalEditor, |
| 19 | 19 | resolvePathLinkTarget, |
| 20 | 20 | } from "../terminal-links"; |
| 21 | import { | |
| 22 | isTerminalClearShortcut, | |
| 23 | terminalNavigationShortcutData, | |
| 24 | } from "../keybindings"; | |
| 21 | import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; | |
| 25 | 22 | import { |
| 26 | 23 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 27 | 24 | DEFAULT_THREAD_TERMINAL_ID, |
| ... | ... | @@ -161,10 +158,7 @@ function TerminalViewport({ |
| 161 | 158 | try { |
| 162 | 159 | await api.terminal.write({ threadId, terminalId, data }); |
| 163 | 160 | } catch (error) { |
| 164 | writeSystemMessage( | |
| 165 | activeTerminal, | |
| 166 | error instanceof Error ? error.message : fallbackError, | |
| 167 | ); | |
| 161 | writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); | |
| 168 | 162 | } |
| 169 | 163 | }; |
| 170 | 164 | |
| ... | ... | @@ -429,6 +423,7 @@ interface ThreadTerminalDrawerProps { |
| 429 | 423 | onNewTerminal: () => void; |
| 430 | 424 | splitShortcutLabel?: string | undefined; |
| 431 | 425 | newShortcutLabel?: string | undefined; |
| 426 | closeShortcutLabel?: string | undefined; | |
| 432 | 427 | onActiveTerminalChange: (terminalId: string) => void; |
| 433 | 428 | onCloseTerminal: (terminalId: string) => void; |
| 434 | 429 | onHeightChange: (height: number) => void; |
| ... | ... | @@ -444,15 +439,9 @@ interface TerminalActionButtonProps { |
| 444 | 439 | function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { |
| 445 | 440 | return ( |
| 446 | 441 | <Popover> |
| 447 | <PopoverTrigger openOnHover | |
| 448 | render={ | |
| 449 | <button | |
| 450 | type="button" | |
| 451 | className={className} | |
| 452 | onClick={onClick} | |
| 453 | aria-label={label} | |
| 454 | /> | |
| 455 | } | |
| 442 | <PopoverTrigger | |
| 443 | openOnHover | |
| 444 | render={<button type="button" className={className} onClick={onClick} aria-label={label} />} | |
| 456 | 445 | > |
| 457 | 446 | {children} |
| 458 | 447 | </PopoverTrigger> |
| ... | ... | @@ -483,6 +472,7 @@ export default function ThreadTerminalDrawer({ |
| 483 | 472 | onNewTerminal, |
| 484 | 473 | splitShortcutLabel, |
| 485 | 474 | newShortcutLabel, |
| 475 | closeShortcutLabel, | |
| 486 | 476 | onActiveTerminalChange, |
| 487 | 477 | onCloseTerminal, |
| 488 | 478 | onHeightChange, |
| ... | ... | @@ -598,6 +588,9 @@ export default function ThreadTerminalDrawer({ |
| 598 | 588 | ), |
| 599 | 589 | [normalizedTerminalIds], |
| 600 | 590 | ); |
| 591 | const closeTerminalActionLabel = closeShortcutLabel | |
| 592 | ? `Close Terminal (${closeShortcutLabel})` | |
| 593 | : "Close Terminal"; | |
| 601 | 594 | |
| 602 | 595 | useEffect(() => { |
| 603 | 596 | onHeightChangeRef.current = onHeightChange; |
| ... | ... | @@ -709,7 +702,9 @@ export default function ThreadTerminalDrawer({ |
| 709 | 702 | <TerminalActionButton |
| 710 | 703 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" |
| 711 | 704 | onClick={onSplitTerminal} |
| 712 | label={splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"} | |
| 705 | label={ | |
| 706 | splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal" | |
| 707 | } | |
| 713 | 708 | > |
| 714 | 709 | <SquareSplitHorizontal className="size-3.25" /> |
| 715 | 710 | </TerminalActionButton> |
| ... | ... | @@ -725,7 +720,7 @@ export default function ThreadTerminalDrawer({ |
| 725 | 720 | <TerminalActionButton |
| 726 | 721 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" |
| 727 | 722 | onClick={() => onCloseTerminal(resolvedActiveTerminalId)} |
| 728 | label="Close Terminal" | |
| 723 | label={closeTerminalActionLabel} | |
| 729 | 724 | > |
| 730 | 725 | <Trash2 className="size-3.25" /> |
| 731 | 726 | </TerminalActionButton> |
| ... | ... | @@ -805,16 +800,14 @@ export default function ThreadTerminalDrawer({ |
| 805 | 800 | <TerminalActionButton |
| 806 | 801 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" |
| 807 | 802 | onClick={onNewTerminal} |
| 808 | label={ | |
| 809 | newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal" | |
| 810 | } | |
| 803 | label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"} | |
| 811 | 804 | > |
| 812 | 805 | <Plus className="size-3.25" /> |
| 813 | 806 | </TerminalActionButton> |
| 814 | 807 | <TerminalActionButton |
| 815 | 808 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" |
| 816 | 809 | onClick={() => onCloseTerminal(resolvedActiveTerminalId)} |
| 817 | label="Close Terminal" | |
| 810 | label={closeTerminalActionLabel} | |
| 818 | 811 | > |
| 819 | 812 | <Trash2 className="size-3.25" /> |
| 820 | 813 | </TerminalActionButton> |
| ... | ... | @@ -852,6 +845,9 @@ export default function ThreadTerminalDrawer({ |
| 852 | 845 | > |
| 853 | 846 | {terminalGroup.terminalIds.map((terminalId) => { |
| 854 | 847 | const isActive = terminalId === resolvedActiveTerminalId; |
| 848 | const closeTerminalLabel = `Close ${ | |
| 849 | terminalLabelById.get(terminalId) ?? "terminal" | |
| 850 | }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; | |
| 855 | 851 | return ( |
| 856 | 852 | <div |
| 857 | 853 | key={terminalId} |
| ... | ... | @@ -875,18 +871,30 @@ export default function ThreadTerminalDrawer({ |
| 875 | 871 | </span> |
| 876 | 872 | </button> |
| 877 | 873 | {normalizedTerminalIds.length > 1 && ( |
| 878 | <button | |
| 879 | type="button" | |
| 880 | className="rounded px-1 text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100" | |
| 881 | onClick={(event) => { | |
| 882 | event.stopPropagation(); | |
| 883 | onCloseTerminal(terminalId); | |
| 884 | }} | |
| 885 | aria-label={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`} | |
| 886 | title={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`} | |
| 887 | > | |
| 888 | × | |
| 889 | </button> | |
| 874 | <Popover> | |
| 875 | <PopoverTrigger | |
| 876 | openOnHover | |
| 877 | render={ | |
| 878 | <button | |
| 879 | type="button" | |
| 880 | className="inline-flex size-3.5 items-center justify-center rounded text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100" | |
| 881 | onClick={() => onCloseTerminal(terminalId)} | |
| 882 | aria-label={closeTerminalLabel} | |
| 883 | /> | |
| 884 | } | |
| 885 | > | |
| 886 | <XIcon className="size-2.5" /> | |
| 887 | </PopoverTrigger> | |
| 888 | <PopoverPopup | |
| 889 | tooltipStyle | |
| 890 | side="bottom" | |
| 891 | sideOffset={6} | |
| 892 | align="center" | |
| 893 | className="pointer-events-none select-none" | |
| 894 | > | |
| 895 | {closeTerminalLabel} | |
| 896 | </PopoverPopup> | |
| 897 | </Popover> | |
| 890 | 898 | )} |
| 891 | 899 | </div> |
| 892 | 900 | ); |
apps/web/src/keybindings.test.ts+5-5| ... | ... | @@ -7,9 +7,9 @@ import { |
| 7 | 7 | type ResolvedKeybindingsConfig, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import { |
| 10 | isChatNewSameGitStateShortcut, | |
| 11 | 10 | formatShortcutLabel, |
| 12 | 11 | isChatNewShortcut, |
| 12 | isChatNewLocalShortcut, | |
| 13 | 13 | isOpenFavoriteEditorShortcut, |
| 14 | 14 | isTerminalClearShortcut, |
| 15 | 15 | isTerminalCloseShortcut, |
| ... | ... | @@ -91,7 +91,7 @@ const DEFAULT_BINDINGS = compile([ |
| 91 | 91 | whenAst: whenIdentifier("terminalFocus"), |
| 92 | 92 | }, |
| 93 | 93 | { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, |
| 94 | { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newSameGitState" }, | |
| 94 | { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, | |
| 95 | 95 | { shortcut: modShortcut("o"), command: "editor.openFavorite" }, |
| 96 | 96 | ]); |
| 97 | 97 | |
| ... | ... | @@ -245,9 +245,9 @@ describe("chat/editor shortcuts", () => { |
| 245 | 245 | ); |
| 246 | 246 | }); |
| 247 | 247 | |
| 248 | it("matches chat.newSameGitState shortcut", () => { | |
| 248 | it("matches chat.newLocal shortcut", () => { | |
| 249 | 249 | assert.isTrue( |
| 250 | isChatNewSameGitStateShortcut( | |
| 250 | isChatNewLocalShortcut( | |
| 251 | 251 | event({ key: "n", metaKey: true, shiftKey: true }), |
| 252 | 252 | DEFAULT_BINDINGS, |
| 253 | 253 | { |
| ... | ... | @@ -256,7 +256,7 @@ describe("chat/editor shortcuts", () => { |
| 256 | 256 | ), |
| 257 | 257 | ); |
| 258 | 258 | assert.isTrue( |
| 259 | isChatNewSameGitStateShortcut( | |
| 259 | isChatNewLocalShortcut( | |
| 260 | 260 | event({ key: "n", ctrlKey: true, shiftKey: true }), |
| 261 | 261 | DEFAULT_BINDINGS, |
| 262 | 262 | { |
apps/web/src/keybindings.ts+2-2| ... | ... | @@ -206,12 +206,12 @@ export function isChatNewShortcut( |
| 206 | 206 | return matchesCommandShortcut(event, keybindings, "chat.new", options); |
| 207 | 207 | } |
| 208 | 208 | |
| 209 | export function isChatNewSameGitStateShortcut( | |
| 209 | export function isChatNewLocalShortcut( | |
| 210 | 210 | event: ShortcutEventLike, |
| 211 | 211 | keybindings: ResolvedKeybindingsConfig, |
| 212 | 212 | options?: ShortcutMatchOptions, |
| 213 | 213 | ): boolean { |
| 214 | return matchesCommandShortcut(event, keybindings, "chat.newSameGitState", options); | |
| 214 | return matchesCommandShortcut(event, keybindings, "chat.newLocal", options); | |
| 215 | 215 | } |
| 216 | 216 | |
| 217 | 217 | export function isOpenFavoriteEditorShortcut( |
packages/contracts/src/keybindings.test.ts+6| ... | ... | @@ -20,6 +20,12 @@ describe("keybindings contracts", () => { |
| 20 | 20 | command: "terminal.close", |
| 21 | 21 | }); |
| 22 | 22 | assert.strictEqual(parsedClose.command, "terminal.close"); |
| 23 | ||
| 24 | const parsedLocal = keybindingRuleSchema.parse({ | |
| 25 | key: "mod+shift+n", | |
| 26 | command: "chat.newLocal", | |
| 27 | }); | |
| 28 | assert.strictEqual(parsedLocal.command, "chat.newLocal"); | |
| 23 | 29 | }); |
| 24 | 30 | |
| 25 | 31 | it("rejects invalid command values", () => { |
packages/contracts/src/keybindings.ts+1-1| ... | ... | @@ -6,7 +6,7 @@ export const keybindingCommandSchema = z.enum([ |
| 6 | 6 | "terminal.new", |
| 7 | 7 | "terminal.close", |
| 8 | 8 | "chat.new", |
| 9 | "chat.newSameGitState", | |
| 9 | "chat.newLocal", | |
| 10 | 10 | "editor.openFavorite", |
| 11 | 11 | ]); |
| 12 | 12 |