diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index e3be8baac540..a44f1f5f4ff9 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -22,7 +22,10 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+j", "command": "terminal.toggle" }, { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, - { "key": "mod+shift+o", "command": "chat.new" }, + { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, + { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, + { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, + { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, { "key": "mod+o", "command": "editor.openFavorite" } ] ``` @@ -46,7 +49,9 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.toggle`: open/close terminal drawer - `terminal.split`: split terminal (in focused terminal context by default) - `terminal.new`: create new terminal (in focused terminal context by default) -- `chat.new`: create a new chat thread for the active project +- `terminal.close`: close/kill the focused terminal (in focused terminal context by default) +- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state +- `chat.newLocal`: create a new local chat thread for the active project (no worktree context) - `editor.openFavorite`: open current project/worktree in the last-used editor ### Key Syntax diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index b9a0b4d7854f..97e2de438ff7 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -28,7 +28,10 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, - { key: "mod+shift+o", command: "chat.new" }, + { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, + { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, + { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, + { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, ]; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5114a24468f6..ccbbd10d7807 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -52,6 +52,7 @@ import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { isOpenFavoriteEditorShortcut, + isTerminalCloseShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalToggleShortcut, @@ -278,6 +279,10 @@ export default function ChatView() { () => shortcutLabelForCommand(keybindings, "terminal.new"), [keybindings], ); + const closeTerminalShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "terminal.close"), + [keybindings], + ); const envLocked = Boolean( activeThread && @@ -554,6 +559,14 @@ export default function ChatView() { return; } + if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) { + event.preventDefault(); + event.stopPropagation(); + if (!activeThread?.terminalOpen) return; + closeTerminal(activeThread.activeTerminalId); + return; + } + if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return; event.preventDefault(); event.stopPropagation(); @@ -570,7 +583,9 @@ export default function ChatView() { return () => window.removeEventListener("keydown", handler); }, [ activeThread?.terminalOpen, + activeThread?.activeTerminalId, activeThreadId, + closeTerminal, createNewTerminal, dispatch, splitTerminal, @@ -1453,6 +1468,7 @@ export default function ChatView() { onNewTerminal={createNewTerminal} splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} newShortcutLabel={newTerminalShortcutLabel ?? undefined} + closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} onActiveTerminalChange={activateTerminal} onCloseTerminal={closeTerminal} onHeightChange={(height) => diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 60392d004292..2c29a6da4fef 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -7,7 +7,7 @@ import { useTheme } from "../hooks/useTheme"; import { DEFAULT_MODEL } from "../model-logic"; import { derivePendingApprovals } from "../session-logic"; import { useStore } from "../store"; -import { isChatNewShortcut } from "../keybindings"; +import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -140,7 +140,13 @@ export default function Sidebar() { }, [state.threads]); const handleNewThread = useCallback( - (projectId: string) => { + ( + projectId: string, + options?: { + branch?: string | null; + worktreePath?: string | null; + }, + ) => { dispatch({ type: "ADD_THREAD", thread: { @@ -166,8 +172,8 @@ export default function Sidebar() { events: [], error: null, createdAt: new Date().toISOString(), - branch: null, - worktreePath: null, + branch: options?.branch ?? null, + worktreePath: options?.worktreePath ?? null, }, }); }, @@ -381,14 +387,23 @@ export default function Sidebar() { useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { - if (!isChatNewShortcut(event, keybindings)) return; - const activeThread = state.threads.find((t) => t.id === state.activeThreadId); + if (isChatNewLocalShortcut(event, keybindings)) { + const projectId = activeThread?.projectId ?? state.projects[0]?.id; + if (!projectId) return; + event.preventDefault(); + handleNewThread(projectId); + return; + } + + if (!isChatNewShortcut(event, keybindings)) return; const projectId = activeThread?.projectId ?? state.projects[0]?.id; if (!projectId) return; - event.preventDefault(); - handleNewThread(projectId); + handleNewThread(projectId, { + branch: activeThread?.branch ?? null, + worktreePath: activeThread?.worktreePath ?? null, + }); }; window.addEventListener("keydown", onWindowKeyDown); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d74ba49a0800..1c35459a9b11 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,5 +1,5 @@ import { FitAddon } from "@xterm/addon-fit"; -import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react"; +import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; import { type NativeApi } from "@t3tools/contracts"; import { Terminal, type ITheme } from "@xterm/xterm"; import { @@ -18,10 +18,7 @@ import { preferredTerminalEditor, resolvePathLinkTarget, } from "../terminal-links"; -import { - isTerminalClearShortcut, - terminalNavigationShortcutData, -} from "../keybindings"; +import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -161,10 +158,7 @@ function TerminalViewport({ try { await api.terminal.write({ threadId, terminalId, data }); } catch (error) { - writeSystemMessage( - activeTerminal, - error instanceof Error ? error.message : fallbackError, - ); + writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); } }; @@ -429,6 +423,7 @@ interface ThreadTerminalDrawerProps { onNewTerminal: () => void; splitShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; + closeShortcutLabel?: string | undefined; onActiveTerminalChange: (terminalId: string) => void; onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; @@ -444,15 +439,9 @@ interface TerminalActionButtonProps { function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { return ( - - } + } > {children} @@ -483,6 +472,7 @@ export default function ThreadTerminalDrawer({ onNewTerminal, splitShortcutLabel, newShortcutLabel, + closeShortcutLabel, onActiveTerminalChange, onCloseTerminal, onHeightChange, @@ -598,6 +588,9 @@ export default function ThreadTerminalDrawer({ ), [normalizedTerminalIds], ); + const closeTerminalActionLabel = closeShortcutLabel + ? `Close Terminal (${closeShortcutLabel})` + : "Close Terminal"; useEffect(() => { onHeightChangeRef.current = onHeightChange; @@ -709,7 +702,9 @@ export default function ThreadTerminalDrawer({ @@ -725,7 +720,7 @@ export default function ThreadTerminalDrawer({ onCloseTerminal(resolvedActiveTerminalId)} - label="Close Terminal" + label={closeTerminalActionLabel} > @@ -805,16 +800,14 @@ export default function ThreadTerminalDrawer({ onCloseTerminal(resolvedActiveTerminalId)} - label="Close Terminal" + label={closeTerminalActionLabel} > @@ -852,6 +845,9 @@ export default function ThreadTerminalDrawer({ > {terminalGroup.terminalIds.map((terminalId) => { const isActive = terminalId === resolvedActiveTerminalId; + const closeTerminalLabel = `Close ${ + terminalLabelById.get(terminalId) ?? "terminal" + }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; return (
{normalizedTerminalIds.length > 1 && ( - + + onCloseTerminal(terminalId)} + aria-label={closeTerminalLabel} + /> + } + > + + + + {closeTerminalLabel} + + )}
); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 4ceb1b920ac8..2a3b9c3244e4 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -9,8 +9,10 @@ import { import { formatShortcutLabel, isChatNewShortcut, + isChatNewLocalShortcut, isOpenFavoriteEditorShortcut, isTerminalClearShortcut, + isTerminalCloseShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalToggleShortcut, @@ -83,7 +85,13 @@ const DEFAULT_BINDINGS = compile([ command: "terminal.new", whenAst: whenIdentifier("terminalFocus"), }, + { + shortcut: modShortcut("w"), + command: "terminal.close", + whenAst: whenIdentifier("terminalFocus"), + }, { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, + { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, ]); @@ -101,8 +109,8 @@ describe("isTerminalToggleShortcut", () => { }); }); -describe("split/new terminal shortcuts", () => { - it("requires terminalFocus for default split/new bindings", () => { +describe("split/new/close terminal shortcuts", () => { + it("requires terminalFocus for default split/new/close bindings", () => { assert.isFalse( isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", @@ -115,6 +123,12 @@ describe("split/new terminal shortcuts", () => { context: { terminalFocus: false }, }), ); + assert.isFalse( + isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: false }, + }), + ); }); it("matches split/new when terminalFocus is true", () => { @@ -130,6 +144,12 @@ describe("split/new terminal shortcuts", () => { context: { terminalFocus: true }, }), ); + assert.isTrue( + isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), + ); }); it("supports when expressions", () => { @@ -225,6 +245,27 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches chat.newLocal shortcut", () => { + assert.isTrue( + isChatNewLocalShortcut( + event({ key: "n", metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + }, + ), + ); + assert.isTrue( + isChatNewLocalShortcut( + event({ key: "n", ctrlKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "Linux", + }, + ), + ); + }); + it("matches editor.openFavorite shortcut", () => { assert.isTrue( isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 4efcd6fee399..859602e97cb6 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -190,6 +190,14 @@ export function isTerminalNewShortcut( return matchesCommandShortcut(event, keybindings, "terminal.new", options); } +export function isTerminalCloseShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.close", options); +} + export function isChatNewShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, @@ -198,6 +206,14 @@ export function isChatNewShortcut( return matchesCommandShortcut(event, keybindings, "chat.new", options); } +export function isChatNewLocalShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "chat.newLocal", options); +} + export function isOpenFavoriteEditorShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 3427e581c2aa..12726ea720b8 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -14,6 +14,18 @@ describe("keybindings contracts", () => { command: "terminal.toggle", }); assert.strictEqual(parsed.command, "terminal.toggle"); + + const parsedClose = keybindingRuleSchema.parse({ + key: "mod+w", + command: "terminal.close", + }); + assert.strictEqual(parsedClose.command, "terminal.close"); + + const parsedLocal = keybindingRuleSchema.parse({ + key: "mod+shift+n", + command: "chat.newLocal", + }); + assert.strictEqual(parsedLocal.command, "chat.newLocal"); }); it("rejects invalid command values", () => { diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 921fcbd1dd2b..d9d8bfbf9eef 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -4,7 +4,9 @@ export const keybindingCommandSchema = z.enum([ "terminal.toggle", "terminal.split", "terminal.new", + "terminal.close", "chat.new", + "chat.newLocal", "editor.openFavorite", ]);