diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx
index c16f817e5..c3868e55b 100644
--- a/packages/app/src/components/agent-input-area.tsx
+++ b/packages/app/src/components/agent-input-area.tsx
@@ -120,6 +120,7 @@ export function AgentInputArea({
const toast = useToast();
const voice = useVoiceOptional();
const voiceToggleKeys = useShortcutKeys("voice-toggle");
+ const dictationCancelKeys = useShortcutKeys("dictation-cancel");
const isDictationReady =
isConnected &&
(agentDirectoryStatus === "ready" ||
@@ -595,7 +596,7 @@ export function AgentInputArea({
Interrupt
-
+ {dictationCancelKeys ? : null}
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index 1651d5fcf..b8ef91dac 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -745,7 +745,7 @@ function DesktopSidebar({
Add project
-
+ {newAgentKeys ? : null}
diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx
index 261da6465..05bfd25e4 100644
--- a/packages/app/src/components/message-input.tsx
+++ b/packages/app/src/components/message-input.tsx
@@ -33,6 +33,7 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
import { focusWithRetries } from "@/utils/web-focus";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
+import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import {
markScrollInvestigationEvent,
@@ -210,6 +211,10 @@ export const MessageInput = forwardRef(funct
markScrollInvestigationRender(investigationComponentId);
const toast = useToast();
const voice = useVoiceOptional();
+ const sendKeys = useShortcutKeys("message-input-send");
+ const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
+ const dictationToggleKeys = useShortcutKeys("dictation-toggle");
+ const queueKeys = useShortcutKeys("message-input-queue");
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const rootRef = useRef(null);
const inputWrapperRef = useRef(null);
@@ -1006,10 +1011,12 @@ export const MessageInput = forwardRef(funct
: "Mute voice"
: "Dictation"}
-
+ {(isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys) ? (
+
+ ) : null}
@@ -1032,7 +1039,7 @@ export const MessageInput = forwardRef(funct
Queue
-
+ {queueKeys ? : null}
@@ -1055,7 +1062,7 @@ export const MessageInput = forwardRef(funct
Send
-
+ {sendKeys ? : null}
diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx
index a1a6cb262..ae6ff3d7a 100644
--- a/packages/app/src/components/sidebar-workspace-list.tsx
+++ b/packages/app/src/components/sidebar-workspace-list.tsx
@@ -74,6 +74,10 @@ import { confirmDialog } from "@/utils/confirm-dialog";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { Shortcut } from "@/components/ui/shortcut";
+import type { ShortcutKey } from "@/utils/format-shortcut";
+import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
+import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
@@ -116,6 +120,7 @@ interface ProjectHeaderRowProps {
onPress: () => void;
serverId: string | null;
canCreateWorktree: boolean;
+ isProjectActive?: boolean;
onWorkspacePress?: () => void;
onWorktreeCreated?: (workspaceId: string) => void;
shortcutNumber?: number | null;
@@ -145,6 +150,7 @@ interface WorkspaceRowInnerProps {
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
+ archiveShortcutKeys?: ShortcutKey[] | null;
}
function resolveStatusDotColor(input: {
@@ -267,14 +273,17 @@ function NewWorktreeButton({
visible,
loading = false,
testID,
+ showShortcutHint = false,
}: {
displayName: string;
onPress: () => void;
visible: boolean;
loading?: boolean;
testID: string;
+ showShortcutHint?: boolean;
}) {
const { theme } = useUnistyles();
+ const newWorktreeKeys = useShortcutKeys("new-worktree");
return (
@@ -309,8 +318,13 @@ function NewWorktreeButton({
}
-
- New worktree
+
+
+ New worktree
+ {showShortcutHint && newWorktreeKeys ? (
+
+ ) : null}
+
@@ -544,6 +558,7 @@ function ProjectHeaderRow({
onPress,
serverId,
canCreateWorktree,
+ isProjectActive = false,
onWorkspacePress,
onWorktreeCreated,
shortcutNumber = null,
@@ -594,6 +609,17 @@ function ProjectHeaderRow({
toast.error(error instanceof Error ? error.message : String(error));
},
});
+ useKeyboardActionHandler({
+ handlerId: `worktree-new-${project.projectKey}`,
+ actions: ["worktree.new"],
+ enabled: isProjectActive && canCreateWorktree && !createWorktreeMutation.isPending,
+ priority: 0,
+ handle: () => {
+ createWorktreeMutation.mutate();
+ return true;
+ },
+ });
+
const interaction = useLongPressDragInteraction({
drag,
menuController,
@@ -636,6 +662,7 @@ function ProjectHeaderRow({
onPress={() => createWorktreeMutation.mutate()}
visible={isHovered || isMobileBreakpoint}
loading={createWorktreeMutation.isPending}
+ showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`}
/>
) : null}
@@ -711,6 +738,7 @@ function WorkspaceRowInner({
onArchive,
onCopyBranchName,
onCopyPath,
+ archiveShortcutKeys,
}: WorkspaceRowInnerProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
@@ -792,7 +820,7 @@ function WorkspaceRowInner({
/>
)}
-
+
{onCopyPath ? (
}
+ trailing={archiveShortcutKeys ? : null}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
@@ -903,7 +932,7 @@ function WorkspaceRowWithMenu({
void (async () => {
const confirmed = await confirmDialog({
title: "Archive worktree?",
- message: `Archive "${workspace.name}"?\n\nThis removes the worktree from the sidebar.`,
+ message: `Archive "${workspace.name}"?\n\nThe worktree will be removed from disk, terminals will be stopped, and agents inside will be archived.\n\nYour branch is still accessible if you committed.`,
confirmLabel: "Archive",
cancelLabel: "Cancel",
destructive: true,
@@ -991,6 +1020,23 @@ function WorkspaceRowWithMenu({
toast.copied("Branch name copied");
}, [toast, workspace.name]);
+ const archiveShortcutKeys = useShortcutKeys("archive-worktree");
+
+ useKeyboardActionHandler({
+ handlerId: `worktree-archive-${workspace.workspaceKey}`,
+ actions: ["worktree.archive"],
+ enabled: selected && !isArchiving,
+ priority: 0,
+ handle: () => {
+ if (isWorktree) {
+ handleArchiveWorktree();
+ } else {
+ handleArchiveWorkspace();
+ }
+ return true;
+ },
+ });
+
return (
);
}
@@ -1182,6 +1229,7 @@ function FlattenedProjectRow({
drag,
isDragging,
dragHandleProps,
+ isProjectActive = false,
}: {
project: SidebarProjectEntry;
displayName: string;
@@ -1196,6 +1244,7 @@ function FlattenedProjectRow({
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
+ isProjectActive?: boolean;
}) {
if (project.projectKind === "non_git") {
return (
@@ -1226,6 +1275,7 @@ function FlattenedProjectRow({
onPress={onPress}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
+ isProjectActive={isProjectActive}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
shortcutNumber={shortcutNumber}
@@ -1327,6 +1377,13 @@ function ProjectBlock({
[activeWorkspaceSelection, collapsed, project, serverId],
);
+ const isProjectActive = useMemo(() => {
+ if (!serverId || !activeWorkspaceSelection || activeWorkspaceSelection.serverId !== serverId) {
+ return false;
+ }
+ return project.workspaces.some((w) => w.workspaceId === activeWorkspaceSelection.workspaceId);
+ }, [serverId, activeWorkspaceSelection, project.workspaces]);
+
const renderWorkspaceRow = useCallback(
(
item: SidebarWorkspaceEntry,
@@ -1419,6 +1476,7 @@ function ProjectBlock({
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
+ isProjectActive={isProjectActive}
/>
) : (
<>
@@ -1432,6 +1490,7 @@ function ProjectBlock({
onPress={onToggleCollapsed}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
+ isProjectActive={isProjectActive}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
drag={drag}
@@ -1923,9 +1982,18 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
flexShrink: 0,
},
+ projectActionTooltipRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ },
projectActionTooltipText: {
color: theme.colors.foreground,
- fontSize: theme.fontSize.xs,
+ fontSize: theme.fontSize.sm,
+ },
+ projectActionTooltipShortcut: {
+ backgroundColor: theme.colors.surface3,
+ borderColor: theme.colors.borderAccent,
},
workspaceRow: {
minHeight: 36,
diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx
index eae652b8e..d98edc09d 100644
--- a/packages/app/src/components/terminal-pane.tsx
+++ b/packages/app/src/components/terminal-pane.tsx
@@ -1,12 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
-import { Plus, X } from "lucide-react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
-import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
-import type { ListTerminalsResponse } from "@server/shared/messages";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -15,8 +11,6 @@ import {
normalizeTerminalTransportKey,
resolvePendingModifierDataInput,
} from "@/utils/terminal-keys";
-import { upsertTerminalListEntry } from "@/utils/terminal-list";
-import { confirmDialog } from "@/utils/confirm-dialog";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import {
TerminalStreamController,
@@ -29,14 +23,10 @@ import TerminalEmulator from "./terminal-emulator";
interface TerminalPaneProps {
serverId: string;
cwd: string;
- selectedTerminalId: string | null;
- onSelectedTerminalIdChange?: (terminalId: string | null) => void;
- hideHeader?: boolean;
- manageTerminalDirectorySubscription?: boolean;
+ terminalId: string;
}
const MAX_OUTPUT_CHARS = 200_000;
-const TERMINAL_TAB_MAX_WIDTH = 220;
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
const MODIFIER_LABELS = {
@@ -79,8 +69,6 @@ type PendingTerminalInput =
};
};
-type ListTerminalsPayload = ListTerminalsResponse["payload"];
-
const EMPTY_MODIFIERS: ModifierState = {
ctrl: false,
shift: false,
@@ -91,30 +79,10 @@ function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`;
}
-function TerminalCloseGradient({ color, gradientId }: { color: string; gradientId: string }) {
- return (
-
-
-
- );
-}
-
export function TerminalPane({
serverId,
cwd,
- selectedTerminalId,
- onSelectedTerminalIdChange,
- hideHeader = false,
- manageTerminalDirectorySubscription = true,
+ terminalId,
}: TerminalPaneProps) {
const isScreenFocused = useIsFocused();
const { theme } = useUnistyles();
@@ -129,12 +97,10 @@ export function TerminalPane({
enabled: isMobile,
});
- const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
- const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef(null);
const workspaceTerminalSession = useMemo(
@@ -157,20 +123,20 @@ export function TerminalPane({
getOutputSessionState,
);
const selectedOutputState = useMemo(() => {
- if (outputState.selectedTerminalId === selectedTerminalId) {
+ if (outputState.selectedTerminalId === terminalId) {
return outputState;
}
return {
...outputState,
- selectedTerminalId,
- snapshotText: outputSession.readSnapshot({ terminalId: selectedTerminalId }),
+ selectedTerminalId: terminalId,
+ snapshotText: outputSession.readSnapshot({ terminalId }),
snapshotSequence: 0,
chunkText: "",
chunkSequence: 0,
chunkReplay: false,
};
- }, [outputSession, outputState, selectedTerminalId]);
+ }, [outputSession, outputState, terminalId]);
const [activeStream, setActiveStream] = useState<{
terminalId: string;
streamId: number;
@@ -180,68 +146,14 @@ export function TerminalPane({
const [modifiers, setModifiers] = useState(EMPTY_MODIFIERS);
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
- const [hoveredTerminalId, setHoveredTerminalId] = useState(null);
- const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState(null);
- const hoverOutTimeoutRef = useRef | null>(null);
- const selectedTerminalIdRef = useRef(selectedTerminalId);
+ const terminalIdRef = useRef(terminalId);
const pendingTerminalInputRef = useRef([]);
const keyboardRefitTimeoutsRef = useRef>>([]);
const lastAutoFocusKeyRef = useRef(null);
- const updateSelectedTerminalId = useCallback(
- (next: string | null) => {
- onSelectedTerminalIdChange?.(next);
- },
- [onSelectedTerminalIdChange],
- );
-
useEffect(() => {
- selectedTerminalIdRef.current = selectedTerminalId;
- }, [selectedTerminalId]);
-
- const clearHoverOutTimeout = useCallback(() => {
- if (!hoverOutTimeoutRef.current) {
- return;
- }
- clearTimeout(hoverOutTimeoutRef.current);
- hoverOutTimeoutRef.current = null;
- }, []);
-
- const handleTerminalTabHoverIn = useCallback(
- (terminalId: string) => {
- clearHoverOutTimeout();
- setHoveredTerminalId(terminalId);
- },
- [clearHoverOutTimeout],
- );
-
- const handleTerminalTabHoverOut = useCallback(
- (terminalId: string) => {
- clearHoverOutTimeout();
- hoverOutTimeoutRef.current = setTimeout(() => {
- setHoveredTerminalId((current) => (current === terminalId ? null : current));
- setHoveredCloseTerminalId((current) => (current === terminalId ? null : current));
- }, 50);
- },
- [clearHoverOutTimeout],
- );
-
- const handleTerminalCloseHoverIn = useCallback(
- (terminalId: string) => {
- clearHoverOutTimeout();
- setHoveredTerminalId(terminalId);
- setHoveredCloseTerminalId(terminalId);
- },
- [clearHoverOutTimeout],
- );
-
- const handleTerminalCloseHoverOut = useCallback((terminalId: string) => {
- setHoveredCloseTerminalId((current) => (current === terminalId ? null : current));
- }, []);
-
- useEffect(() => {
- return () => clearHoverOutTimeout();
- }, [clearHoverOutTimeout]);
+ terminalIdRef.current = terminalId;
+ }, [terminalId]);
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
@@ -251,19 +163,19 @@ export function TerminalPane({
}, []);
useEffect(() => {
- if (isMobile || !isScreenFocused || !selectedTerminalId) {
+ if (isMobile || !isScreenFocused || !terminalId) {
lastAutoFocusKeyRef.current = null;
return;
}
- const nextFocusKey = `${scopeKey}:${selectedTerminalId}`;
+ const nextFocusKey = `${scopeKey}:${terminalId}`;
if (lastAutoFocusKeyRef.current === nextFocusKey) {
return;
}
lastAutoFocusKeyRef.current = nextFocusKey;
requestTerminalFocus();
- }, [isMobile, isScreenFocused, requestTerminalFocus, scopeKey, selectedTerminalId]);
+ }, [isMobile, isScreenFocused, requestTerminalFocus, scopeKey, terminalId]);
const clearKeyboardRefitTimeouts = useCallback(() => {
if (keyboardRefitTimeoutsRef.current.length === 0) {
@@ -302,7 +214,7 @@ export function TerminalPane({
useFocusEffect(
useCallback(() => {
- if (!selectedTerminalId) {
+ if (!terminalId) {
return;
}
// Navigation transitions can temporarily report stale dimensions.
@@ -318,23 +230,9 @@ export function TerminalPane({
clearTimeout(handle);
}
};
- }, [requestTerminalReflow, selectedTerminalId]),
+ }, [requestTerminalReflow, terminalId]),
);
- const terminalsQuery = useQuery({
- queryKey: terminalsQueryKey,
- enabled: Boolean(client && isConnected && cwd.startsWith("/")),
- queryFn: async () => {
- if (!client) {
- throw new Error("Host is not connected");
- }
- return await client.listTerminals(cwd);
- },
- staleTime: 5_000,
- });
-
- const terminals = terminalsQuery.data?.terminals ?? [];
-
useEffect(() => {
if (!client || !isConnected || !isScreenFocused) {
return;
@@ -358,110 +256,10 @@ export function TerminalPane({
});
}, [client, isConnected]);
- useEffect(() => {
- if (!manageTerminalDirectorySubscription || !client || !isConnected || !cwd.startsWith("/")) {
- return;
- }
-
- const unsubscribe = client.on("terminals_changed", (message) => {
- if (message.type !== "terminals_changed") {
- return;
- }
- if (message.payload.cwd !== cwd) {
- return;
- }
-
- queryClient.setQueryData(terminalsQueryKey, (current) => ({
- cwd: message.payload.cwd,
- terminals: message.payload.terminals,
- requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
- }));
- });
-
- client.subscribeTerminals({ cwd });
-
- return () => {
- unsubscribe();
- client.unsubscribeTerminals({ cwd });
- };
- }, [
- client,
- cwd,
- isConnected,
- manageTerminalDirectorySubscription,
- queryClient,
- terminalsQueryKey,
- ]);
-
- const createTerminalMutation = useMutation({
- mutationFn: async () => {
- if (!client) {
- throw new Error("Host is not connected");
- }
- return await client.createTerminal(cwd);
- },
- onSuccess: (payload) => {
- const createdTerminal = payload.terminal;
- if (createdTerminal) {
- queryClient.setQueryData(terminalsQueryKey, (current) => {
- const nextTerminals = upsertTerminalListEntry({
- terminals: current?.terminals ?? [],
- terminal: createdTerminal,
- });
-
- return {
- cwd: current?.cwd ?? cwd,
- terminals: nextTerminals,
- requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
- };
- });
- updateSelectedTerminalId(createdTerminal.id);
- requestTerminalFocus();
- }
- void queryClient.invalidateQueries({
- queryKey: terminalsQueryKey,
- });
- },
- });
-
- const killTerminalMutation = useMutation({
- mutationFn: async (terminalId: string) => {
- if (!client) {
- throw new Error("Host is not connected");
- }
- const payload = await client.killTerminal(terminalId);
- if (!payload.success) {
- throw new Error("Unable to close terminal");
- }
- return payload;
- },
- onSuccess: (_, terminalId) => {
- setHoveredTerminalId((current) => (current === terminalId ? null : current));
- outputSession.clearTerminal({ terminalId });
- if (selectedTerminalIdRef.current === terminalId) {
- updateSelectedTerminalId(null);
- setModifiers({ ...EMPTY_MODIFIERS });
- }
- void queryClient.invalidateQueries({
- queryKey: terminalsQueryKey,
- });
- void queryClient.refetchQueries({
- queryKey: terminalsQueryKey,
- type: "active",
- });
- },
- });
-
useEffect(() => {
lastReportedSizeRef.current = null;
}, [scopeKey]);
- useEffect(() => {
- const terminalIds = terminals.map((terminal) => terminal.id);
- outputSession.prune({ terminalIds });
- streamControllerRef.current?.pruneResumeOffsets({ terminalIds });
- }, [outputSession, terminals]);
-
const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => {
setIsAttaching(status.isAttaching);
setStreamError(status.error);
@@ -501,7 +299,7 @@ export function TerminalPane({
streamControllerRef.current = controller;
controller.setTerminal({
- terminalId: isScreenFocused ? selectedTerminalIdRef.current : null,
+ terminalId: isScreenFocused ? terminalIdRef.current : null,
});
return () => {
@@ -521,29 +319,21 @@ export function TerminalPane({
useEffect(() => {
pendingTerminalInputRef.current = [];
- const nextSelectedTerminalId = isScreenFocused ? selectedTerminalId : null;
+ const nextTerminalId = isScreenFocused ? terminalId : null;
outputSession.setSelectedTerminal({
- terminalId: nextSelectedTerminalId,
+ terminalId: nextTerminalId,
});
streamControllerRef.current?.setTerminal({
- terminalId: nextSelectedTerminalId,
+ terminalId: nextTerminalId,
});
- }, [isScreenFocused, outputSession, selectedTerminalId]);
+ }, [isScreenFocused, outputSession, terminalId]);
const activeStreamId =
- activeStream && activeStream.terminalId === selectedTerminalId ? activeStream.streamId : null;
+ activeStream && activeStream.terminalId === terminalId ? activeStream.streamId : null;
const getCurrentActiveStreamId = useCallback(() => {
return streamControllerRef.current?.getActiveStreamId() ?? null;
}, []);
- const selectedTerminal = useMemo(
- () => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
- [terminals, selectedTerminalId],
- );
- const handleCreateTerminal = useCallback(() => {
- createTerminalMutation.mutate();
- }, [createTerminalMutation]);
-
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
const queue = pendingTerminalInputRef.current;
queue.push(entry);
@@ -558,13 +348,13 @@ export function TerminalPane({
return false;
}
- const terminalId = selectedTerminalIdRef.current;
- if (!terminalId) {
+ const currentTerminalId = terminalIdRef.current;
+ if (!currentTerminalId) {
return false;
}
if (entry.type === "data") {
- client.sendTerminalInput(terminalId, {
+ client.sendTerminalInput(currentTerminalId, {
type: "input",
data: entry.data,
});
@@ -575,7 +365,7 @@ export function TerminalPane({
if (encoded.length === 0) {
return true;
}
- client.sendTerminalInput(terminalId, {
+ client.sendTerminalInput(currentTerminalId, {
type: "input",
data: encoded,
});
@@ -611,29 +401,6 @@ export function TerminalPane({
flushPendingTerminalInput();
}, [activeStreamId, flushPendingTerminalInput]);
- const handleCloseTerminal = useCallback(
- async (terminalId: string) => {
- if (killTerminalMutation.isPending && killTerminalMutation.variables === terminalId) {
- return;
- }
-
- const confirmed = await confirmDialog({
- title: "Close terminal?",
- message: "Any running process in this terminal will be stopped immediately.",
- confirmLabel: "Close",
- cancelLabel: "Cancel",
- destructive: true,
- });
-
- if (!confirmed) {
- return;
- }
-
- killTerminalMutation.mutate(terminalId);
- },
- [killTerminalMutation],
- );
-
const clearPendingModifiers = useCallback(() => {
setModifiers({ ...EMPTY_MODIFIERS });
}, []);
@@ -646,7 +413,7 @@ export function TerminalPane({
alt: boolean;
meta?: boolean;
}): boolean => {
- if (!client || !selectedTerminalIdRef.current) {
+ if (!client || !terminalIdRef.current) {
enqueuePendingTerminalInput({
type: "key",
input: {
@@ -710,7 +477,7 @@ export function TerminalPane({
}
}
- if (!client || !selectedTerminalIdRef.current) {
+ if (!client || !terminalIdRef.current) {
enqueuePendingTerminalInput({
type: "data",
data,
@@ -741,7 +508,7 @@ export function TerminalPane({
const handleTerminalResize = useCallback(
async (input: { rows: number; cols: number }) => {
const { rows, cols } = input;
- if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) {
+ if (!client || !terminalId || rows <= 0 || cols <= 0) {
return;
}
const normalizedRows = Math.floor(rows);
@@ -751,13 +518,13 @@ export function TerminalPane({
return;
}
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
- client.sendTerminalInput(selectedTerminalId, {
+ client.sendTerminalInput(terminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
});
},
- [client, selectedTerminalId],
+ [client, terminalId],
);
const handleTerminalKey = useCallback(
@@ -816,119 +583,10 @@ export function TerminalPane({
);
}
- const queryError = terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null;
- const isCreating = createTerminalMutation.isPending;
- const createError =
- createTerminalMutation.error instanceof Error ? createTerminalMutation.error.message : null;
- const closeError =
- killTerminalMutation.error instanceof Error ? killTerminalMutation.error.message : null;
- const combinedError = streamError ?? closeError ?? createError ?? queryError;
-
return (
- {!hideHeader ? (
-
-
- {terminals.map((terminal) => {
- const isActive = terminal.id === selectedTerminalId;
- const isTabHovered = hoveredTerminalId === terminal.id;
- const isCloseHovered = hoveredCloseTerminalId === terminal.id;
- const isClosingTerminal =
- killTerminalMutation.isPending && killTerminalMutation.variables === terminal.id;
- const shouldShowCloseButton = isTabHovered || isCloseHovered || isClosingTerminal;
- const gradientId = `terminal-close-gradient-${terminal.id.replace(
- /[^a-zA-Z0-9_-]/g,
- "-",
- )}`;
- return (
- updateSelectedTerminalId(terminal.id)}
- onHoverIn={() => handleTerminalTabHoverIn(terminal.id)}
- onHoverOut={() => handleTerminalTabHoverOut(terminal.id)}
- style={({ pressed, hovered }) => [
- styles.terminalTab,
- isActive && styles.terminalTabActive,
- shouldShowCloseButton && styles.terminalTabHovered,
- (pressed || hovered) && styles.terminalTabHovered,
- ]}
- >
-
- {terminal.name}
-
- handleTerminalCloseHoverIn(terminal.id)}
- onHoverOut={() => handleTerminalCloseHoverOut(terminal.id)}
- onPress={(event) => {
- event.stopPropagation();
- void handleCloseTerminal(terminal.id);
- }}
- style={({ hovered, pressed }) => [
- styles.terminalTabCloseButton,
- shouldShowCloseButton
- ? styles.terminalTabCloseButtonShown
- : styles.terminalTabCloseButtonHidden,
- ]}
- >
- {({ hovered = false, pressed = false }) => {
- const iconColor =
- hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted;
- return (
- <>
-
-
- {isClosingTerminal ? (
-
- ) : (
-
- )}
-
- >
- );
- }}
-
-
- );
- })}
-
-
- [
- styles.headerIconButton,
- (hovered || pressed) && styles.headerIconButtonHovered,
- ]}
- >
- {isCreating ? (
-
- ) : (
-
- )}
-
-
-
- ) : null}
-
- {selectedTerminal && isScreenFocused ? (
+ {isScreenFocused ? (
- ) : selectedTerminal ? (
-
) : (
-
- No terminal selected
-
+
)}
{isAttaching && isScreenFocused ? (
@@ -987,10 +641,10 @@ export function TerminalPane({
) : null}
- {combinedError ? (
+ {streamError ? (
- {combinedError}
+ {streamError}
) : null}
@@ -1048,100 +702,6 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
backgroundColor: theme.colors.surface0,
},
- header: {
- minHeight: 48,
- borderBottomWidth: 1,
- borderBottomColor: theme.colors.border,
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "space-between",
- gap: theme.spacing[1],
- paddingHorizontal: theme.spacing[1],
- paddingVertical: theme.spacing[1],
- },
- tabsScroll: {
- flex: 1,
- minWidth: 0,
- },
- tabsContent: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[1],
- paddingRight: theme.spacing[2],
- },
- terminalTab: {
- borderRadius: theme.borderRadius.md,
- paddingHorizontal: theme.spacing[3],
- paddingVertical: theme.spacing[2],
- justifyContent: "center",
- maxWidth: TERMINAL_TAB_MAX_WIDTH,
- minWidth: 96,
- overflow: "hidden",
- position: "relative",
- },
- terminalTabHovered: {
- backgroundColor: theme.colors.surface2,
- },
- terminalTabActive: {
- backgroundColor: theme.colors.surface2,
- },
- terminalTabText: {
- minWidth: 0,
- flexShrink: 1,
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- fontWeight: theme.fontWeight.normal,
- },
- terminalTabTextActive: {
- color: theme.colors.foreground,
- },
- terminalTabCloseButton: {
- position: "absolute",
- right: 0,
- top: 0,
- bottom: 0,
- width: 32,
- borderRadius: theme.borderRadius.sm,
- alignItems: "flex-end",
- justifyContent: "center",
- paddingRight: theme.spacing[2],
- },
- terminalTabCloseButtonShown: {
- opacity: 1,
- },
- terminalTabCloseButtonHidden: {
- opacity: 0,
- },
- terminalTabCloseGradient: {
- ...StyleSheet.absoluteFillObject,
- borderRadius: theme.borderRadius.sm,
- overflow: "hidden",
- zIndex: 0,
- },
- terminalTabCloseIcon: {
- position: "relative",
- zIndex: 1,
- alignItems: "center",
- justifyContent: "center",
- },
- headerActions: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[1],
- },
- headerIconButton: {
- width: 30,
- height: 30,
- borderRadius: theme.borderRadius.md,
- borderWidth: 1,
- borderColor: theme.colors.border,
- backgroundColor: theme.colors.surface1,
- alignItems: "center",
- justifyContent: "center",
- },
- headerIconButtonHovered: {
- backgroundColor: theme.colors.surface2,
- },
outputContainer: {
flex: 1,
minHeight: 0,
diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx
index 04c3a9886..7ba93ac83 100644
--- a/packages/app/src/components/ui/dropdown-menu.tsx
+++ b/packages/app/src/components/ui/dropdown-menu.tsx
@@ -559,9 +559,9 @@ const styles = StyleSheet.create((theme) => ({
left: 0,
},
content: {
- backgroundColor: theme.colors.surface0,
+ backgroundColor: theme.colors.surface1,
borderWidth: 1,
- borderColor: theme.colors.border,
+ borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
@@ -604,16 +604,16 @@ const styles = StyleSheet.create((theme) => ({
borderColor: "transparent",
},
itemHovered: {
- backgroundColor: theme.colors.surface1,
+ backgroundColor: theme.colors.surface2,
},
itemPressed: {
- backgroundColor: theme.colors.surface1,
+ backgroundColor: theme.colors.surface2,
},
itemSelected: {
- backgroundColor: theme.colors.surface1,
+ backgroundColor: theme.colors.surface2,
},
itemSelectedInteractive: {
- backgroundColor: theme.colors.surface1,
+ backgroundColor: theme.colors.surface2,
},
itemSelectedAccent: {
backgroundColor: theme.colors.accent,
diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts
index 02c931d1c..fe1b5865f 100644
--- a/packages/app/src/hooks/use-keyboard-shortcuts.ts
+++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts
@@ -153,6 +153,21 @@ export function useKeyboardShortcuts({
id: "workspace.tab.new",
scope: "workspace",
});
+ case "worktree.archive":
+ return keyboardActionDispatcher.dispatch({
+ id: "worktree.archive",
+ scope: "sidebar",
+ });
+ case "worktree.new":
+ return keyboardActionDispatcher.dispatch({
+ id: "worktree.new",
+ scope: "sidebar",
+ });
+ case "workspace.terminal.new":
+ return keyboardActionDispatcher.dispatch({
+ id: "workspace.terminal.new",
+ scope: "workspace",
+ });
case "workspace.tab.close.current":
return keyboardActionDispatcher.dispatch({
id: "workspace.tab.close-current",
diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts
index 1925f1226..4cd0ad84c 100644
--- a/packages/app/src/keyboard/actions.ts
+++ b/packages/app/src/keyboard/actions.ts
@@ -7,6 +7,8 @@ export type KeyboardFocusScope =
export type MessageInputKeyboardActionKind =
| "focus"
+ | "send"
+ | "queue"
| "dictation-toggle"
| "dictation-cancel"
| "voice-toggle"
@@ -35,6 +37,9 @@ export type KeyboardActionId =
| "sidebar.toggle.right"
| "command-center.toggle"
| "shortcuts.dialog.toggle"
+ | "workspace.terminal.new"
+ | "worktree.new"
+ | "worktree.archive"
| "message-input.action";
export type KeyboardShortcutPayload =
diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts
index 6c6dd0fa9..817ac7101 100644
--- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts
+++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts
@@ -20,7 +20,10 @@ export type KeyboardActionId =
| "workspace.pane.move-tab.right"
| "workspace.pane.move-tab.up"
| "workspace.pane.move-tab.down"
- | "workspace.pane.close";
+ | "workspace.pane.close"
+ | "workspace.terminal.new"
+ | "worktree.new"
+ | "worktree.archive";
export type KeyboardActionDefinition =
| { id: "message-input.focus"; scope: KeyboardActionScope }
@@ -42,7 +45,10 @@ export type KeyboardActionDefinition =
| { id: "workspace.pane.move-tab.right"; scope: KeyboardActionScope }
| { id: "workspace.pane.move-tab.up"; scope: KeyboardActionScope }
| { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope }
- | { id: "workspace.pane.close"; scope: KeyboardActionScope };
+ | { id: "workspace.pane.close"; scope: KeyboardActionScope }
+ | { id: "workspace.terminal.new"; scope: KeyboardActionScope }
+ | { id: "worktree.new"; scope: KeyboardActionScope }
+ | { id: "worktree.archive"; scope: KeyboardActionScope };
export type KeyboardActionHandler = {
handlerId: string;
diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts
index 8794966e7..b016f6bd2 100644
--- a/packages/app/src/keyboard/keyboard-shortcuts.ts
+++ b/packages/app/src/keyboard/keyboard-shortcuts.ts
@@ -127,6 +127,58 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
+ // --- New worktree ---
+ {
+ id: "worktree-new-cmd-o-mac",
+ action: "worktree.new",
+ combo: "Cmd+O",
+ when: { mac: true, commandCenter: false },
+ help: {
+ id: "new-worktree",
+ section: "global",
+ label: "New worktree",
+ keys: ["mod", "O"],
+ },
+ },
+ {
+ id: "worktree-new-ctrl-o-non-mac",
+ action: "worktree.new",
+ combo: "Ctrl+O",
+ when: { mac: false, commandCenter: false, terminal: false },
+ help: {
+ id: "new-worktree",
+ section: "global",
+ label: "New worktree",
+ keys: ["mod", "O"],
+ },
+ },
+
+ // --- Archive worktree ---
+ {
+ id: "worktree-archive-cmd-shift-backspace-mac",
+ action: "worktree.archive",
+ combo: "Cmd+Shift+Backspace",
+ when: { mac: true, commandCenter: false },
+ help: {
+ id: "archive-worktree",
+ section: "global",
+ label: "Archive worktree",
+ keys: ["mod", "shift", "Backspace"],
+ },
+ },
+ {
+ id: "worktree-archive-ctrl-shift-backspace-non-mac",
+ action: "worktree.archive",
+ combo: "Ctrl+Shift+Backspace",
+ when: { mac: false, commandCenter: false, terminal: false },
+ help: {
+ id: "archive-worktree",
+ section: "global",
+ label: "Archive worktree",
+ keys: ["mod", "shift", "Backspace"],
+ },
+ },
+
// --- Tab management ---
{
id: "workspace-tab-new-cmd-t-mac",
@@ -500,6 +552,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
+ // --- New terminal ---
+ {
+ id: "workspace-terminal-new-cmd-shift-t-mac",
+ action: "workspace.terminal.new",
+ combo: "Cmd+Shift+T",
+ when: { mac: true, commandCenter: false },
+ help: {
+ id: "workspace-terminal-new",
+ section: "global",
+ label: "New terminal",
+ keys: ["mod", "shift", "T"],
+ },
+ },
+ {
+ id: "workspace-terminal-new-ctrl-shift-t-non-mac",
+ action: "workspace.terminal.new",
+ combo: "Ctrl+Shift+T",
+ when: { mac: false, commandCenter: false, terminal: false },
+ help: {
+ id: "workspace-terminal-new",
+ section: "global",
+ label: "New terminal",
+ keys: ["mod", "shift", "T"],
+ },
+ },
+
// --- Command center ---
{
id: "command-center-toggle-cmd-k-mac",
@@ -674,6 +752,52 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
keys: ["Esc"],
},
},
+ {
+ id: "message-input-send-enter",
+ action: "message-input.action",
+ combo: "Enter",
+ when: { focusScope: "message-input", commandCenter: false },
+ payload: { type: "message-input", kind: "send" },
+ preventDefault: false,
+ stopPropagation: false,
+ help: {
+ id: "message-input-send",
+ section: "agent-input",
+ label: "Send message",
+ keys: ["Enter"],
+ },
+ },
+ {
+ id: "message-input-queue-cmd-enter-mac",
+ action: "message-input.action",
+ combo: "Cmd+Enter",
+ when: { mac: true, focusScope: "message-input", commandCenter: false },
+ payload: { type: "message-input", kind: "queue" },
+ preventDefault: false,
+ stopPropagation: false,
+ help: {
+ id: "message-input-queue",
+ section: "agent-input",
+ label: "Queue message",
+ keys: ["mod", "Enter"],
+ },
+ },
+ {
+ id: "message-input-queue-ctrl-enter-non-mac",
+ action: "message-input.action",
+ combo: "Ctrl+Enter",
+ when: { mac: false, focusScope: "message-input", commandCenter: false },
+ payload: { type: "message-input", kind: "queue" },
+ preventDefault: false,
+ stopPropagation: false,
+ help: {
+ id: "message-input-queue",
+ section: "agent-input",
+ label: "Queue message",
+ keys: ["mod", "Enter"],
+ },
+ },
+
{
id: "message-input-voice-mute-toggle",
action: "message-input.action",
diff --git a/packages/app/src/keyboard/shortcut-string.ts b/packages/app/src/keyboard/shortcut-string.ts
index c84ee8588..f55bcb637 100644
--- a/packages/app/src/keyboard/shortcut-string.ts
+++ b/packages/app/src/keyboard/shortcut-string.ts
@@ -36,6 +36,8 @@ KEY_MAP["`"] = { code: "Backquote", key: "`" };
KEY_MAP["/"] = { code: "Slash" };
KEY_MAP["?"] = { code: "Slash", key: "?" };
KEY_MAP["Space"] = { code: "Space", key: " " };
+KEY_MAP["Enter"] = { code: "Enter", key: "Enter" };
+KEY_MAP["Backspace"] = { code: "Backspace" };
KEY_MAP["Escape"] = { code: "Escape" };
KEY_MAP["ArrowLeft"] = { code: "ArrowLeft" };
KEY_MAP["ArrowRight"] = { code: "ArrowRight" };
diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx
index 11c800f1c..6c18d8d36 100644
--- a/packages/app/src/panels/terminal-panel.tsx
+++ b/packages/app/src/panels/terminal-panel.tsx
@@ -49,7 +49,7 @@ function useTerminalPanelDescriptor(
function TerminalPanel() {
const isFocused = useIsFocused();
- const { serverId, workspaceId, target, openTab } = usePaneContext();
+ const { serverId, workspaceId, target } = usePaneContext();
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
if (!isFocused) {
@@ -60,15 +60,7 @@ function TerminalPanel() {
{
- if (!terminalId) {
- return;
- }
- openTab({ kind: "terminal", terminalId });
- }}
- hideHeader
- manageTerminalDirectorySubscription={false}
+ terminalId={target.terminalId}
/>
);
}
diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx
index 9c1a60980..be910ebc7 100644
--- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx
+++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx
@@ -20,6 +20,7 @@ import {
} from "@/components/ui/context-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
import {
@@ -308,6 +309,10 @@ export function WorkspaceDesktopTabsRow({
tabDropPreviewIndex = null,
}: WorkspaceDesktopTabsRowProps) {
const { theme } = useUnistyles();
+ const newAgentTabKeys = useShortcutKeys("workspace-tab-new");
+ const newTerminalTabKeys = useShortcutKeys("workspace-terminal-new");
+ const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
+ const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
const [tabsContainerWidth, setTabsContainerWidth] = useState(0);
const [tabsActionsWidth, setTabsActionsWidth] = useState(0);
@@ -448,10 +453,12 @@ export function WorkspaceDesktopTabsRow({
>
-
+
New agent tab
-
+ {newAgentTabKeys ? (
+
+ ) : null}
@@ -467,8 +474,13 @@ export function WorkspaceDesktopTabsRow({
>
-
- New terminal tab
+
+
+ New terminal tab
+ {newTerminalTabKeys ? (
+
+ ) : null}
+
@@ -483,8 +495,13 @@ export function WorkspaceDesktopTabsRow({
>
-
- Split pane right
+
+
+ Split pane right
+ {splitRightKeys ? (
+
+ ) : null}
+
@@ -499,8 +516,13 @@ export function WorkspaceDesktopTabsRow({
>
-
- Split pane down
+
+
+ Split pane down
+ {splitDownKeys ? (
+
+ ) : null}
+
diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx
index 0e7b953c2..0f67f66fc 100644
--- a/packages/app/src/screens/workspace/workspace-screen.tsx
+++ b/packages/app/src/screens/workspace/workspace-screen.tsx
@@ -1465,6 +1465,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
case "workspace.tab.new":
handleCreateDraftTab();
return true;
+ case "workspace.terminal.new":
+ handleCreateTerminal();
+ return true;
case "workspace.tab.close-current":
if (activeTabId) {
void handleCloseTabById(activeTabId);
@@ -1493,7 +1496,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return false;
}
},
- [activeTabId, handleCloseTabById, handleCreateDraftTab, navigateToTabId, tabs],
+ [activeTabId, handleCloseTabById, handleCreateDraftTab, handleCreateTerminal, navigateToTabId, tabs],
);
const handleWorkspacePaneAction = useCallback(
@@ -1598,6 +1601,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
"workspace.tab.close-current",
"workspace.tab.navigate-index",
"workspace.tab.navigate-relative",
+ "workspace.terminal.new",
] as const,
enabled: Boolean(normalizedServerId && normalizedWorkspaceId),
priority: 100,
diff --git a/packages/app/src/utils/format-shortcut.ts b/packages/app/src/utils/format-shortcut.ts
index 82978687d..4caffcd23 100644
--- a/packages/app/src/utils/format-shortcut.ts
+++ b/packages/app/src/utils/format-shortcut.ts
@@ -2,8 +2,20 @@ export type ShortcutKey = "mod" | "shift" | "alt" | "ctrl" | "meta" | string;
export type ShortcutOs = "mac" | "non-mac";
+const KEY_DISPLAY: Record = {
+ Backspace: "⌫",
+ Enter: "↩",
+ Esc: "⎋",
+ Space: "␣",
+ Left: "←",
+ Right: "→",
+ Up: "↑",
+ Down: "↓",
+};
+
function normalizeKey(key: string): string {
if (!key) return "";
+ if (KEY_DISPLAY[key]) return KEY_DISPLAY[key];
if (key.length === 1) return key.toUpperCase();
return key;
}