refactor(app): centralize keyboard actions and shortcuts
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user