- 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 controls
Unified · Split
| @@ -24,7 +24,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( · 23 unmodified lines | ||
| 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 · 18 unmodified lines | ||
| 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 |
| @@ -71,7 +71,7 @@ describe("server keybindings", () => { · 70 unmodified lines | ||
| 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", () => { · 1 unmodified line | ||
| 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 | }); |
| @@ -30,7 +30,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ · 29 unmodified lines | ||
| 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 | |
| @@ -279,6 +279,10 @@ export default function ChatView() { · 278 unmodified lines | ||
| 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() { · 1179 unmodified lines | ||
| 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) => |
| @@ -7,7 +7,7 @@ import { useTheme } from "../hooks/useTheme"; · 6 unmodified lines | ||
| 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() { · 374 unmodified lines | ||
| 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() { · 1 unmodified line | ||
| 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); |
| @@ -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 { · 12 unmodified lines | ||
| 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({ · 133 unmodified lines | ||
| 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 { · 258 unmodified lines | ||
| 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 { · 9 unmodified lines | ||
| 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({ · 24 unmodified lines | ||
| 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({ · 109 unmodified lines | ||
| 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({ · 105 unmodified lines | ||
| 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({ · 9 unmodified lines | ||
| 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({ · 73 unmodified lines | ||
| 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({ · 31 unmodified lines | ||
| 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({ · 17 unmodified lines | ||
| 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 | ); |
| @@ -7,9 +7,9 @@ import { · 6 unmodified lines | ||
| 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([ · 75 unmodified lines | ||
| 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", () => { · 147 unmodified lines | ||
| 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", () => { · 2 unmodified lines | ||
| 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 | { |
| @@ -206,12 +206,12 @@ export function isChatNewShortcut( · 205 unmodified lines | ||
| 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( |
| @@ -20,6 +20,12 @@ describe("keybindings contracts", () => { · 19 unmodified lines | ||
| 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", () => { |
| @@ -6,7 +6,7 @@ export const keybindingCommandSchema = z.enum([ · 5 unmodified lines | ||
| 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 | |