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`](
2424 { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" },
2525 { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" },
2626 { "key": "mod+shift+o", "command": "chat.new" },
27 { "key": "mod+shift+n", "command": "chat.newSameGitState" },
27 { "key": "mod+shift+n", "command": "chat.newLocal" },
2828 { "key": "mod+o", "command": "editor.openFavorite" }
2929]
3030```
......@@ -49,8 +49,8 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged
4949- `terminal.split`: split terminal (in focused terminal context by default)
5050- `terminal.new`: create new terminal (in focused terminal context by default)
5151- `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)
5454- `editor.openFavorite`: open current project/worktree in the last-used editor
5555
5656### Key Syntax
apps/server/src/keybindings.test.ts+2-2
......@@ -71,7 +71,7 @@ describe("server keybindings", () => {
7171 );
7272 });
7373
74 it("defines defaults for terminal.close and chat.newSameGitState", () => {
74 it("defines defaults for terminal.close and chat.newLocal", () => {
7575 assert.deepInclude(DEFAULT_KEYBINDINGS, {
7676 key: "mod+w",
7777 command: "terminal.close",
......@@ -79,7 +79,7 @@ describe("server keybindings", () => {
7979 });
8080 assert.deepInclude(DEFAULT_KEYBINDINGS, {
8181 key: "mod+shift+n",
82 command: "chat.newSameGitState",
82 command: "chat.newLocal",
8383 });
8484 });
8585});
apps/server/src/keybindings.ts+1-1
......@@ -30,7 +30,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
3030 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
3131 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
3232 { key: "mod+shift+o", command: "chat.new" },
33 { key: "mod+shift+n", command: "chat.newSameGitState" },
33 { key: "mod+shift+n", command: "chat.newLocal" },
3434 { key: "mod+o", command: "editor.openFavorite" },
3535];
3636
apps/web/src/components/ChatView.tsx+5
......@@ -279,6 +279,10 @@ export default function ChatView() {
279279 () => shortcutLabelForCommand(keybindings, "terminal.new"),
280280 [keybindings],
281281 );
282 const closeTerminalShortcutLabel = useMemo(
283 () => shortcutLabelForCommand(keybindings, "terminal.close"),
284 [keybindings],
285 );
282286
283287 const envLocked = Boolean(
284288 activeThread &&
......@@ -1464,6 +1468,7 @@ export default function ChatView() {
14641468 onNewTerminal={createNewTerminal}
14651469 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
14661470 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
1471 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
14671472 onActiveTerminalChange={activateTerminal}
14681473 onCloseTerminal={closeTerminal}
14691474 onHeightChange={(height) =>
apps/web/src/components/Sidebar.tsx+7-7
......@@ -7,7 +7,7 @@ import { useTheme } from "../hooks/useTheme";
77import { DEFAULT_MODEL } from "../model-logic";
88import { derivePendingApprovals } from "../session-logic";
99import { useStore } from "../store";
10import { isChatNewSameGitStateShortcut, isChatNewShortcut } from "../keybindings";
10import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
1111import {
1212 DEFAULT_THREAD_TERMINAL_HEIGHT,
1313 DEFAULT_THREAD_TERMINAL_ID,
......@@ -388,14 +388,11 @@ export default function Sidebar() {
388388 useEffect(() => {
389389 const onWindowKeyDown = (event: KeyboardEvent) => {
390390 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
391 if (isChatNewSameGitStateShortcut(event, keybindings)) {
391 if (isChatNewLocalShortcut(event, keybindings)) {
392392 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
393393 if (!projectId) return;
394394 event.preventDefault();
395 handleNewThread(projectId, {
396 branch: activeThread?.branch ?? null,
397 worktreePath: activeThread?.worktreePath ?? null,
398 });
395 handleNewThread(projectId);
399396 return;
400397 }
401398
......@@ -403,7 +400,10 @@ export default function Sidebar() {
403400 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
404401 if (!projectId) return;
405402 event.preventDefault();
406 handleNewThread(projectId);
403 handleNewThread(projectId, {
404 branch: activeThread?.branch ?? null,
405 worktreePath: activeThread?.worktreePath ?? null,
406 });
407407 };
408408
409409 window.addEventListener("keydown", onWindowKeyDown);
apps/web/src/components/ThreadTerminalDrawer.tsx+44-36
......@@ -1,5 +1,5 @@
11import { FitAddon } from "@xterm/addon-fit";
2import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react";
2import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";
33import { type NativeApi } from "@t3tools/contracts";
44import { Terminal, type ITheme } from "@xterm/xterm";
55import {
......@@ -18,10 +18,7 @@ import {
1818 preferredTerminalEditor,
1919 resolvePathLinkTarget,
2020} from "../terminal-links";
21import {
22 isTerminalClearShortcut,
23 terminalNavigationShortcutData,
24} from "../keybindings";
21import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings";
2522import {
2623 DEFAULT_THREAD_TERMINAL_HEIGHT,
2724 DEFAULT_THREAD_TERMINAL_ID,
......@@ -161,10 +158,7 @@ function TerminalViewport({
161158 try {
162159 await api.terminal.write({ threadId, terminalId, data });
163160 } catch (error) {
164 writeSystemMessage(
165 activeTerminal,
166 error instanceof Error ? error.message : fallbackError,
167 );
161 writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError);
168162 }
169163 };
170164
......@@ -429,6 +423,7 @@ interface ThreadTerminalDrawerProps {
429423 onNewTerminal: () => void;
430424 splitShortcutLabel?: string | undefined;
431425 newShortcutLabel?: string | undefined;
426 closeShortcutLabel?: string | undefined;
432427 onActiveTerminalChange: (terminalId: string) => void;
433428 onCloseTerminal: (terminalId: string) => void;
434429 onHeightChange: (height: number) => void;
......@@ -444,15 +439,9 @@ interface TerminalActionButtonProps {
444439function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) {
445440 return (
446441 <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} />}
456445 >
457446 {children}
458447 </PopoverTrigger>
......@@ -483,6 +472,7 @@ export default function ThreadTerminalDrawer({
483472 onNewTerminal,
484473 splitShortcutLabel,
485474 newShortcutLabel,
475 closeShortcutLabel,
486476 onActiveTerminalChange,
487477 onCloseTerminal,
488478 onHeightChange,
......@@ -598,6 +588,9 @@ export default function ThreadTerminalDrawer({
598588 ),
599589 [normalizedTerminalIds],
600590 );
591 const closeTerminalActionLabel = closeShortcutLabel
592 ? `Close Terminal (${closeShortcutLabel})`
593 : "Close Terminal";
601594
602595 useEffect(() => {
603596 onHeightChangeRef.current = onHeightChange;
......@@ -709,7 +702,9 @@ export default function ThreadTerminalDrawer({
709702 <TerminalActionButton
710703 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
711704 onClick={onSplitTerminal}
712 label={splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"}
705 label={
706 splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"
707 }
713708 >
714709 <SquareSplitHorizontal className="size-3.25" />
715710 </TerminalActionButton>
......@@ -725,7 +720,7 @@ export default function ThreadTerminalDrawer({
725720 <TerminalActionButton
726721 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
727722 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
728 label="Close Terminal"
723 label={closeTerminalActionLabel}
729724 >
730725 <Trash2 className="size-3.25" />
731726 </TerminalActionButton>
......@@ -805,16 +800,14 @@ export default function ThreadTerminalDrawer({
805800 <TerminalActionButton
806801 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
807802 onClick={onNewTerminal}
808 label={
809 newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"
810 }
803 label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"}
811804 >
812805 <Plus className="size-3.25" />
813806 </TerminalActionButton>
814807 <TerminalActionButton
815808 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
816809 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
817 label="Close Terminal"
810 label={closeTerminalActionLabel}
818811 >
819812 <Trash2 className="size-3.25" />
820813 </TerminalActionButton>
......@@ -852,6 +845,9 @@ export default function ThreadTerminalDrawer({
852845 >
853846 {terminalGroup.terminalIds.map((terminalId) => {
854847 const isActive = terminalId === resolvedActiveTerminalId;
848 const closeTerminalLabel = `Close ${
849 terminalLabelById.get(terminalId) ?? "terminal"
850 }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`;
855851 return (
856852 <div
857853 key={terminalId}
......@@ -875,18 +871,30 @@ export default function ThreadTerminalDrawer({
875871 </span>
876872 </button>
877873 {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>
890898 )}
891899 </div>
892900 );
apps/web/src/keybindings.test.ts+5-5
......@@ -7,9 +7,9 @@ import {
77 type ResolvedKeybindingsConfig,
88} from "@t3tools/contracts";
99import {
10 isChatNewSameGitStateShortcut,
1110 formatShortcutLabel,
1211 isChatNewShortcut,
12 isChatNewLocalShortcut,
1313 isOpenFavoriteEditorShortcut,
1414 isTerminalClearShortcut,
1515 isTerminalCloseShortcut,
......@@ -91,7 +91,7 @@ const DEFAULT_BINDINGS = compile([
9191 whenAst: whenIdentifier("terminalFocus"),
9292 },
9393 { 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" },
9595 { shortcut: modShortcut("o"), command: "editor.openFavorite" },
9696]);
9797
......@@ -245,9 +245,9 @@ describe("chat/editor shortcuts", () => {
245245 );
246246 });
247247
248 it("matches chat.newSameGitState shortcut", () => {
248 it("matches chat.newLocal shortcut", () => {
249249 assert.isTrue(
250 isChatNewSameGitStateShortcut(
250 isChatNewLocalShortcut(
251251 event({ key: "n", metaKey: true, shiftKey: true }),
252252 DEFAULT_BINDINGS,
253253 {
......@@ -256,7 +256,7 @@ describe("chat/editor shortcuts", () => {
256256 ),
257257 );
258258 assert.isTrue(
259 isChatNewSameGitStateShortcut(
259 isChatNewLocalShortcut(
260260 event({ key: "n", ctrlKey: true, shiftKey: true }),
261261 DEFAULT_BINDINGS,
262262 {
apps/web/src/keybindings.ts+2-2
......@@ -206,12 +206,12 @@ export function isChatNewShortcut(
206206 return matchesCommandShortcut(event, keybindings, "chat.new", options);
207207}
208208
209export function isChatNewSameGitStateShortcut(
209export function isChatNewLocalShortcut(
210210 event: ShortcutEventLike,
211211 keybindings: ResolvedKeybindingsConfig,
212212 options?: ShortcutMatchOptions,
213213): boolean {
214 return matchesCommandShortcut(event, keybindings, "chat.newSameGitState", options);
214 return matchesCommandShortcut(event, keybindings, "chat.newLocal", options);
215215}
216216
217217export function isOpenFavoriteEditorShortcut(
packages/contracts/src/keybindings.test.ts+6
......@@ -20,6 +20,12 @@ describe("keybindings contracts", () => {
2020 command: "terminal.close",
2121 });
2222 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");
2329 });
2430
2531 it("rejects invalid command values", () => {
packages/contracts/src/keybindings.ts+1-1
......@@ -6,7 +6,7 @@ export const keybindingCommandSchema = z.enum([
66 "terminal.new",
77 "terminal.close",
88 "chat.new",
9 "chat.newSameGitState",
9 "chat.newLocal",
1010 "editor.openFavorite",
1111]);
1212