refactor(app): centralize keyboard actions and shortcuts
This commit is contained in:
@@ -14,7 +14,7 @@ test('sidebar toggle shows tooltip on the right', async ({ page }) => {
|
|||||||
const tooltip = page.getByTestId('menu-button-tooltip');
|
const tooltip = page.getByTestId('menu-button-tooltip');
|
||||||
await expect(tooltip).toBeVisible();
|
await expect(tooltip).toBeVisible();
|
||||||
await expect(tooltip).toContainText('Toggle sidebar');
|
await expect(tooltip).toContainText('Toggle sidebar');
|
||||||
await expect(tooltip).toContainText(/⌘B|Ctrl\+B/);
|
await expect(tooltip).toContainText(/⌘B|Ctrl\+\./);
|
||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
await expect(tooltip).toBeVisible();
|
await expect(tooltip).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
async function selectNewestTerminalTab(page: Page): Promise<void> {
|
||||||
const tabs = page.locator('[data-testid^="terminal-tab-"]');
|
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> {
|
async function getTerminalRows(page: Page): Promise<number> {
|
||||||
return await page.evaluate(() => {
|
return await page.evaluate(() => {
|
||||||
const terminal = (window as { __paseoTerminal?: { rows?: unknown } }).__paseoTerminal;
|
const terminal = (window as { __paseoTerminal?: { rows?: unknown } }).__paseoTerminal;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
import { getIsTauri, getIsTauriMac } from "@/constants/layout";
|
import { getIsTauri, getIsTauriMac } from "@/constants/layout";
|
||||||
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
||||||
import { CommandCenter } from "@/components/command-center";
|
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 { queryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
WEB_NOTIFICATION_CLICK_EVENT,
|
WEB_NOTIFICATION_CLICK_EVENT,
|
||||||
@@ -147,7 +147,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
|||||||
const openGestureEnabled =
|
const openGestureEnabled =
|
||||||
chromeEnabled && isMobile && mobileView === "agent";
|
chromeEnabled && isMobile && mobileView === "agent";
|
||||||
|
|
||||||
useGlobalKeyboardNav({
|
useKeyboardShortcuts({
|
||||||
enabled: chromeEnabled,
|
enabled: chromeEnabled,
|
||||||
isMobile,
|
isMobile,
|
||||||
toggleAgentList,
|
toggleAgentList,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { Theme } from "@/styles/theme";
|
|||||||
import { CommandAutocomplete } from "./command-autocomplete";
|
import { CommandAutocomplete } from "./command-autocomplete";
|
||||||
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
|
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
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 { focusWithRetries } from "@/utils/web-focus";
|
||||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
@@ -74,8 +74,12 @@ export function AgentInputArea({
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||||
const isScreenFocused = useIsFocused();
|
const isScreenFocused = useIsFocused();
|
||||||
const focusChatInputRequest = useKeyboardNavStore((s) => s.focusChatInputRequest);
|
const messageInputActionRequest = useKeyboardShortcutsStore(
|
||||||
const clearFocusChatInputRequest = useKeyboardNavStore((s) => s.clearFocusChatInputRequest);
|
(s) => s.messageInputActionRequest
|
||||||
|
);
|
||||||
|
const clearMessageInputActionRequest = useKeyboardShortcutsStore(
|
||||||
|
(s) => s.clearMessageInputActionRequest
|
||||||
|
);
|
||||||
|
|
||||||
const client = useSessionStore(
|
const client = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.client ?? null
|
(state) => state.sessions[serverId]?.client ?? null
|
||||||
@@ -108,7 +112,7 @@ export function AgentInputArea({
|
|||||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
|
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
|
||||||
const lastHandledFocusRequestIdRef = useRef<number | null>(null);
|
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// Command autocomplete logic
|
// Command autocomplete logic
|
||||||
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
||||||
@@ -400,21 +404,36 @@ export function AgentInputArea({
|
|||||||
saveDraftInput(agentId, { text: userInput, images: selectedImages });
|
saveDraftInput(agentId, { text: userInput, images: selectedImages });
|
||||||
}, [agentId, userInput, selectedImages, getDraftInput, saveDraftInput]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web") return;
|
|
||||||
if (!isScreenFocused) return;
|
if (!isScreenFocused) return;
|
||||||
if (!focusChatInputRequest) return;
|
if (!messageInputActionRequest) return;
|
||||||
|
|
||||||
const currentKey = `${serverId}:${agentId}`;
|
const currentKey = `${serverId}:${agentId}`;
|
||||||
if (focusChatInputRequest.agentKey !== currentKey) {
|
if (messageInputActionRequest.agentKey !== currentKey) {
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
lastHandledFocusRequestIdRef.current = focusChatInputRequest.id;
|
|
||||||
|
|
||||||
return focusWithRetries({
|
return focusWithRetries({
|
||||||
focus: () => messageInputRef.current?.focus(),
|
focus: () => messageInputRef.current?.focus(),
|
||||||
@@ -424,14 +443,16 @@ export function AgentInputArea({
|
|||||||
typeof document !== "undefined" ? document.activeElement : null;
|
typeof document !== "undefined" ? document.activeElement : null;
|
||||||
return Boolean(el) && active === el;
|
return Boolean(el) && active === el;
|
||||||
},
|
},
|
||||||
onSuccess: () => clearFocusChatInputRequest(),
|
onSuccess: () =>
|
||||||
onTimeout: () => clearFocusChatInputRequest(),
|
clearMessageInputActionRequest(messageInputActionRequest.id),
|
||||||
|
onTimeout: () =>
|
||||||
|
clearMessageInputActionRequest(messageInputActionRequest.id),
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
agentId,
|
agentId,
|
||||||
clearFocusChatInputRequest,
|
clearMessageInputActionRequest,
|
||||||
focusChatInputRequest,
|
|
||||||
isScreenFocused,
|
isScreenFocused,
|
||||||
|
messageInputActionRequest,
|
||||||
serverId,
|
serverId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -48,9 +48,13 @@ export function CommandCenter() {
|
|||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
<Pressable style={styles.backdrop} onPress={handleClose} />
|
<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 }]}>
|
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
testID="command-center-input"
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={query}
|
value={query}
|
||||||
onChangeText={setQuery}
|
onChangeText={setQuery}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Menu, PanelLeft } from "lucide-react-native";
|
|||||||
import { ScreenHeader } from "./screen-header";
|
import { ScreenHeader } from "./screen-header";
|
||||||
import { HeaderToggleButton } from "./header-toggle-button";
|
import { HeaderToggleButton } from "./header-toggle-button";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
|
|
||||||
interface MenuHeaderProps {
|
interface MenuHeaderProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -18,6 +19,7 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
|||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||||
|
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
||||||
|
|
||||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||||
const MenuIcon = isMobile ? Menu : PanelLeft;
|
const MenuIcon = isMobile ? Menu : PanelLeft;
|
||||||
@@ -32,7 +34,7 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
|||||||
<HeaderToggleButton
|
<HeaderToggleButton
|
||||||
onPress={toggleAgentList}
|
onPress={toggleAgentList}
|
||||||
tooltipLabel="Toggle sidebar"
|
tooltipLabel="Toggle sidebar"
|
||||||
tooltipKeys={["mod", "B"]}
|
tooltipKeys={toggleShortcutKeys}
|
||||||
tooltipSide="right"
|
tooltipSide="right"
|
||||||
testID="menu-button"
|
testID="menu-button"
|
||||||
nativeID="menu-button"
|
nativeID="menu-button"
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ import { useDictation } from "@/hooks/use-dictation";
|
|||||||
import { DictationOverlay } from "./dictation-controls";
|
import { DictationOverlay } from "./dictation-controls";
|
||||||
import { RealtimeVoiceOverlay } from "./realtime-voice-overlay";
|
import { RealtimeVoiceOverlay } from "./realtime-voice-overlay";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
|
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||||
|
|
||||||
export interface ImageAttachment {
|
export interface ImageAttachment {
|
||||||
uri: string;
|
uri: string;
|
||||||
@@ -82,6 +82,7 @@ export interface MessageInputProps {
|
|||||||
export interface MessageInputRef {
|
export interface MessageInputRef {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
blur: () => void;
|
blur: () => void;
|
||||||
|
runKeyboardAction: (action: MessageInputKeyboardActionKind) => void;
|
||||||
/**
|
/**
|
||||||
* Web-only: return the underlying DOM element for focus assertions/retries.
|
* Web-only: return the underlying DOM element for focus assertions/retries.
|
||||||
* May return null if not mounted or on native.
|
* May return null if not mounted or on native.
|
||||||
@@ -138,8 +139,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const voice = useVoiceOptional();
|
const voice = useVoiceOptional();
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
|
||||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
|
||||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||||
const textInputRef = useRef<
|
const textInputRef = useRef<
|
||||||
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
||||||
@@ -153,6 +152,40 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
blur: () => {
|
blur: () => {
|
||||||
textInputRef.current?.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: () => {
|
getNativeElement: () => {
|
||||||
if (!IS_WEB) return null;
|
if (!IS_WEB) return null;
|
||||||
const current = textInputRef.current as
|
const current = textInputRef.current as
|
||||||
@@ -295,121 +328,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
await startDictation();
|
await startDictation();
|
||||||
}, [dictationUnavailableMessage, startDictation, toast]);
|
}, [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
|
// Animate overlay
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
overlayTransition.value = withTiming(showOverlay ? 1 : 0, {
|
overlayTransition.value = withTiming(showOverlay ? 1 : 0, {
|
||||||
@@ -636,60 +554,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
|
|
||||||
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
|
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;
|
if (event.nativeEvent.key !== "Enter") return;
|
||||||
|
|
||||||
// Shift+Enter: add newline (default behavior, don't intercept)
|
// Shift+Enter: add newline (default behavior, don't intercept)
|
||||||
@@ -714,7 +578,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container} testID="message-input-root">
|
||||||
{/* Regular input */}
|
{/* Regular input */}
|
||||||
<Animated.View style={[styles.inputWrapper, inputAnimatedStyle]}>
|
<Animated.View style={[styles.inputWrapper, inputAnimatedStyle]}>
|
||||||
{/* Image preview pills */}
|
{/* Image preview pills */}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
type SidebarSectionData,
|
type SidebarSectionData,
|
||||||
} from "@/hooks/use-sidebar-agents-grouped";
|
} from "@/hooks/use-sidebar-agents-grouped";
|
||||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
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 { getIsTauri } from "@/constants/layout";
|
||||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||||
|
|
||||||
@@ -370,9 +370,11 @@ export function SidebarAgentList({
|
|||||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
|
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
|
||||||
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore((s) => s.toggleProjectCollapsed);
|
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore((s) => s.toggleProjectCollapsed);
|
||||||
|
|
||||||
const altDown = useKeyboardNavStore((s) => s.altDown);
|
const altDown = useKeyboardShortcutsStore((s) => s.altDown);
|
||||||
const cmdOrCtrlDown = useKeyboardNavStore((s) => s.cmdOrCtrlDown);
|
const cmdOrCtrlDown = useKeyboardShortcutsStore((s) => s.cmdOrCtrlDown);
|
||||||
const sidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.sidebarShortcutAgentKeys);
|
const sidebarShortcutAgentKeys = useKeyboardShortcutsStore(
|
||||||
|
(s) => s.sidebarShortcutAgentKeys
|
||||||
|
);
|
||||||
const isTauri = getIsTauri();
|
const isTauri = getIsTauri();
|
||||||
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown);
|
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown);
|
||||||
const shortcutIndexByAgentKey = useMemo(() => {
|
const shortcutIndexByAgentKey = useMemo(() => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
|
|||||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
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 { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
|
||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
@@ -121,7 +121,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
}, [isRevalidating, isManualRefresh]);
|
}, [isRevalidating, isManualRefresh]);
|
||||||
|
|
||||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
|
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
|
||||||
const setSidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.setSidebarShortcutAgentKeys);
|
const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore(
|
||||||
|
(s) => s.setSidebarShortcutAgentKeys
|
||||||
|
);
|
||||||
const sidebarShortcutAgentKeys = useMemo(() => {
|
const sidebarShortcutAgentKeys = useMemo(() => {
|
||||||
return deriveSidebarShortcutAgentKeys(sections, collapsedProjectKeys, 9);
|
return deriveSidebarShortcutAgentKeys(sections, collapsedProjectKeys, 9);
|
||||||
}, [collapsedProjectKeys, sections]);
|
}, [collapsedProjectKeys, sections]);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { TextInput } from "react-native";
|
import type { TextInput } from "react-native";
|
||||||
import { router, usePathname } from "expo-router";
|
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 { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
@@ -126,9 +126,11 @@ export type CommandCenterItem =
|
|||||||
export function useCommandCenter() {
|
export function useCommandCenter() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { agents } = useAggregatedAgents();
|
const { agents } = useAggregatedAgents();
|
||||||
const open = useKeyboardNavStore((s) => s.commandCenterOpen);
|
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
||||||
const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen);
|
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
||||||
const requestFocusChatInput = useKeyboardNavStore((s) => s.requestFocusChatInput);
|
const requestMessageInputAction = useKeyboardShortcutsStore(
|
||||||
|
(s) => s.requestMessageInputAction
|
||||||
|
);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const didNavigateRef = useRef(false);
|
const didNavigateRef = useRef(false);
|
||||||
const prevOpenRef = useRef(open);
|
const prevOpenRef = useRef(open);
|
||||||
@@ -220,13 +222,16 @@ export function useCommandCenter() {
|
|||||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||||
const navigate = shouldReplace ? router.replace : router.push;
|
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.
|
// Don't restore focus back to the prior element after we navigate.
|
||||||
clearCommandCenterFocusRestoreElement();
|
clearCommandCenterFocusRestoreElement();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any);
|
navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any);
|
||||||
},
|
},
|
||||||
[pathname, requestFocusChatInput, setOpen]
|
[pathname, requestMessageInputAction, setOpen]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
|
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
|
||||||
@@ -267,7 +272,10 @@ export function useCommandCenter() {
|
|||||||
isFocused,
|
isFocused,
|
||||||
onTimeout: () => {
|
onTimeout: () => {
|
||||||
if (agentKeyFromPathname) {
|
if (agentKeyFromPathname) {
|
||||||
requestFocusChatInput(agentKeyFromPathname);
|
requestMessageInputAction({
|
||||||
|
agentKey: agentKeyFromPathname,
|
||||||
|
kind: "focus",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -283,7 +291,7 @@ export function useCommandCenter() {
|
|||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
}, [agentKeyFromPathname, open, requestFocusChatInput]);
|
}, [agentKeyFromPathname, open, requestMessageInputAction]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user