authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 23:02:35-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 23:08:37-08:00
logbc22a71f10acf5d56ebc6f7b13966a7fc8fdc504
tree94c0959b2e6bb3a028d2794a075b6bc3cbd933f7
parentc42d6a6a6489c89d56830cdba7fee752afef5c14

Rename local chat shortcut and surface terminal close hints

- 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

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