refactor(app): centralize keyboard actions and shortcuts

This commit is contained in:
Mohamed Boudra
2026-02-14 19:53:19 +07:00
parent 86b476e309
commit 756f9a7972
18 changed files with 943 additions and 569 deletions
@@ -14,7 +14,7 @@ test('sidebar toggle shows tooltip on the right', async ({ page }) => {
const tooltip = page.getByTestId('menu-button-tooltip');
await expect(tooltip).toBeVisible();
await expect(tooltip).toContainText('Toggle sidebar');
await expect(tooltip).toContainText(/⌘B|Ctrl\+B/);
await expect(tooltip).toContainText(/⌘B|Ctrl\+\./);
await page.waitForTimeout(250);
await expect(tooltip).toBeVisible();
+87
View File
@@ -95,6 +95,24 @@ async function openTerminalsPanel(page: Page): Promise<void> {
});
}
async function getDesktopAgentSidebarOpen(page: Page): Promise<boolean | null> {
return await page.evaluate(() => {
const raw = localStorage.getItem("panel-state");
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as {
state?: { desktop?: { agentListOpen?: boolean } };
};
const value = parsed?.state?.desktop?.agentListOpen;
return typeof value === "boolean" ? value : null;
} catch {
return null;
}
});
}
async function selectNewestTerminalTab(page: Page): Promise<void> {
const tabs = page.locator('[data-testid^="terminal-tab-"]');
@@ -256,6 +274,75 @@ test("terminals are shared by agents on the same cwd", async ({ page }) => {
}
});
test("terminal captures escape and ctrl+c key input", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-keys-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal key combo capture");
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type("cat -v", { delay: 1 });
await page.keyboard.press("Enter");
await expect(surface).toContainText("cat -v", { timeout: 30000 });
await page.keyboard.press("Escape");
await expect(surface).toContainText("^[", { timeout: 30000 });
await page.keyboard.press("Control+C");
await expect(surface).toContainText("^C", { timeout: 30000 });
await page.keyboard.press("Control+B");
await expect(surface).toContainText("^B", { timeout: 30000 });
const marker = `terminal-key-capture-${Date.now()}`;
await page.keyboard.type(`echo ${marker}`, { delay: 1 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(marker, { timeout: 30000 });
} finally {
await repo.cleanup();
}
});
test("Cmd+B toggles sidebar even when terminal is focused", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-cmd-b-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal Cmd+B");
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await expect
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
.toBe(true);
await page.keyboard.press("Meta+B");
await expect
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
.toBe(false);
await page.keyboard.press("Meta+B");
await expect
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
.toBe(true);
} finally {
await repo.cleanup();
}
});
async function getTerminalRows(page: Page): Promise<number> {
return await page.evaluate(() => {
const terminal = (window as { __paseoTerminal?: { rows?: unknown } }).__paseoTerminal;
+2 -2
View File
@@ -36,7 +36,7 @@ import {
import { getIsTauri, getIsTauriMac } from "@/constants/layout";
import { useTrafficLightPadding } from "@/utils/tauri-window";
import { CommandCenter } from "@/components/command-center";
import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { queryClient } from "@/query/query-client";
import {
WEB_NOTIFICATION_CLICK_EVENT,
@@ -147,7 +147,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
const openGestureEnabled =
chromeEnabled && isMobile && mobileView === "agent";
useGlobalKeyboardNav({
useKeyboardShortcuts({
enabled: chromeEnabled,
isMobile,
toggleAgentList,
@@ -28,7 +28,7 @@ import { Theme } from "@/styles/theme";
import { CommandAutocomplete } from "./command-autocomplete";
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { focusWithRetries } from "@/utils/web-focus";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
@@ -74,8 +74,12 @@ export function AgentInputArea({
const insets = useSafeAreaInsets();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const isScreenFocused = useIsFocused();
const focusChatInputRequest = useKeyboardNavStore((s) => s.focusChatInputRequest);
const clearFocusChatInputRequest = useKeyboardNavStore((s) => s.clearFocusChatInputRequest);
const messageInputActionRequest = useKeyboardShortcutsStore(
(s) => s.messageInputActionRequest
);
const clearMessageInputActionRequest = useKeyboardShortcutsStore(
(s) => s.clearMessageInputActionRequest
);
const client = useSessionStore(
(state) => state.sessions[serverId]?.client ?? null
@@ -108,7 +112,7 @@ export function AgentInputArea({
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
const lastHandledFocusRequestIdRef = useRef<number | null>(null);
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null);
// Command autocomplete logic
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
@@ -400,21 +404,36 @@ export function AgentInputArea({
saveDraftInput(agentId, { text: userInput, images: selectedImages });
}, [agentId, userInput, selectedImages, getDraftInput, saveDraftInput]);
// When switching agents from the command center, auto-focus the input on web.
// Keyboard-dispatched message-input actions are routed through store requests.
useEffect(() => {
if (Platform.OS !== "web") return;
if (!isScreenFocused) return;
if (!focusChatInputRequest) return;
if (!messageInputActionRequest) return;
const currentKey = `${serverId}:${agentId}`;
if (focusChatInputRequest.agentKey !== currentKey) {
if (messageInputActionRequest.agentKey !== currentKey) {
return;
}
if (lastHandledFocusRequestIdRef.current === focusChatInputRequest.id) {
if (
lastHandledMessageInputActionRequestIdRef.current ===
messageInputActionRequest.id
) {
return;
}
lastHandledMessageInputActionRequestIdRef.current =
messageInputActionRequest.id;
if (messageInputActionRequest.kind !== "focus") {
messageInputRef.current?.runKeyboardAction(messageInputActionRequest.kind);
clearMessageInputActionRequest(messageInputActionRequest.id);
return;
}
if (Platform.OS !== "web") {
messageInputRef.current?.focus();
clearMessageInputActionRequest(messageInputActionRequest.id);
return;
}
lastHandledFocusRequestIdRef.current = focusChatInputRequest.id;
return focusWithRetries({
focus: () => messageInputRef.current?.focus(),
@@ -424,14 +443,16 @@ export function AgentInputArea({
typeof document !== "undefined" ? document.activeElement : null;
return Boolean(el) && active === el;
},
onSuccess: () => clearFocusChatInputRequest(),
onTimeout: () => clearFocusChatInputRequest(),
onSuccess: () =>
clearMessageInputActionRequest(messageInputActionRequest.id),
onTimeout: () =>
clearMessageInputActionRequest(messageInputActionRequest.id),
});
}, [
agentId,
clearFocusChatInputRequest,
focusChatInputRequest,
clearMessageInputActionRequest,
isScreenFocused,
messageInputActionRequest,
serverId,
]);
@@ -48,9 +48,13 @@ export function CommandCenter() {
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
<View style={[styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }]}>
<View
testID="command-center-panel"
style={[styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }]}
>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<TextInput
testID="command-center-input"
ref={inputRef}
value={query}
onChangeText={setQuery}
@@ -5,6 +5,7 @@ import { Menu, PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
interface MenuHeaderProps {
title?: string;
@@ -18,6 +19,7 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const MenuIcon = isMobile ? Menu : PanelLeft;
@@ -32,7 +34,7 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
<HeaderToggleButton
onPress={toggleAgentList}
tooltipLabel="Toggle sidebar"
tooltipKeys={["mod", "B"]}
tooltipKeys={toggleShortcutKeys}
tooltipSide="right"
testID="menu-button"
nativeID="menu-button"
+37 -173
View File
@@ -30,13 +30,13 @@ import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "./dictation-controls";
import { RealtimeVoiceOverlay } from "./realtime-voice-overlay";
import type { DaemonClient } from "@server/client/daemon-client";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
export interface ImageAttachment {
uri: string;
@@ -82,6 +82,7 @@ export interface MessageInputProps {
export interface MessageInputRef {
focus: () => void;
blur: () => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => void;
/**
* Web-only: return the underlying DOM element for focus assertions/retries.
* May return null if not mounted or on native.
@@ -138,8 +139,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const { theme } = useUnistyles();
const toast = useToast();
const voice = useVoiceOptional();
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const textInputRef = useRef<
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
@@ -153,6 +152,40 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
blur: () => {
textInputRef.current?.blur?.();
},
runKeyboardAction: (action) => {
if (action === "focus") {
textInputRef.current?.focus();
return;
}
if (action === "voice-toggle") {
handleToggleRealtimeVoiceShortcut();
return;
}
if (action === "voice-mute-toggle") {
if (isRealtimeVoiceForCurrentAgent) {
voice?.toggleMute();
}
return;
}
if (action === "dictation-cancel") {
if (isDictatingRef.current) {
cancelDictation();
}
return;
}
if (action === "dictation-toggle") {
if (isDictatingRef.current) {
sendAfterTranscriptRef.current = true;
confirmDictation();
} else {
void startDictationIfAvailable();
}
}
},
getNativeElement: () => {
if (!IS_WEB) return null;
const current = textInputRef.current as
@@ -295,121 +328,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
await startDictation();
}, [dictationUnavailableMessage, startDictation, toast]);
// Cmd+D to start/submit dictation, Cmd+Shift+D toggles realtime voice, Escape cancels dictation
useEffect(() => {
if (!IS_WEB) return;
const toggleRealtimeVoice = () => {
if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) {
return;
}
if (voice.isVoiceSwitching) {
return;
}
if (voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)) {
const tasks: Promise<unknown>[] = [];
if (isAgentRunning && client) {
tasks.push(client.cancelAgent(voiceAgentId));
}
tasks.push(voice.stopVoice());
void Promise.allSettled(tasks).then((results) => {
results.forEach((result) => {
if (result.status === "rejected") {
console.error(
"[MessageInput] Failed to stop realtime voice",
result.reason
);
}
});
});
return;
}
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
console.error("[MessageInput] Failed to start realtime voice", error);
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: null;
if (message && message.trim().length > 0) {
toast.error(message);
}
});
};
const resolveNativeInput = (): unknown => {
const current = textInputRef.current as any;
if (!current) return null;
if (typeof current.getNativeRef === "function") {
return current.getNativeRef();
}
return current;
};
function handleKeyDown(event: KeyboardEvent) {
const nativeInput = resolveNativeInput();
const isFromInput = Boolean(nativeInput && event.target === nativeInput);
if (!isScreenFocused && !isInputFocusedRef.current && !isFromInput) {
return;
}
const isMod = event.metaKey || event.ctrlKey;
const isKeyD = event.code === "KeyD" || event.key.toLowerCase() === "d";
if (isMod && event.shiftKey && isKeyD && !event.repeat) {
event.preventDefault();
toggleRealtimeVoice();
return;
}
if (
isRealtimeVoiceForCurrentAgent &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
(event.code === "Space" || event.key === " ") &&
!event.repeat
) {
event.preventDefault();
voice?.toggleMute();
return;
}
const dictating = isDictatingRef.current;
// Cmd+D: start dictation or submit if already dictating
if (isMod && isKeyD) {
event.preventDefault();
if (dictating) {
sendAfterTranscriptRef.current = true;
confirmDictation();
} else {
void startDictationIfAvailable();
}
return;
}
// Escape: cancel dictation
if (event.key === "Escape" && dictating) {
event.preventDefault();
cancelDictation();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
cancelDictation,
client,
confirmDictation,
disabled,
isAgentRunning,
isConnected,
isRealtimeVoiceForCurrentAgent,
isScreenFocused,
startDictationIfAvailable,
toast,
voiceAgentId,
voiceServerId,
voice,
]);
// Animate overlay
useEffect(() => {
overlayTransition.value = withTiming(showOverlay ? 1 : 0, {
@@ -636,60 +554,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
const key = event.nativeEvent.key.toLowerCase();
// Cmd+B or Ctrl+B: toggle sidebar
if ((metaKey || ctrlKey) && key === "b") {
event.preventDefault();
toggleAgentList();
return;
}
// Cmd+E or Ctrl+E: toggle explorer sidebar
if ((metaKey || ctrlKey) && key === "e") {
event.preventDefault();
toggleFileExplorer();
return;
}
// Cmd+Shift+D or Ctrl+Shift+D: toggle realtime voice mode
if ((metaKey || ctrlKey) && shiftKey && key === "d") {
event.preventDefault();
handleToggleRealtimeVoiceShortcut();
return;
}
// Cmd+D or Ctrl+D: start dictation or submit if already dictating
if ((metaKey || ctrlKey) && key === "d") {
event.preventDefault();
if (isDictating) {
sendAfterTranscriptRef.current = true;
confirmDictation();
} else {
void startDictationIfAvailable();
}
return;
}
// Escape: cancel dictation
if (event.nativeEvent.key === "Escape" && isDictating) {
event.preventDefault();
cancelDictation();
return;
}
if (
isRealtimeVoiceForCurrentAgent &&
!metaKey &&
!ctrlKey &&
!shiftKey &&
event.nativeEvent.key === " "
) {
event.preventDefault();
voice?.toggleMute();
return;
}
if (event.nativeEvent.key !== "Enter") return;
// Shift+Enter: add newline (default behavior, don't intercept)
@@ -714,7 +578,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
return (
<View style={styles.container}>
<View style={styles.container} testID="message-input-root">
{/* Regular input */}
<Animated.View style={[styles.inputWrapper, inputAnimatedStyle]}>
{/* Image preview pills */}
@@ -45,7 +45,7 @@ import {
type SidebarSectionData,
} from "@/hooks/use-sidebar-agents-grouped";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getIsTauri } from "@/constants/layout";
import { AgentStatusDot } from "@/components/agent-status-dot";
@@ -370,9 +370,11 @@ export function SidebarAgentList({
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore((s) => s.toggleProjectCollapsed);
const altDown = useKeyboardNavStore((s) => s.altDown);
const cmdOrCtrlDown = useKeyboardNavStore((s) => s.cmdOrCtrlDown);
const sidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.sidebarShortcutAgentKeys);
const altDown = useKeyboardShortcutsStore((s) => s.altDown);
const cmdOrCtrlDown = useKeyboardShortcutsStore((s) => s.cmdOrCtrlDown);
const sidebarShortcutAgentKeys = useKeyboardShortcutsStore(
(s) => s.sidebarShortcutAgentKeys
);
const isTauri = getIsTauri();
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown);
const shortcutIndexByAgentKey = useMemo(() => {
@@ -18,7 +18,7 @@ import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
import { Combobox } from "@/components/ui/combobox";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
@@ -121,7 +121,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
}, [isRevalidating, isManualRefresh]);
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
const setSidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.setSidebarShortcutAgentKeys);
const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore(
(s) => s.setSidebarShortcutAgentKeys
);
const sidebarShortcutAgentKeys = useMemo(() => {
return deriveSidebarShortcutAgentKeys(sections, collapsedProjectKeys, 9);
}, [collapsedProjectKeys, sections]);
+16 -8
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TextInput } from "react-native";
import { router, usePathname } from "expo-router";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import {
@@ -126,9 +126,11 @@ export type CommandCenterItem =
export function useCommandCenter() {
const pathname = usePathname();
const { agents } = useAggregatedAgents();
const open = useKeyboardNavStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen);
const requestFocusChatInput = useKeyboardNavStore((s) => s.requestFocusChatInput);
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
const requestMessageInputAction = useKeyboardShortcutsStore(
(s) => s.requestMessageInputAction
);
const inputRef = useRef<TextInput>(null);
const didNavigateRef = useRef(false);
const prevOpenRef = useRef(open);
@@ -220,13 +222,16 @@ export function useCommandCenter() {
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
requestFocusChatInput(agentKey(agent));
requestMessageInputAction({
agentKey: agentKey(agent),
kind: "focus",
});
// Don't restore focus back to the prior element after we navigate.
clearCommandCenterFocusRestoreElement();
setOpen(false);
navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any);
},
[pathname, requestFocusChatInput, setOpen]
[pathname, requestMessageInputAction, setOpen]
);
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
@@ -267,7 +272,10 @@ export function useCommandCenter() {
isFocused,
onTimeout: () => {
if (agentKeyFromPathname) {
requestFocusChatInput(agentKeyFromPathname);
requestMessageInputAction({
agentKey: agentKeyFromPathname,
kind: "focus",
});
}
},
});
@@ -283,7 +291,7 @@ export function useCommandCenter() {
inputRef.current?.focus();
}, 0);
return () => clearTimeout(id);
}, [agentKeyFromPathname, open, requestFocusChatInput]);
}, [agentKeyFromPathname, open, requestMessageInputAction]);
useEffect(() => {
if (!open) return;
@@ -1,316 +0,0 @@
import { useEffect } from "react";
import { Platform } from "react-native";
import { usePathname, useRouter } from "expo-router";
import { getIsTauri } from "@/constants/layout";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useSessionStore } from "@/stores/session-store";
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import { queryClient } from "@/query/query-client";
import {
buildNewAgentRoute,
resolveNewAgentWorkingDir,
} from "@/utils/new-agent-routing";
import {
buildHostAgentDetailRoute,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
export function useGlobalKeyboardNav({
enabled,
isMobile,
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
}: {
enabled: boolean;
isMobile: boolean;
toggleAgentList: () => void;
selectedAgentId?: string;
toggleFileExplorer?: () => void;
}) {
const router = useRouter();
const pathname = usePathname();
const resetModifiers = useKeyboardNavStore((s) => s.resetModifiers);
useEffect(() => {
if (!enabled) return;
if (Platform.OS !== "web") return;
if (isMobile) return;
const isTauri = getIsTauri();
const shouldHandle = () => {
if (typeof document === "undefined") return false;
if (document.visibilityState !== "visible") return false;
return true;
};
const isEditableTarget = (event: KeyboardEvent): boolean => {
const target = event.target;
if (!(target instanceof Element)) return false;
if ((target as HTMLElement).isContentEditable) return true;
const tag = target.tagName.toLowerCase();
if (tag === "input" || tag === "textarea") return true;
return false;
};
const parseShortcutDigit = (event: KeyboardEvent): number | null => {
const code = event.code ?? "";
if (code.startsWith("Digit")) {
const n = Number(code.slice("Digit".length));
return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null;
}
if (code.startsWith("Numpad")) {
const n = Number(code.slice("Numpad".length));
return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null;
}
const key = event.key ?? "";
if (key >= "1" && key <= "9") {
return Number(key);
}
return null;
};
const navigateToSidebarShortcut = (digit: number) => {
const state = useKeyboardNavStore.getState();
const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null;
if (!targetKey) {
return;
}
const parsed = parseSidebarAgentKey(targetKey);
if (!parsed) {
return;
}
const { serverId, agentId } = parsed;
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
};
const navigateToNewAgent = () => {
let targetServerId = parseServerIdFromPathname(pathname);
let targetWorkingDir: string | null = null;
if (selectedAgentId) {
const separatorIndex = selectedAgentId.indexOf(":");
if (separatorIndex > 0) {
const serverId = selectedAgentId.slice(0, separatorIndex);
const agentId = selectedAgentId.slice(separatorIndex + 1);
targetServerId = serverId;
const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
const cwd = agent?.cwd?.trim();
if (cwd) {
const checkout =
queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(serverId, cwd)
) ?? null;
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
}
}
}
if (!targetServerId) {
const sessionServerIds = Object.keys(useSessionStore.getState().sessions);
targetServerId = sessionServerIds[0] ?? null;
}
if (!targetServerId) {
return;
}
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!shouldHandle()) {
return;
}
const key = event.key ?? "";
const lowerKey = key.toLowerCase();
if (key === "Alt" && !event.shiftKey) {
useKeyboardNavStore.getState().setAltDown(true);
}
if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) {
useKeyboardNavStore.getState().setCmdOrCtrlDown(true);
}
// If shift is pressed while a modifier is held, hide the badges
if (key === "Shift") {
const state = useKeyboardNavStore.getState();
if (state.altDown || state.cmdOrCtrlDown) {
state.resetModifiers();
}
}
const isMod = event.metaKey || event.ctrlKey;
const isKeyN = event.code === "KeyN" || lowerKey === "n";
// Cmd/Ctrl+Alt+N: new agent (web + Tauri)
// Note: intentionally works even when focus is inside an input/textarea.
if (isMod && event.altKey && !event.shiftKey && isKeyN) {
event.preventDefault();
navigateToNewAgent();
return;
}
// Cmd/Ctrl+N: new agent (Tauri only)
// Note: intentionally works even when focus is inside an input/textarea.
if (isTauri && isMod && !event.altKey && !event.shiftKey && isKeyN) {
event.preventDefault();
navigateToNewAgent();
return;
}
// Cmd+B: toggle sidebar
if (
isMod &&
(event.code === "KeyB" || lowerKey === "b")
) {
// The MessageInput already handles Cmd+B inside editable fields. If we also
// handle it globally, it can double-toggle and look like it "doesn't work".
if (isEditableTarget(event)) {
return;
}
event.preventDefault();
toggleAgentList();
return;
}
// Cmd+.: toggle sidebar (VS Code quick-fix muscle memory)
// Note: intentionally works even when focus is inside an input/textarea.
if (
isMod &&
(event.code === "Period" || key === ".")
) {
// Ignore while command center is open.
if (useKeyboardNavStore.getState().commandCenterOpen) {
return;
}
event.preventDefault();
toggleAgentList();
return;
}
// Cmd+E: toggle explorer sidebar (only when an agent is selected)
if (
selectedAgentId &&
toggleFileExplorer &&
isMod &&
(event.code === "KeyE" || lowerKey === "e")
) {
// Same double-toggle issue as Cmd+B when focus is inside a text input.
if (isEditableTarget(event)) {
return;
}
event.preventDefault();
toggleFileExplorer();
return;
}
// Ctrl+`: toggle explorer sidebar (VS Code muscle memory)
// Note: intentionally works even when focus is inside an input/textarea.
if (
selectedAgentId &&
toggleFileExplorer &&
event.ctrlKey &&
!event.metaKey &&
(event.code === "Backquote" || key === "`")
) {
// Ignore while command center is open.
if (useKeyboardNavStore.getState().commandCenterOpen) {
return;
}
event.preventDefault();
toggleFileExplorer();
return;
}
// Cmd+K: command center
if (isMod && lowerKey === "k") {
event.preventDefault();
const s = useKeyboardNavStore.getState();
if (!s.commandCenterOpen) {
const target =
event.target instanceof Element ? (event.target as Element) : null;
const targetEl =
target?.closest?.("textarea, input, [contenteditable='true']") ??
(target instanceof HTMLElement ? target : null);
const active = document.activeElement;
const activeEl = active instanceof HTMLElement ? active : null;
setCommandCenterFocusRestoreElement(
(targetEl as HTMLElement | null) ?? activeEl ?? null
);
}
s.setCommandCenterOpen(!s.commandCenterOpen);
return;
}
// Number switching: ignore while command center is open.
if (useKeyboardNavStore.getState().commandCenterOpen) {
return;
}
const digit = parseShortcutDigit(event);
if (!digit) {
return;
}
// Alt/Option+number: always (web + Tauri)
if (event.altKey) {
event.preventDefault();
navigateToSidebarShortcut(digit);
return;
}
// Cmd/Ctrl+number: Tauri only (avoid browser tab switching)
if (isTauri && isMod) {
event.preventDefault();
navigateToSidebarShortcut(digit);
}
};
const handleKeyUp = (event: KeyboardEvent) => {
const key = event.key ?? "";
if (key === "Alt") {
useKeyboardNavStore.getState().setAltDown(false);
}
if (isTauri && (key === "Meta" || key === "Control")) {
useKeyboardNavStore.getState().setCmdOrCtrlDown(false);
}
};
const handleBlurOrHide = () => {
resetModifiers();
};
// react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("keyup", handleKeyUp, true);
window.addEventListener("blur", handleBlurOrHide);
document.addEventListener("visibilitychange", handleBlurOrHide);
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", handleBlurOrHide);
document.removeEventListener("visibilitychange", handleBlurOrHide);
};
}, [
enabled,
isMobile,
pathname,
resetModifiers,
router,
selectedAgentId,
toggleAgentList,
toggleFileExplorer,
]);
}
@@ -0,0 +1,281 @@
import { useEffect } from "react";
import { Platform } from "react-native";
import { usePathname, useRouter } from "expo-router";
import { getIsTauri } from "@/constants/layout";
import { useSessionStore } from "@/stores/session-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import { queryClient } from "@/query/query-client";
import {
buildNewAgentRoute,
resolveNewAgentWorkingDir,
} from "@/utils/new-agent-routing";
import {
buildHostAgentDetailRoute,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import {
type MessageInputKeyboardActionKind,
type KeyboardShortcutPayload,
} from "@/keyboard/actions";
import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts";
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import { getShortcutOs } from "@/utils/shortcut-platform";
function resolveSelectedOrRouteAgentKey(input: {
selectedAgentId?: string;
pathname: string;
}): string | null {
if (input.selectedAgentId) {
return input.selectedAgentId;
}
const route = parseHostAgentRouteFromPathname(input.pathname);
if (!route) {
return null;
}
return `${route.serverId}:${route.agentId}`;
}
export function useKeyboardShortcuts({
enabled,
isMobile,
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
}: {
enabled: boolean;
isMobile: boolean;
toggleAgentList: () => void;
selectedAgentId?: string;
toggleFileExplorer?: () => void;
}) {
const router = useRouter();
const pathname = usePathname();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
useEffect(() => {
if (!enabled) return;
if (Platform.OS !== "web") return;
if (isMobile) return;
const isTauri = getIsTauri();
const isMac = getShortcutOs() === "mac";
const shouldHandle = () => {
if (typeof document === "undefined") return false;
if (document.visibilityState !== "visible") return false;
return true;
};
const navigateToSidebarShortcut = (digit: number): boolean => {
const state = useKeyboardShortcutsStore.getState();
const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null;
if (!targetKey) {
return false;
}
const parsed = parseSidebarAgentKey(targetKey);
if (!parsed) {
return false;
}
const { serverId, agentId } = parsed;
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
return true;
};
const navigateToNewAgent = (): boolean => {
let targetServerId = parseServerIdFromPathname(pathname);
let targetWorkingDir: string | null = null;
if (selectedAgentId) {
const separatorIndex = selectedAgentId.indexOf(":");
if (separatorIndex > 0) {
const serverId = selectedAgentId.slice(0, separatorIndex);
const agentId = selectedAgentId.slice(separatorIndex + 1);
targetServerId = serverId;
const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
const cwd = agent?.cwd?.trim();
if (cwd) {
const checkout =
queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(serverId, cwd)
) ?? null;
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
}
}
}
if (!targetServerId) {
const sessionServerIds = Object.keys(useSessionStore.getState().sessions);
targetServerId = sessionServerIds[0] ?? null;
}
if (!targetServerId) {
return false;
}
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
return true;
};
const requestMessageInputAction = (
kind: MessageInputKeyboardActionKind
): boolean => {
const agentKey = resolveSelectedOrRouteAgentKey({ selectedAgentId, pathname });
if (!agentKey) {
return false;
}
useKeyboardShortcutsStore.getState().requestMessageInputAction({
agentKey,
kind,
});
return true;
};
const handleAction = (input: {
action: string;
payload: KeyboardShortcutPayload;
event: KeyboardEvent;
}): boolean => {
switch (input.action) {
case "agent.new":
return navigateToNewAgent();
case "sidebar.toggle.left":
toggleAgentList();
return true;
case "sidebar.toggle.right":
if (!selectedAgentId || !toggleFileExplorer) {
return false;
}
toggleFileExplorer();
return true;
case "sidebar.navigate.shortcut":
if (!input.payload || typeof input.payload !== "object" || !("digit" in input.payload)) {
return false;
}
return navigateToSidebarShortcut(input.payload.digit);
case "command-center.toggle": {
const store = useKeyboardShortcutsStore.getState();
if (!store.commandCenterOpen) {
const target =
input.event.target instanceof Element ? (input.event.target as Element) : null;
const targetEl =
target?.closest?.("textarea, input, [contenteditable='true']") ??
(target instanceof HTMLElement ? target : null);
const active = document.activeElement;
const activeEl = active instanceof HTMLElement ? active : null;
setCommandCenterFocusRestoreElement(
(targetEl as HTMLElement | null) ?? activeEl ?? null
);
}
store.setCommandCenterOpen(!store.commandCenterOpen);
return true;
}
case "message-input.action":
if (!input.payload || typeof input.payload !== "object" || !("kind" in input.payload)) {
return false;
}
return requestMessageInputAction(input.payload.kind);
default:
return false;
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!shouldHandle()) {
return;
}
const key = event.key ?? "";
if (key === "Alt" && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setAltDown(true);
}
if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(true);
}
if (key === "Shift") {
const state = useKeyboardShortcutsStore.getState();
if (state.altDown || state.cmdOrCtrlDown) {
state.resetModifiers();
}
}
const store = useKeyboardShortcutsStore.getState();
const focusScope = resolveKeyboardFocusScope({
target: event.target,
commandCenterOpen: store.commandCenterOpen,
});
const match = resolveKeyboardShortcut({
event,
context: {
isMac,
isTauri,
focusScope,
commandCenterOpen: store.commandCenterOpen,
hasSelectedAgent: Boolean(selectedAgentId && toggleFileExplorer),
},
});
if (!match) {
return;
}
const handled = handleAction({
action: match.action,
payload: match.payload,
event,
});
if (!handled) {
return;
}
if (match.preventDefault) {
event.preventDefault();
}
if (match.stopPropagation) {
event.stopPropagation();
}
};
const handleKeyUp = (event: KeyboardEvent) => {
const key = event.key ?? "";
if (key === "Alt") {
useKeyboardShortcutsStore.getState().setAltDown(false);
}
if (isTauri && (key === "Meta" || key === "Control")) {
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(false);
}
};
const handleBlurOrHide = () => {
resetModifiers();
};
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("keyup", handleKeyUp, true);
window.addEventListener("blur", handleBlurOrHide);
document.addEventListener("visibilitychange", handleBlurOrHide);
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", handleBlurOrHide);
document.removeEventListener("visibilitychange", handleBlurOrHide);
};
}, [
enabled,
isMobile,
pathname,
resetModifiers,
router,
selectedAgentId,
toggleAgentList,
toggleFileExplorer,
]);
}
+26
View File
@@ -0,0 +1,26 @@
export type KeyboardFocusScope =
| "terminal"
| "message-input"
| "command-center"
| "editable"
| "other";
export type MessageInputKeyboardActionKind =
| "focus"
| "dictation-toggle"
| "dictation-cancel"
| "voice-toggle"
| "voice-mute-toggle";
export type KeyboardActionId =
| "agent.new"
| "sidebar.toggle.left"
| "sidebar.toggle.right"
| "sidebar.navigate.shortcut"
| "command-center.toggle"
| "message-input.action";
export type KeyboardShortcutPayload =
| { digit: number }
| { kind: MessageInputKeyboardActionKind }
| null;
+42
View File
@@ -0,0 +1,42 @@
import type { KeyboardFocusScope } from "@/keyboard/actions";
export function resolveKeyboardFocusScope(input: {
target: EventTarget | null;
commandCenterOpen: boolean;
}): KeyboardFocusScope {
const { target, commandCenterOpen } = input;
if (!(target instanceof Element)) {
return commandCenterOpen ? "command-center" : "other";
}
if (
target.closest("[data-testid='terminal-surface']") ||
target.closest(".xterm")
) {
return "terminal";
}
if (
commandCenterOpen &&
(target.closest("[data-testid='command-center-panel']") ||
target.closest("[data-testid='command-center-input']"))
) {
return "command-center";
}
if (target.closest("[data-testid='message-input-root']")) {
return "message-input";
}
const editable = target as HTMLElement;
if (editable.isContentEditable) {
return commandCenterOpen ? "command-center" : "editable";
}
const tag = target.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") {
return commandCenterOpen ? "command-center" : "editable";
}
return commandCenterOpen ? "command-center" : "other";
}
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import {
resolveKeyboardShortcut,
type KeyboardShortcutContext,
} from "./keyboard-shortcuts";
function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
return {
key: "",
code: "",
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
repeat: false,
...overrides,
} as KeyboardEvent;
}
function shortcutContext(
overrides: Partial<KeyboardShortcutContext> = {}
): KeyboardShortcutContext {
return {
isMac: false,
isTauri: false,
focusScope: "other",
commandCenterOpen: false,
hasSelectedAgent: true,
...overrides,
};
}
describe("keyboard-shortcuts", () => {
it("matches Cmd+B sidebar toggle on macOS", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "b",
code: "KeyB",
metaKey: true,
}),
context: shortcutContext({ isMac: true }),
});
expect(match?.action).toBe("sidebar.toggle.left");
});
it("does not bind Ctrl+B on non-mac", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "b",
code: "KeyB",
ctrlKey: true,
}),
context: shortcutContext({ isMac: false }),
});
expect(match).toBeNull();
});
it("keeps Mod+. as sidebar toggle fallback", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: ".",
code: "Period",
ctrlKey: true,
}),
context: shortcutContext({ isMac: false }),
});
expect(match?.action).toBe("sidebar.toggle.left");
});
it("routes Mod+D to message-input action outside terminal", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "d",
code: "KeyD",
metaKey: true,
}),
context: shortcutContext({ isMac: true, focusScope: "message-input" }),
});
expect(match?.action).toBe("message-input.action");
expect(match?.payload).toEqual({ kind: "dictation-toggle" });
});
it("does not route message-input actions when terminal is focused", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "d",
code: "KeyD",
metaKey: true,
}),
context: shortcutContext({ isMac: true, focusScope: "terminal" }),
});
expect(match).toBeNull();
});
it("parses Alt+digit sidebar shortcut payload", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "2",
code: "Digit2",
altKey: true,
}),
context: shortcutContext(),
});
expect(match?.action).toBe("sidebar.navigate.shortcut");
expect(match?.payload).toEqual({ digit: 2 });
});
});
@@ -0,0 +1,227 @@
import type {
KeyboardActionId,
KeyboardFocusScope,
KeyboardShortcutPayload,
MessageInputKeyboardActionKind,
} from "@/keyboard/actions";
export type KeyboardShortcutContext = {
isMac: boolean;
isTauri: boolean;
focusScope: KeyboardFocusScope;
commandCenterOpen: boolean;
hasSelectedAgent: boolean;
};
export type KeyboardShortcutMatch = {
action: KeyboardActionId;
payload: KeyboardShortcutPayload;
preventDefault: boolean;
stopPropagation: boolean;
};
type KeyboardShortcutBinding = {
id: string;
action: KeyboardActionId;
matches: (event: KeyboardEvent) => boolean;
when: (context: KeyboardShortcutContext) => boolean;
payload?: (event: KeyboardEvent) => KeyboardShortcutPayload;
preventDefault?: boolean;
stopPropagation?: boolean;
};
function isMod(event: KeyboardEvent): boolean {
return event.metaKey || event.ctrlKey;
}
function parseDigit(event: KeyboardEvent): number | null {
const code = event.code ?? "";
if (code.startsWith("Digit")) {
const value = Number(code.slice("Digit".length));
return Number.isFinite(value) && value >= 1 && value <= 9 ? value : null;
}
if (code.startsWith("Numpad")) {
const value = Number(code.slice("Numpad".length));
return Number.isFinite(value) && value >= 1 && value <= 9 ? value : null;
}
const key = event.key ?? "";
if (key >= "1" && key <= "9") {
return Number(key);
}
return null;
}
function hasDigit(event: KeyboardEvent): boolean {
return parseDigit(event) !== null;
}
function withMessageInputAction(
kind: MessageInputKeyboardActionKind
): (event: KeyboardEvent) => KeyboardShortcutPayload {
return () => ({ kind });
}
const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
{
id: "agent-new-mod-alt-n",
action: "agent.new",
matches: (event) =>
isMod(event) &&
event.altKey &&
!event.shiftKey &&
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
when: () => true,
},
{
id: "agent-new-tauri-mod-n",
action: "agent.new",
matches: (event) =>
isMod(event) &&
!event.altKey &&
!event.shiftKey &&
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
when: (context) => context.isTauri,
},
{
id: "command-center-toggle",
action: "command-center.toggle",
matches: (event) =>
isMod(event) &&
!event.altKey &&
!event.shiftKey &&
(event.code === "KeyK" || event.key.toLowerCase() === "k"),
when: () => true,
},
{
id: "sidebar-toggle-left-mac-cmd-b",
action: "sidebar.toggle.left",
matches: (event) =>
event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
(event.code === "KeyB" || event.key.toLowerCase() === "b"),
when: (context) => context.isMac,
},
{
id: "sidebar-toggle-left-mod-period",
action: "sidebar.toggle.left",
matches: (event) =>
isMod(event) &&
!event.altKey &&
!event.shiftKey &&
(event.code === "Period" || event.key === "."),
when: (context) => !context.commandCenterOpen,
},
{
id: "sidebar-toggle-right-mod-e",
action: "sidebar.toggle.right",
matches: (event) =>
isMod(event) &&
!event.altKey &&
!event.shiftKey &&
(event.code === "KeyE" || event.key.toLowerCase() === "e"),
when: (context) => context.hasSelectedAgent && !context.commandCenterOpen,
},
{
id: "sidebar-toggle-right-ctrl-backquote",
action: "sidebar.toggle.right",
matches: (event) =>
event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey &&
(event.code === "Backquote" || event.key === "`"),
when: (context) => context.hasSelectedAgent && !context.commandCenterOpen,
},
{
id: "message-input-voice-toggle",
action: "message-input.action",
matches: (event) =>
isMod(event) &&
event.shiftKey &&
!event.altKey &&
(event.code === "KeyD" || event.key.toLowerCase() === "d") &&
!event.repeat,
payload: withMessageInputAction("voice-toggle"),
when: (context) =>
!context.commandCenterOpen && context.focusScope !== "terminal",
},
{
id: "message-input-dictation-toggle",
action: "message-input.action",
matches: (event) =>
isMod(event) &&
!event.shiftKey &&
!event.altKey &&
(event.code === "KeyD" || event.key.toLowerCase() === "d"),
payload: withMessageInputAction("dictation-toggle"),
when: (context) =>
!context.commandCenterOpen && context.focusScope !== "terminal",
},
{
id: "message-input-dictation-cancel",
action: "message-input.action",
matches: (event) => event.key === "Escape",
payload: withMessageInputAction("dictation-cancel"),
when: (context) =>
!context.commandCenterOpen && context.focusScope !== "terminal",
},
{
id: "message-input-voice-mute-toggle",
action: "message-input.action",
matches: (event) =>
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
(event.code === "Space" || event.key === " ") &&
!event.repeat,
payload: withMessageInputAction("voice-mute-toggle"),
when: (context) =>
!context.commandCenterOpen && context.focusScope !== "terminal",
},
{
id: "sidebar-shortcut-alt-digit",
action: "sidebar.navigate.shortcut",
matches: (event) => event.altKey && hasDigit(event),
payload: (event) => {
const digit = parseDigit(event);
return digit ? { digit } : null;
},
when: (context) => !context.commandCenterOpen,
},
{
id: "sidebar-shortcut-tauri-mod-digit",
action: "sidebar.navigate.shortcut",
matches: (event) => isMod(event) && hasDigit(event),
payload: (event) => {
const digit = parseDigit(event);
return digit ? { digit } : null;
},
when: (context) => context.isTauri && !context.commandCenterOpen,
},
];
export function resolveKeyboardShortcut(input: {
event: KeyboardEvent;
context: KeyboardShortcutContext;
}): KeyboardShortcutMatch | null {
const { event, context } = input;
for (const binding of SHORTCUT_BINDINGS) {
if (!binding.matches(event)) {
continue;
}
if (!binding.when(context)) {
continue;
}
const payload = binding.payload?.(event) ?? null;
return {
action: binding.action,
payload,
preventDefault: binding.preventDefault ?? true,
stopPropagation: binding.stopPropagation ?? true,
};
}
return null;
}
@@ -1,47 +0,0 @@
import { create } from "zustand";
type FocusChatInputRequest = {
id: number;
agentKey: string;
};
interface KeyboardNavState {
commandCenterOpen: boolean;
altDown: boolean;
cmdOrCtrlDown: boolean;
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
sidebarShortcutAgentKeys: string[];
/** Web-only request to focus the MessageInput for the selected agent. */
focusChatInputRequest: FocusChatInputRequest | null;
requestFocusChatInput: (agentKey: string) => void;
clearFocusChatInputRequest: () => void;
setCommandCenterOpen: (open: boolean) => void;
setAltDown: (down: boolean) => void;
setCmdOrCtrlDown: (down: boolean) => void;
setSidebarShortcutAgentKeys: (keys: string[]) => void;
resetModifiers: () => void;
}
export const useKeyboardNavStore = create<KeyboardNavState>((set, get) => ({
commandCenterOpen: false,
altDown: false,
cmdOrCtrlDown: false,
sidebarShortcutAgentKeys: [],
focusChatInputRequest: null,
requestFocusChatInput: (agentKey) => {
const prev = get().focusChatInputRequest;
const id = (prev?.id ?? 0) + 1;
set({ focusChatInputRequest: { id, agentKey } });
},
clearFocusChatInputRequest: () => set({ focusChatInputRequest: null }),
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setAltDown: (down) => set({ altDown: down }),
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
}));
@@ -0,0 +1,58 @@
import { create } from "zustand";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
export type MessageInputActionRequest = {
id: number;
agentKey: string;
kind: MessageInputKeyboardActionKind;
};
interface KeyboardShortcutsState {
commandCenterOpen: boolean;
altDown: boolean;
cmdOrCtrlDown: boolean;
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
sidebarShortcutAgentKeys: string[];
messageInputActionRequest: MessageInputActionRequest | null;
setCommandCenterOpen: (open: boolean) => void;
setAltDown: (down: boolean) => void;
setCmdOrCtrlDown: (down: boolean) => void;
setSidebarShortcutAgentKeys: (keys: string[]) => void;
resetModifiers: () => void;
requestMessageInputAction: (input: {
agentKey: string;
kind: MessageInputKeyboardActionKind;
}) => void;
clearMessageInputActionRequest: (id: number) => void;
}
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
(set, get) => ({
commandCenterOpen: false,
altDown: false,
cmdOrCtrlDown: false,
sidebarShortcutAgentKeys: [],
messageInputActionRequest: null,
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setAltDown: (down) => set({ altDown: down }),
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
requestMessageInputAction: ({ agentKey, kind }) => {
const previous = get().messageInputActionRequest;
const id = (previous?.id ?? 0) + 1;
set({ messageInputActionRequest: { id, agentKey, kind } });
},
clearMessageInputActionRequest: (id) => {
const current = get().messageInputActionRequest;
if (!current || current.id !== id) {
return;
}
set({ messageInputActionRequest: null });
},
})
);