Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions KEYBINDINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
```
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ 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" },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mod+w conflicts with browser tab-close

Cmd+W / Ctrl+W is the standard shortcut for closing a browser tab. While the terminalFocus guard limits when this fires, most browsers do not allow JavaScript to intercept and preventDefault() on Ctrl+W/Cmd+W — the tab will still close.

This should work fine in Electron (where the app controls key event routing), but in the browser-served mode the shortcut will effectively be unusable and may cause unexpected tab closures. If the app is meant to run in both contexts, consider choosing a different default binding for browser users (e.g. mod+shift+w).

{ 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" },
];

Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import BranchToolbar from "./BranchToolbar";
import GitActionsControl from "./GitActionsControl";
import {
isOpenFavoriteEditorShortcut,
isTerminalCloseShortcut,
isTerminalNewShortcut,
isTerminalSplitShortcut,
isTerminalToggleShortcut,
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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();
Expand All @@ -570,7 +583,9 @@ export default function ChatView() {
return () => window.removeEventListener("keydown", handler);
}, [
activeThread?.terminalOpen,
activeThread?.activeTerminalId,
activeThreadId,
closeTerminal,
createNewTerminal,
dispatch,
splitTerminal,
Expand Down Expand Up @@ -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) =>
Expand Down
31 changes: 23 additions & 8 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand All @@ -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,
},
});
},
Expand Down Expand Up @@ -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);
Expand Down
80 changes: 44 additions & 36 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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);
}
};

Expand Down Expand Up @@ -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;
Expand All @@ -444,15 +439,9 @@ interface TerminalActionButtonProps {
function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) {
return (
<Popover>
<PopoverTrigger openOnHover
render={
<button
type="button"
className={className}
onClick={onClick}
aria-label={label}
/>
}
<PopoverTrigger
openOnHover
render={<button type="button" className={className} onClick={onClick} aria-label={label} />}
>
{children}
</PopoverTrigger>
Expand Down Expand Up @@ -483,6 +472,7 @@ export default function ThreadTerminalDrawer({
onNewTerminal,
splitShortcutLabel,
newShortcutLabel,
closeShortcutLabel,
onActiveTerminalChange,
onCloseTerminal,
onHeightChange,
Expand Down Expand Up @@ -598,6 +588,9 @@ export default function ThreadTerminalDrawer({
),
[normalizedTerminalIds],
);
const closeTerminalActionLabel = closeShortcutLabel
? `Close Terminal (${closeShortcutLabel})`
: "Close Terminal";

useEffect(() => {
onHeightChangeRef.current = onHeightChange;
Expand Down Expand Up @@ -709,7 +702,9 @@ export default function ThreadTerminalDrawer({
<TerminalActionButton
className="p-1 text-foreground/90 transition-colors hover:bg-accent"
onClick={onSplitTerminal}
label={splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"}
label={
splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"
}
>
<SquareSplitHorizontal className="size-3.25" />
</TerminalActionButton>
Expand All @@ -725,7 +720,7 @@ export default function ThreadTerminalDrawer({
<TerminalActionButton
className="p-1 text-foreground/90 transition-colors hover:bg-accent"
onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
label="Close Terminal"
label={closeTerminalActionLabel}
>
<Trash2 className="size-3.25" />
</TerminalActionButton>
Expand Down Expand Up @@ -805,16 +800,14 @@ export default function ThreadTerminalDrawer({
<TerminalActionButton
className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
onClick={onNewTerminal}
label={
newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"
}
label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"}
>
<Plus className="size-3.25" />
</TerminalActionButton>
<TerminalActionButton
className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
label="Close Terminal"
label={closeTerminalActionLabel}
>
<Trash2 className="size-3.25" />
</TerminalActionButton>
Expand Down Expand Up @@ -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 (
<div
key={terminalId}
Expand All @@ -875,18 +871,30 @@ export default function ThreadTerminalDrawer({
</span>
</button>
{normalizedTerminalIds.length > 1 && (
<button
type="button"
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"
onClick={(event) => {
event.stopPropagation();
onCloseTerminal(terminalId);
}}
aria-label={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`}
title={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`}
>
×
</button>
<Popover>
<PopoverTrigger
openOnHover
render={
<button
type="button"
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"
onClick={() => onCloseTerminal(terminalId)}
aria-label={closeTerminalLabel}
/>
}
>
<XIcon className="size-2.5" />
</PopoverTrigger>
<PopoverPopup
tooltipStyle
side="bottom"
sideOffset={6}
align="center"
className="pointer-events-none select-none"
>
{closeTerminalLabel}
</PopoverPopup>
</Popover>
)}
</div>
);
Expand Down
Loading