mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
* feat(keyboard): support hardware keyboards on iPad Register app shortcuts as iOS UIKeyCommands so a paired hardware keyboard drives the same bindings as web and desktop. Overlays (menus, comboboxes, modals, command center) get first refusal on bare keys, and a focused terminal keeps the keys it needs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(keyboard): record iOS hardware key observation spike With a UITextView focused on iPad Pro M4/iOS 18.6, window and controller observers received Cmd+B, Cmd+P and Escape. Escape also invoked UIKeyCommand when registered, so the stream must be the sole emitter. Use observation for shortcuts and commands only for text-control ownership. Simulator intercepted Cmd+K as HID184; input capture blocked synthetic typing entirely. Keep capture off and verify the toggle via a rebind during simulator QA. * refactor(keyboard): give overlays one key registration API Overlay ownership now follows one layer-ordered registry on every runtime. Web retains focus trapping and restoration through its adapter; native registers the same key handler without a DOM scope. Picker navigation and dismissal no longer require a parallel native stack. Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com> * feat(keyboard): route native hardware keys through the shared resolver Observe each physical press once, including native modal windows, and derive focus from declared composer and terminal surfaces. Native bindings and rebinds now use the same resolver and overlay precedence as browser and Electron input. Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com> * feat(keyboard): show shortcuts when hardware keyboards are present Keep routing independent of compact layout and detect hardware capability for hints and settings. Rebind capture uses the shared overlay interface on native too. Keep the filtered sidebar model available for keyboard navigation while its panel is hidden. Co-authored-by: Les Stroud <public@lesstroud.com> Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com> * test(keyboard): allow shortcut hints in compact menu assertions Compact menus now display hardware keyboard hints. Assert action labels, order and uniqueness without treating a trailing shortcut badge as part of the action label. * fix(keyboard): retain an overlay's opening layer in state Capture the registry's current top when an overlay opens and retain that layer through rerenders. React Compiler removed the previous useMemo cache after the registry getter moved across modules, allowing a parent modal to rise above its child and start an infinite focus loop. Compile this hook in Vitest as Expo does. The new hook regression failed first with layer 220 instead of 20, then passed with explicit opening state. Focused unit and browser tests pass, and the previously failing onboarding E2E completes through the real Expo compiler. Typecheck, lint and format pass. * refactor(keyboard): let overlays own native hardware input Remove the TextInput key handlers that duplicate declared overlay arrows and Escape in Add Project and Command Center. Remove Add Project's hidden responder now that native window sources observe keys without it. Keep soft-keyboard submit editing behavior. * fix(keyboard): close Add Project outside its state updater Root Escape previously called the parent close action from a React state updater, producing a cross-component render warning when navigation was still queued. Compute the previous page in the event handler and close outside the updater. The existing Escape/Back E2E now batches navigation and dismissal through the DOM key source. It failed first with the AddProjectFlowHost render warning, then passed after the correction. Typecheck, lint and format pass; native Escape checks pass on Android and iPad. * fix(keyboard): preserve Android dead-key composition * fix(keyboard): recognize focused native text inputs * test(keyboard): replace JSDOM coverage with browser tests * refactor(menu): spell out arrow navigation branches * fix(composer): claim fullscreen Escape on native * refactor(composer): one fullscreen Escape module for every platform The shared registration lived in fullscreen-exit.shared.ts with two one-line re-exports (fullscreen-exit.ts and fullscreen-exit.native.ts). Metro needs no native override now that the Android back button is its own adapter, so the implementation moves into fullscreen-exit.ts and the browser test imports the module every platform resolves. --------- Co-authored-by: Ubuntu <jeroenfrenken@icloud.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>
1774 lines
52 KiB
TypeScript
1774 lines
52 KiB
TypeScript
import type { ShortcutKey } from "@/utils/format-shortcut";
|
|
import type {
|
|
KeyboardActionId,
|
|
KeyboardFocusScope,
|
|
KeyboardShortcutPayload,
|
|
MessageInputKeyboardActionKind,
|
|
} from "@/keyboard/actions";
|
|
import {
|
|
chordStringToShortcutKeys,
|
|
isModifierKeyCode,
|
|
type KeyCombo,
|
|
parseChordString,
|
|
} from "@/keyboard/shortcut-string";
|
|
|
|
export type { KeyCombo } from "@/keyboard/shortcut-string";
|
|
|
|
// --- Public types ---
|
|
|
|
export interface KeyboardShortcutContext {
|
|
isMac: boolean;
|
|
usesDesktopBindings: boolean;
|
|
focusScope: KeyboardFocusScope;
|
|
commandCenterOpen: boolean;
|
|
}
|
|
|
|
export interface KeyboardShortcutInput {
|
|
key: string;
|
|
code: string;
|
|
altKey: boolean;
|
|
ctrlKey: boolean;
|
|
metaKey: boolean;
|
|
shiftKey: boolean;
|
|
repeat: boolean;
|
|
}
|
|
|
|
export interface KeyboardShortcutMatch {
|
|
action: KeyboardActionId;
|
|
payload: KeyboardShortcutPayload;
|
|
preventDefault: boolean;
|
|
stopPropagation: boolean;
|
|
}
|
|
|
|
export interface KeyboardShortcutHelpRow {
|
|
id: string;
|
|
label: string;
|
|
labelKey: string;
|
|
/** The keys that actually fire this action, or `null` when it has none. */
|
|
chord: ShortcutKey[][] | null;
|
|
note?: string;
|
|
noteKey?: string;
|
|
}
|
|
|
|
export type ShortcutSectionId = "general" | "workspaces" | "tabs-panes" | "layout" | "agent-input";
|
|
|
|
export interface KeyboardShortcutHelpSection {
|
|
id: ShortcutSectionId;
|
|
title: string;
|
|
titleKey: string;
|
|
rows: KeyboardShortcutHelpRow[];
|
|
}
|
|
|
|
// --- Binding definition types ---
|
|
|
|
interface KeyboardShortcutPlatformContext {
|
|
isMac: boolean;
|
|
usesDesktopBindings: boolean;
|
|
}
|
|
|
|
interface ShortcutWhen {
|
|
/** true = mac only, false = non-mac only */
|
|
mac?: boolean;
|
|
/** Desktop keyboard conventions on Electron/native; browser conventions otherwise. */
|
|
desktopBindings?: boolean;
|
|
/** false = disabled when a text-editing surface is focused */
|
|
editable?: false;
|
|
/** false = disabled when terminal is focused */
|
|
terminal?: false;
|
|
/** false = disabled when command center is open */
|
|
commandCenter?: false;
|
|
/** Allowed focus scope or scopes */
|
|
focusScope?: KeyboardFocusScope | readonly KeyboardFocusScope[];
|
|
}
|
|
|
|
type ShortcutPayloadDef =
|
|
| { type: "index" }
|
|
| { type: "delta"; delta: 1 | -1 }
|
|
| { type: "message-input"; kind: MessageInputKeyboardActionKind };
|
|
|
|
interface ShortcutHelp {
|
|
id: string;
|
|
section: ShortcutSectionId;
|
|
label: string;
|
|
/**
|
|
* Display keys to show instead of the combo. Set this only when the combo
|
|
* cannot express what the row means — the `Digit` wildcard, which stands for
|
|
* any of 1-9, and `Shift+?`, whose Shift is implied by the character itself.
|
|
* Every other row derives its keys from `combo`, so a rebound shortcut shows
|
|
* what it now does rather than what it shipped as.
|
|
*/
|
|
defaultDisplayKeys?: ShortcutKey[];
|
|
note?: string;
|
|
}
|
|
|
|
interface ShortcutBinding {
|
|
id: string;
|
|
action: KeyboardActionId;
|
|
combo: string;
|
|
repeat?: false;
|
|
when?: ShortcutWhen;
|
|
payload?: ShortcutPayloadDef;
|
|
preventDefault?: boolean;
|
|
stopPropagation?: boolean;
|
|
help?: ShortcutHelp;
|
|
}
|
|
|
|
export interface ParsedShortcutBinding extends ShortcutBinding {
|
|
parsedChord: KeyCombo[];
|
|
}
|
|
|
|
export interface ChordState {
|
|
candidateIndices: number[];
|
|
step: number;
|
|
timeoutId: ReturnType<typeof setTimeout> | null;
|
|
}
|
|
|
|
// --- Constants ---
|
|
|
|
// Sections are listed in this order, most-reached-for first.
|
|
const SHORTCUT_HELP_SECTION_ORDER: readonly ShortcutSectionId[] = [
|
|
"general",
|
|
"workspaces",
|
|
"tabs-panes",
|
|
"layout",
|
|
"agent-input",
|
|
];
|
|
|
|
const SHORTCUT_HELP_SECTION_TITLES: Record<ShortcutSectionId, string> = {
|
|
general: "General",
|
|
workspaces: "Projects & Workspaces",
|
|
"tabs-panes": "Tabs & Panes",
|
|
layout: "Layout",
|
|
"agent-input": "Agent Input",
|
|
};
|
|
|
|
const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
|
|
general: "settings.shortcuts.sections.general",
|
|
workspaces: "settings.shortcuts.sections.workspaces",
|
|
"tabs-panes": "settings.shortcuts.sections.tabsPanes",
|
|
layout: "settings.shortcuts.sections.layout",
|
|
"agent-input": "settings.shortcuts.sections.agentInput",
|
|
};
|
|
|
|
// Rows render in this order rather than in binding-definition order, so the shortcut someone opens
|
|
// this sheet to find sits at the top of its section. Every help id must appear here; the unit test
|
|
// fails when a new shortcut is added without a place, instead of silently sinking to the bottom.
|
|
export const SHORTCUT_HELP_ROW_ORDER: Record<ShortcutSectionId, readonly string[]> = {
|
|
general: [
|
|
"toggle-command-center",
|
|
"search-files",
|
|
"show-shortcuts",
|
|
"toggle-settings",
|
|
"cycle-theme",
|
|
],
|
|
workspaces: [
|
|
"new-agent",
|
|
"new-workspace",
|
|
"workspace-jump-index",
|
|
"workspace-prev",
|
|
"workspace-next",
|
|
"pin-workspace",
|
|
"archive-workspace",
|
|
],
|
|
"tabs-panes": [
|
|
"workspace-tab-new",
|
|
"workspace-tab-target-agent",
|
|
"workspace-terminal-new",
|
|
"workspace-tab-target-browser",
|
|
"workspace-tab-target-changes",
|
|
"workspace-tab-target-files",
|
|
"workspace-tab-close-current",
|
|
"workspace-tab-jump-index",
|
|
"workspace-tab-prev",
|
|
"workspace-tab-next",
|
|
"workspace-pane-split-right",
|
|
"workspace-pane-split-down",
|
|
"workspace-pane-focus-left",
|
|
"workspace-pane-focus-right",
|
|
"workspace-pane-focus-up",
|
|
"workspace-pane-focus-down",
|
|
"workspace-pane-move-tab-left",
|
|
"workspace-pane-move-tab-right",
|
|
"workspace-pane-move-tab-up",
|
|
"workspace-pane-move-tab-down",
|
|
"workspace-pane-close",
|
|
],
|
|
layout: ["toggle-left-sidebar", "toggle-right-sidebar", "toggle-both-sidebars", "toggle-focus"],
|
|
"agent-input": [
|
|
"focus-message-input",
|
|
"cycle-agent-mode",
|
|
"voice-toggle",
|
|
"dictation-toggle",
|
|
"agent-interrupt",
|
|
"voice-mute-toggle",
|
|
],
|
|
};
|
|
|
|
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
|
|
"new-agent": "settings.shortcuts.help.openProject",
|
|
"new-workspace": "settings.shortcuts.help.newWorkspace",
|
|
"switch-project": "settings.shortcuts.help.switchProject",
|
|
"archive-workspace": "settings.shortcuts.help.archiveWorkspace",
|
|
"workspace-tab-new": "settings.shortcuts.help.newTab",
|
|
"workspace-tab-target-agent": "workspace.tabs.actions.newAgent",
|
|
"workspace-tab-target-browser": "workspace.tabs.actions.newBrowser",
|
|
"workspace-tab-target-changes": "workspace.tabs.actions.changes",
|
|
"workspace-tab-target-files": "workspace.tabs.actions.files",
|
|
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
|
|
"workspace-jump-index": "settings.shortcuts.help.jumpToWorkspace",
|
|
"workspace-tab-jump-index": "settings.shortcuts.help.jumpToTab",
|
|
"workspace-prev": "settings.shortcuts.help.previousWorkspace",
|
|
"workspace-next": "settings.shortcuts.help.nextWorkspace",
|
|
"workspace-tab-prev": "settings.shortcuts.help.previousTab",
|
|
"workspace-tab-next": "settings.shortcuts.help.nextTab",
|
|
"workspace-pane-split-right": "settings.shortcuts.help.splitPaneRight",
|
|
"workspace-pane-split-down": "settings.shortcuts.help.splitPaneDown",
|
|
"workspace-pane-focus-left": "settings.shortcuts.help.focusPaneLeft",
|
|
"workspace-pane-focus-right": "settings.shortcuts.help.focusPaneRight",
|
|
"workspace-pane-focus-up": "settings.shortcuts.help.focusPaneUp",
|
|
"workspace-pane-focus-down": "settings.shortcuts.help.focusPaneDown",
|
|
"workspace-pane-move-tab-left": "settings.shortcuts.help.moveTabLeft",
|
|
"workspace-pane-move-tab-right": "settings.shortcuts.help.moveTabRight",
|
|
"workspace-pane-move-tab-up": "settings.shortcuts.help.moveTabUp",
|
|
"workspace-pane-move-tab-down": "settings.shortcuts.help.moveTabDown",
|
|
"workspace-pane-close": "settings.shortcuts.help.closePane",
|
|
"workspace-terminal-new": "settings.shortcuts.help.newTerminal",
|
|
"search-files": "settings.shortcuts.help.searchFiles",
|
|
"toggle-command-center": "settings.shortcuts.help.toggleCommandCenter",
|
|
"show-shortcuts": "settings.shortcuts.help.showKeyboardShortcuts",
|
|
"toggle-left-sidebar": "settings.shortcuts.help.toggleLeftSidebar",
|
|
"toggle-right-sidebar": "settings.shortcuts.help.toggleRightSidebar",
|
|
"toggle-both-sidebars": "settings.shortcuts.help.toggleBothSidebars",
|
|
"toggle-settings": "settings.shortcuts.help.toggleSettings",
|
|
"toggle-focus": "settings.shortcuts.help.toggleFocusMode",
|
|
"cycle-theme": "settings.shortcuts.help.cycleTheme",
|
|
"focus-message-input": "settings.shortcuts.help.focusMessageInput",
|
|
"cycle-agent-mode": "settings.shortcuts.help.cycleAgentMode",
|
|
"voice-toggle": "settings.shortcuts.help.toggleVoiceMode",
|
|
"dictation-toggle": "settings.shortcuts.help.startStopDictation",
|
|
"agent-interrupt": "settings.shortcuts.help.interruptAgent",
|
|
"voice-mute-toggle": "settings.shortcuts.help.muteUnmuteVoiceMode",
|
|
};
|
|
|
|
const SHORTCUT_HELP_NOTE_KEYS: Record<string, string> = {
|
|
"show-shortcuts": "settings.shortcuts.helpNotes.showKeyboardShortcuts",
|
|
};
|
|
|
|
// --- Binding definitions ---
|
|
|
|
const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
|
// --- Open project ---
|
|
// Open project moved from Cmd+Shift+O to Cmd+O. The binding ids intentionally
|
|
// keep their original "cmd-shift-o" / "ctrl-shift-o" names: user shortcut
|
|
// overrides are keyed by binding id, so renaming them would silently drop a
|
|
// user's customized Open project shortcut on upgrade.
|
|
{
|
|
id: "agent-new-cmd-shift-o-mac",
|
|
action: "agent.new",
|
|
combo: "Cmd+O",
|
|
when: { mac: true },
|
|
help: {
|
|
id: "new-agent",
|
|
section: "workspaces",
|
|
label: "Open project",
|
|
},
|
|
},
|
|
{
|
|
id: "agent-new-ctrl-shift-o-non-mac",
|
|
action: "agent.new",
|
|
combo: "Ctrl+O",
|
|
when: { mac: false, terminal: false },
|
|
help: {
|
|
id: "new-agent",
|
|
section: "workspaces",
|
|
label: "Open project",
|
|
},
|
|
},
|
|
|
|
// --- New workspace ---
|
|
{
|
|
id: "workspace-new-cmd-n-mac",
|
|
action: "workspace.new",
|
|
combo: "Cmd+N",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "new-workspace",
|
|
section: "workspaces",
|
|
label: "New workspace",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-new-ctrl-n-non-mac",
|
|
action: "workspace.new",
|
|
combo: "Ctrl+N",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "new-workspace",
|
|
section: "workspaces",
|
|
label: "New workspace",
|
|
},
|
|
},
|
|
|
|
// --- Search files (switch project on the New Workspace screen) ---
|
|
{
|
|
id: "workspace-project-pick-cmd-p-mac",
|
|
action: "command-center.files",
|
|
combo: "Cmd+P",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "search-files",
|
|
section: "general",
|
|
label: "Search files",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-project-pick-ctrl-p-non-mac",
|
|
action: "command-center.files",
|
|
combo: "Ctrl+P",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "search-files",
|
|
section: "general",
|
|
label: "Search files",
|
|
},
|
|
},
|
|
|
|
// --- Archive workspace ---
|
|
{
|
|
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
|
|
// 2027-01-11 with a stored-override migration. Keeps existing custom chords.
|
|
id: "worktree-archive-cmd-shift-backspace-mac",
|
|
action: "workspace.archive",
|
|
combo: "Cmd+Shift+Backspace",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "archive-workspace",
|
|
section: "workspaces",
|
|
label: "Archive workspace",
|
|
},
|
|
},
|
|
{
|
|
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
|
|
// 2027-01-11 with a stored-override migration. Keeps existing custom chords.
|
|
id: "worktree-archive-ctrl-shift-backspace-non-mac",
|
|
action: "workspace.archive",
|
|
combo: "Ctrl+Shift+Backspace",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "archive-workspace",
|
|
section: "workspaces",
|
|
label: "Archive workspace",
|
|
},
|
|
},
|
|
|
|
// --- Pin workspace ---
|
|
{
|
|
id: "workspace-pin-cmd-shift-p-mac",
|
|
action: "workspace.pin",
|
|
combo: "Cmd+Shift+P",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "pin-workspace",
|
|
section: "workspaces",
|
|
label: "Pin chat",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-pin-ctrl-shift-p-non-mac",
|
|
action: "workspace.pin",
|
|
combo: "Ctrl+Shift+P",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "pin-workspace",
|
|
section: "workspaces",
|
|
label: "Pin chat",
|
|
},
|
|
},
|
|
|
|
// --- Tab management ---
|
|
{
|
|
id: "workspace-tab-new-cmd-t-mac",
|
|
action: "workspace.tab.menu.open",
|
|
combo: "Cmd+T",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "workspace-tab-new",
|
|
section: "tabs-panes",
|
|
label: "New tab",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-tab-new-ctrl-t-non-mac",
|
|
action: "workspace.tab.menu.open",
|
|
combo: "Ctrl+T",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "workspace-tab-new",
|
|
section: "tabs-panes",
|
|
label: "New tab",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-tab-target-agent-cmd-shift-a-mac",
|
|
action: "workspace.tab.target.agent",
|
|
combo: "Cmd+Shift+A",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "workspace-tab-target-agent",
|
|
section: "tabs-panes",
|
|
label: "New agent",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-tab-target-agent-ctrl-shift-a-non-mac",
|
|
action: "workspace.tab.target.agent",
|
|
combo: "Ctrl+Shift+A",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "workspace-tab-target-agent",
|
|
section: "tabs-panes",
|
|
label: "New agent",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-tab-target-browser-cmd-shift-b-mac",
|
|
action: "workspace.tab.target.browser",
|
|
combo: "Cmd+Shift+B",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "workspace-tab-target-browser",
|
|
section: "tabs-panes",
|
|
label: "New browser",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-tab-target-browser-ctrl-shift-b-non-mac",
|
|
action: "workspace.tab.target.browser",
|
|
combo: "Ctrl+Shift+B",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "workspace-tab-target-browser",
|
|
section: "tabs-panes",
|
|
label: "New browser",
|
|
},
|
|
},
|
|
{
|
|
// Keep the binding id stable so saved overrides from the former Cmd+Shift+C default survive.
|
|
id: "workspace-tab-target-changes-cmd-shift-c-mac",
|
|
action: "workspace.tab.target.changes",
|
|
combo: "Cmd+Shift+G",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "workspace-tab-target-changes",
|
|
section: "tabs-panes",
|
|
label: "Changes",
|
|
},
|
|
},
|
|
{
|
|
// Keep the binding id stable so saved overrides from the former Ctrl+Shift+C default survive.
|
|
id: "workspace-tab-target-changes-ctrl-shift-c-non-mac",
|
|
action: "workspace.tab.target.changes",
|
|
combo: "Ctrl+Shift+G",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "workspace-tab-target-changes",
|
|
section: "tabs-panes",
|
|
label: "Changes",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-tab-target-files-cmd-shift-e-mac",
|
|
action: "workspace.tab.target.files",
|
|
combo: "Cmd+Shift+E",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "workspace-tab-target-files",
|
|
section: "tabs-panes",
|
|
label: "Files",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-tab-target-files-ctrl-shift-e-non-mac",
|
|
action: "workspace.tab.target.files",
|
|
combo: "Ctrl+Shift+E",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "workspace-tab-target-files",
|
|
section: "tabs-panes",
|
|
label: "Files",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-tab-close-current-cmd-w-mac",
|
|
action: "workspace.tab.close.current",
|
|
combo: "Cmd+W",
|
|
when: { mac: true, desktopBindings: true, commandCenter: false },
|
|
help: {
|
|
id: "workspace-tab-close-current",
|
|
section: "tabs-panes",
|
|
label: "Close current tab",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-tab-close-current-ctrl-w-non-mac",
|
|
action: "workspace.tab.close.current",
|
|
combo: "Ctrl+W",
|
|
when: { mac: false, desktopBindings: true, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "workspace-tab-close-current",
|
|
section: "tabs-panes",
|
|
label: "Close current tab",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-tab-close-current-alt-shift-w-web",
|
|
action: "workspace.tab.close.current",
|
|
combo: "Alt+Shift+W",
|
|
when: { desktopBindings: false, commandCenter: false },
|
|
help: {
|
|
id: "workspace-tab-close-current",
|
|
section: "tabs-panes",
|
|
label: "Close current tab",
|
|
},
|
|
},
|
|
|
|
// --- Workspace index jump ---
|
|
{
|
|
id: "workspace-navigate-index-cmd-digit-mac",
|
|
action: "workspace.navigate.index",
|
|
combo: "Cmd+Digit",
|
|
when: { mac: true, desktopBindings: true, commandCenter: false },
|
|
payload: { type: "index" },
|
|
help: {
|
|
id: "workspace-jump-index",
|
|
section: "workspaces",
|
|
label: "Jump to workspace",
|
|
defaultDisplayKeys: ["mod", "1-9"],
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-navigate-index-ctrl-digit-non-mac",
|
|
action: "workspace.navigate.index",
|
|
combo: "Ctrl+Digit",
|
|
when: { mac: false, desktopBindings: true, commandCenter: false, terminal: false },
|
|
payload: { type: "index" },
|
|
help: {
|
|
id: "workspace-jump-index",
|
|
section: "workspaces",
|
|
label: "Jump to workspace",
|
|
defaultDisplayKeys: ["mod", "1-9"],
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-navigate-index-alt-digit-web",
|
|
action: "workspace.navigate.index",
|
|
combo: "Alt+Digit",
|
|
when: { desktopBindings: false, commandCenter: false },
|
|
payload: { type: "index" },
|
|
help: {
|
|
id: "workspace-jump-index",
|
|
section: "workspaces",
|
|
label: "Jump to workspace",
|
|
defaultDisplayKeys: ["alt", "1-9"],
|
|
},
|
|
},
|
|
|
|
// --- Tab index jump ---
|
|
{
|
|
id: "workspace-tab-navigate-index-cmd-alt-digit-mac-desktop",
|
|
action: "workspace.tab.navigate.index",
|
|
combo: "Cmd+Alt+Digit",
|
|
when: { mac: true, desktopBindings: true, commandCenter: false },
|
|
payload: { type: "index" },
|
|
help: {
|
|
id: "workspace-tab-jump-index",
|
|
section: "tabs-panes",
|
|
label: "Jump to tab",
|
|
defaultDisplayKeys: ["mod", "alt", "1-9"],
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-tab-navigate-index-alt-digit-desktop",
|
|
action: "workspace.tab.navigate.index",
|
|
combo: "Alt+Digit",
|
|
when: { mac: false, desktopBindings: true, commandCenter: false },
|
|
payload: { type: "index" },
|
|
help: {
|
|
id: "workspace-tab-jump-index",
|
|
section: "tabs-panes",
|
|
label: "Jump to tab",
|
|
defaultDisplayKeys: ["alt", "1-9"],
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-tab-navigate-index-alt-shift-digit-web",
|
|
action: "workspace.tab.navigate.index",
|
|
combo: "Alt+Shift+Digit",
|
|
when: { desktopBindings: false, commandCenter: false },
|
|
payload: { type: "index" },
|
|
help: {
|
|
id: "workspace-tab-jump-index",
|
|
section: "tabs-panes",
|
|
label: "Jump to tab",
|
|
defaultDisplayKeys: ["alt", "shift", "1-9"],
|
|
},
|
|
},
|
|
|
|
// --- Workspace relative navigation ---
|
|
{
|
|
id: "workspace-navigate-relative-cmd-left-mac",
|
|
action: "workspace.navigate.relative",
|
|
combo: "Cmd+[",
|
|
when: { mac: true, desktopBindings: true, commandCenter: false },
|
|
payload: { type: "delta", delta: -1 },
|
|
help: {
|
|
id: "workspace-prev",
|
|
section: "workspaces",
|
|
label: "Previous workspace",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-navigate-relative-ctrl-left-non-mac",
|
|
action: "workspace.navigate.relative",
|
|
combo: "Ctrl+[",
|
|
when: { mac: false, desktopBindings: true, commandCenter: false, terminal: false },
|
|
payload: { type: "delta", delta: -1 },
|
|
help: {
|
|
id: "workspace-prev",
|
|
section: "workspaces",
|
|
label: "Previous workspace",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-navigate-relative-cmd-right-mac",
|
|
action: "workspace.navigate.relative",
|
|
combo: "Cmd+]",
|
|
when: { mac: true, desktopBindings: true, commandCenter: false },
|
|
payload: { type: "delta", delta: 1 },
|
|
help: {
|
|
id: "workspace-next",
|
|
section: "workspaces",
|
|
label: "Next workspace",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-navigate-relative-ctrl-right-non-mac",
|
|
action: "workspace.navigate.relative",
|
|
combo: "Ctrl+]",
|
|
when: { mac: false, desktopBindings: true, commandCenter: false, terminal: false },
|
|
payload: { type: "delta", delta: 1 },
|
|
help: {
|
|
id: "workspace-next",
|
|
section: "workspaces",
|
|
label: "Next workspace",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-navigate-relative-alt-left-web",
|
|
action: "workspace.navigate.relative",
|
|
combo: "Alt+[",
|
|
when: { desktopBindings: false, commandCenter: false },
|
|
payload: { type: "delta", delta: -1 },
|
|
help: {
|
|
id: "workspace-prev",
|
|
section: "workspaces",
|
|
label: "Previous workspace",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-navigate-relative-alt-right-web",
|
|
action: "workspace.navigate.relative",
|
|
combo: "Alt+]",
|
|
when: { desktopBindings: false, commandCenter: false },
|
|
payload: { type: "delta", delta: 1 },
|
|
help: {
|
|
id: "workspace-next",
|
|
section: "workspaces",
|
|
label: "Next workspace",
|
|
},
|
|
},
|
|
|
|
// --- Tab relative navigation ---
|
|
{
|
|
id: "workspace-tab-navigate-relative-alt-shift-left",
|
|
action: "workspace.tab.navigate.relative",
|
|
combo: "Alt+Shift+[",
|
|
when: { commandCenter: false },
|
|
payload: { type: "delta", delta: -1 },
|
|
help: {
|
|
id: "workspace-tab-prev",
|
|
section: "tabs-panes",
|
|
label: "Previous tab",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-tab-navigate-relative-alt-shift-right",
|
|
action: "workspace.tab.navigate.relative",
|
|
combo: "Alt+Shift+]",
|
|
when: { commandCenter: false },
|
|
payload: { type: "delta", delta: 1 },
|
|
help: {
|
|
id: "workspace-tab-next",
|
|
section: "tabs-panes",
|
|
label: "Next tab",
|
|
},
|
|
},
|
|
|
|
// --- Pane management (mac only) ---
|
|
{
|
|
id: "workspace-pane-split-right-cmd-backslash",
|
|
action: "workspace.pane.split.right",
|
|
combo: "Cmd+\\",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "workspace-pane-split-right",
|
|
section: "tabs-panes",
|
|
label: "Split pane right",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-pane-split-down-cmd-shift-backslash",
|
|
action: "workspace.pane.split.down",
|
|
combo: "Cmd+Shift+\\",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "workspace-pane-split-down",
|
|
section: "tabs-panes",
|
|
label: "Split pane down",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-pane-focus-left-cmd-shift-left",
|
|
action: "workspace.pane.focus.left",
|
|
combo: "Cmd+Shift+ArrowLeft",
|
|
when: { mac: true, commandCenter: false, editable: false },
|
|
help: {
|
|
id: "workspace-pane-focus-left",
|
|
section: "tabs-panes",
|
|
label: "Focus pane left",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-pane-focus-right-cmd-shift-right",
|
|
action: "workspace.pane.focus.right",
|
|
combo: "Cmd+Shift+ArrowRight",
|
|
when: { mac: true, commandCenter: false, editable: false },
|
|
help: {
|
|
id: "workspace-pane-focus-right",
|
|
section: "tabs-panes",
|
|
label: "Focus pane right",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-pane-focus-up-cmd-shift-up",
|
|
action: "workspace.pane.focus.up",
|
|
combo: "Cmd+Shift+ArrowUp",
|
|
when: { mac: true, commandCenter: false, editable: false },
|
|
help: {
|
|
id: "workspace-pane-focus-up",
|
|
section: "tabs-panes",
|
|
label: "Focus pane up",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-pane-focus-down-cmd-shift-down",
|
|
action: "workspace.pane.focus.down",
|
|
combo: "Cmd+Shift+ArrowDown",
|
|
when: { mac: true, commandCenter: false, editable: false },
|
|
help: {
|
|
id: "workspace-pane-focus-down",
|
|
section: "tabs-panes",
|
|
label: "Focus pane down",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-pane-move-tab-left-cmd-shift-alt-left",
|
|
action: "workspace.pane.move-tab.left",
|
|
combo: "Cmd+Alt+Shift+ArrowLeft",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "workspace-pane-move-tab-left",
|
|
section: "tabs-panes",
|
|
label: "Move tab left",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-pane-move-tab-right-cmd-shift-alt-right",
|
|
action: "workspace.pane.move-tab.right",
|
|
combo: "Cmd+Alt+Shift+ArrowRight",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "workspace-pane-move-tab-right",
|
|
section: "tabs-panes",
|
|
label: "Move tab right",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-pane-move-tab-up-cmd-shift-alt-up",
|
|
action: "workspace.pane.move-tab.up",
|
|
combo: "Cmd+Alt+Shift+ArrowUp",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "workspace-pane-move-tab-up",
|
|
section: "tabs-panes",
|
|
label: "Move tab up",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-pane-move-tab-down-cmd-shift-alt-down",
|
|
action: "workspace.pane.move-tab.down",
|
|
combo: "Cmd+Alt+Shift+ArrowDown",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "workspace-pane-move-tab-down",
|
|
section: "tabs-panes",
|
|
label: "Move tab down",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-pane-close-cmd-shift-w",
|
|
action: "workspace.pane.close",
|
|
combo: "Cmd+Shift+W",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "workspace-pane-close",
|
|
section: "tabs-panes",
|
|
label: "Close pane",
|
|
},
|
|
},
|
|
// --- New terminal ---
|
|
{
|
|
id: "workspace-terminal-new-cmd-shift-t-mac",
|
|
action: "workspace.terminal.new",
|
|
combo: "Cmd+Shift+T",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "workspace-terminal-new",
|
|
section: "tabs-panes",
|
|
label: "New terminal",
|
|
},
|
|
},
|
|
{
|
|
id: "workspace-terminal-new-ctrl-shift-t-non-mac",
|
|
action: "workspace.terminal.new",
|
|
combo: "Ctrl+Shift+T",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "workspace-terminal-new",
|
|
section: "tabs-panes",
|
|
label: "New terminal",
|
|
},
|
|
},
|
|
|
|
// --- Command center ---
|
|
{
|
|
id: "command-center-toggle-cmd-k-mac",
|
|
action: "command-center.toggle",
|
|
combo: "Cmd+K",
|
|
when: { mac: true },
|
|
help: {
|
|
id: "toggle-command-center",
|
|
section: "general",
|
|
label: "Toggle command center",
|
|
},
|
|
},
|
|
{
|
|
id: "command-center-toggle-ctrl-k-non-mac",
|
|
action: "command-center.toggle",
|
|
combo: "Ctrl+K",
|
|
when: { mac: false, terminal: false },
|
|
help: {
|
|
id: "toggle-command-center",
|
|
section: "general",
|
|
label: "Toggle command center",
|
|
},
|
|
},
|
|
|
|
// --- Keyboard shortcuts dialog ---
|
|
{
|
|
id: "shortcuts-dialog-toggle-question-mark",
|
|
action: "shortcuts.dialog.toggle",
|
|
combo: "Shift+?",
|
|
repeat: false,
|
|
when: { focusScope: "other" },
|
|
help: {
|
|
id: "show-shortcuts",
|
|
section: "general",
|
|
label: "Show keyboard shortcuts",
|
|
defaultDisplayKeys: ["?"],
|
|
note: "Available when focus is not in a text field or terminal.",
|
|
},
|
|
},
|
|
|
|
// --- Sidebar toggles ---
|
|
{
|
|
id: "sidebar-toggle-left-mac-cmd-b",
|
|
action: "sidebar.toggle.left",
|
|
combo: "Cmd+B",
|
|
when: { mac: true },
|
|
help: {
|
|
id: "toggle-left-sidebar",
|
|
section: "layout",
|
|
label: "Toggle left sidebar",
|
|
},
|
|
},
|
|
{
|
|
id: "sidebar-toggle-left-ctrl-period-non-mac",
|
|
action: "sidebar.toggle.left",
|
|
combo: "Ctrl+B",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "toggle-left-sidebar",
|
|
section: "layout",
|
|
label: "Toggle left sidebar",
|
|
},
|
|
},
|
|
{
|
|
id: "sidebar-toggle-right-cmd-e-mac",
|
|
action: "sidebar.toggle.right",
|
|
combo: "Cmd+E",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "toggle-right-sidebar",
|
|
section: "layout",
|
|
label: "Toggle Explorer sidebar",
|
|
},
|
|
},
|
|
{
|
|
id: "sidebar-toggle-right-ctrl-e-non-mac",
|
|
action: "sidebar.toggle.right",
|
|
combo: "Ctrl+E",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "toggle-right-sidebar",
|
|
section: "layout",
|
|
label: "Toggle Explorer sidebar",
|
|
},
|
|
},
|
|
{
|
|
id: "sidebar-toggle-right-ctrl-backquote",
|
|
action: "sidebar.toggle.right",
|
|
combo: "Ctrl+`",
|
|
when: { commandCenter: false },
|
|
},
|
|
|
|
// --- Toggle both sidebars ---
|
|
{
|
|
id: "sidebar-toggle-both-cmd-period-mac",
|
|
action: "sidebar.toggle.both",
|
|
combo: "Cmd+.",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "toggle-both-sidebars",
|
|
section: "layout",
|
|
label: "Toggle both sidebars",
|
|
},
|
|
},
|
|
{
|
|
id: "sidebar-toggle-both-ctrl-period-non-mac",
|
|
action: "sidebar.toggle.both",
|
|
combo: "Ctrl+.",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "toggle-both-sidebars",
|
|
section: "layout",
|
|
label: "Toggle both sidebars",
|
|
},
|
|
},
|
|
|
|
// --- Settings toggle ---
|
|
{
|
|
id: "settings-toggle-cmd-comma-mac",
|
|
action: "settings.toggle",
|
|
combo: "Cmd+,",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "toggle-settings",
|
|
section: "general",
|
|
label: "Toggle settings",
|
|
},
|
|
},
|
|
{
|
|
id: "settings-toggle-ctrl-comma-non-mac",
|
|
action: "settings.toggle",
|
|
combo: "Ctrl+,",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "toggle-settings",
|
|
section: "general",
|
|
label: "Toggle settings",
|
|
},
|
|
},
|
|
|
|
// --- Focus mode ---
|
|
{
|
|
id: "view-toggle-focus-cmd-shift-f-mac",
|
|
action: "view.toggle.focus",
|
|
combo: "Cmd+Shift+F",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "toggle-focus",
|
|
section: "layout",
|
|
label: "Toggle focus mode",
|
|
},
|
|
},
|
|
{
|
|
id: "view-toggle-focus-ctrl-shift-f-non-mac",
|
|
action: "view.toggle.focus",
|
|
combo: "Ctrl+Shift+F",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "toggle-focus",
|
|
section: "layout",
|
|
label: "Toggle focus mode",
|
|
},
|
|
},
|
|
|
|
// --- Theme cycling ---
|
|
{
|
|
id: "theme-cycle-cmd-shift-t-mac",
|
|
action: "theme.cycle",
|
|
combo: "Cmd+Alt+T",
|
|
when: { mac: true, commandCenter: false },
|
|
help: {
|
|
id: "cycle-theme",
|
|
section: "general",
|
|
label: "Cycle theme",
|
|
},
|
|
},
|
|
{
|
|
id: "theme-cycle-ctrl-alt-t-non-mac",
|
|
action: "theme.cycle",
|
|
combo: "Ctrl+Alt+T",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
help: {
|
|
id: "cycle-theme",
|
|
section: "general",
|
|
label: "Cycle theme",
|
|
},
|
|
},
|
|
|
|
// --- Message input ---
|
|
{
|
|
id: "message-input-focus-cmd-l-mac",
|
|
action: "message-input.action",
|
|
combo: "Cmd+L",
|
|
when: { mac: true, commandCenter: false },
|
|
payload: { type: "message-input", kind: "focus" },
|
|
help: {
|
|
id: "focus-message-input",
|
|
section: "agent-input",
|
|
label: "Focus message input",
|
|
},
|
|
},
|
|
{
|
|
id: "message-input-focus-ctrl-l-non-mac",
|
|
action: "message-input.action",
|
|
combo: "Ctrl+L",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
payload: { type: "message-input", kind: "focus" },
|
|
help: {
|
|
id: "focus-message-input",
|
|
section: "agent-input",
|
|
label: "Focus message input",
|
|
},
|
|
},
|
|
{
|
|
id: "message-input-mode-cycle-shift-tab",
|
|
action: "message-input.action",
|
|
combo: "Shift+Tab",
|
|
repeat: false,
|
|
when: { commandCenter: false, focusScope: "message-input" },
|
|
payload: { type: "message-input", kind: "mode-cycle" },
|
|
help: {
|
|
id: "cycle-agent-mode",
|
|
section: "agent-input",
|
|
label: "Cycle agent mode",
|
|
},
|
|
},
|
|
{
|
|
id: "message-input-voice-toggle-cmd-shift-d-mac",
|
|
action: "message-input.action",
|
|
combo: "Cmd+Shift+D",
|
|
repeat: false,
|
|
when: { mac: true, commandCenter: false, terminal: false },
|
|
payload: { type: "message-input", kind: "voice-toggle" },
|
|
help: {
|
|
id: "voice-toggle",
|
|
section: "agent-input",
|
|
label: "Toggle voice mode",
|
|
},
|
|
},
|
|
{
|
|
id: "message-input-voice-toggle-ctrl-shift-d-non-mac",
|
|
action: "message-input.action",
|
|
combo: "Ctrl+Shift+D",
|
|
repeat: false,
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
payload: { type: "message-input", kind: "voice-toggle" },
|
|
help: {
|
|
id: "voice-toggle",
|
|
section: "agent-input",
|
|
label: "Toggle voice mode",
|
|
},
|
|
},
|
|
{
|
|
id: "message-input-dictation-toggle-cmd-d-mac",
|
|
action: "message-input.action",
|
|
combo: "Cmd+D",
|
|
when: { mac: true, commandCenter: false, terminal: false },
|
|
payload: { type: "message-input", kind: "dictation-toggle" },
|
|
help: {
|
|
id: "dictation-toggle",
|
|
section: "agent-input",
|
|
label: "Start/stop dictation",
|
|
},
|
|
},
|
|
{
|
|
id: "message-input-dictation-toggle-ctrl-d-non-mac",
|
|
action: "message-input.action",
|
|
combo: "Ctrl+D",
|
|
when: { mac: false, commandCenter: false, terminal: false },
|
|
payload: { type: "message-input", kind: "dictation-toggle" },
|
|
help: {
|
|
id: "dictation-toggle",
|
|
section: "agent-input",
|
|
label: "Start/stop dictation",
|
|
},
|
|
},
|
|
{
|
|
id: "agent-interrupt",
|
|
action: "agent.interrupt",
|
|
combo: "Escape",
|
|
when: { commandCenter: false, focusScope: ["message-input", "other"] },
|
|
preventDefault: false,
|
|
stopPropagation: false,
|
|
help: {
|
|
id: "agent-interrupt",
|
|
section: "agent-input",
|
|
label: "Interrupt agent",
|
|
},
|
|
},
|
|
{
|
|
id: "message-input-dictation-confirm-enter",
|
|
action: "message-input.action",
|
|
combo: "Enter",
|
|
when: { commandCenter: false, terminal: false },
|
|
payload: { type: "message-input", kind: "dictation-confirm" },
|
|
},
|
|
|
|
{
|
|
id: "message-input-voice-mute-toggle",
|
|
action: "message-input.action",
|
|
combo: "Space",
|
|
repeat: false,
|
|
when: { commandCenter: false, focusScope: "other" },
|
|
payload: { type: "message-input", kind: "voice-mute-toggle" },
|
|
help: {
|
|
id: "voice-mute-toggle",
|
|
section: "agent-input",
|
|
label: "Mute/unmute voice mode",
|
|
},
|
|
},
|
|
];
|
|
|
|
// --- Parse bindings at module load ---
|
|
|
|
/**
|
|
* The stored value meaning "the user deliberately unassigned this shortcut".
|
|
* Distinct from a missing key, which means "no override, use the default".
|
|
*/
|
|
export const UNASSIGNED_COMBO = null;
|
|
|
|
/**
|
|
* Parse a binding's combo string into a chord.
|
|
*
|
|
* An empty combo yields an empty chord, which never matches any event — the
|
|
* matcher skips bindings whose first combo is missing. That is how both a
|
|
* user-unassigned shortcut and a binding authored without a default combo are
|
|
* represented: one state, not two. Authors who want a default-less binding
|
|
* write `combo: ""` and nothing else.
|
|
*/
|
|
export function parseBindingChord(combo: string): KeyCombo[] {
|
|
if (combo === "") {
|
|
return [];
|
|
}
|
|
return parseChordString(combo);
|
|
}
|
|
|
|
function parseBinding(binding: ShortcutBinding): ParsedShortcutBinding {
|
|
const parsedChord = parseBindingChord(binding.combo);
|
|
const lastCombo = parsedChord.at(-1);
|
|
if (binding.repeat === false && lastCombo) {
|
|
lastCombo.repeat = false;
|
|
}
|
|
return { ...binding, parsedChord };
|
|
}
|
|
|
|
export const DEFAULT_BINDINGS: readonly ParsedShortcutBinding[] =
|
|
SHORTCUT_BINDINGS.map(parseBinding);
|
|
|
|
export type ShortcutOverrides = Record<string, string | null>;
|
|
|
|
/** Keep number-group actions bound to all nine indices when recording a new modifier. */
|
|
export function normalizeCapturedShortcutCombo(bindingId: string, combo: string): string {
|
|
const binding = DEFAULT_BINDINGS.find((candidate) => candidate.id === bindingId);
|
|
if (binding?.payload?.type !== "index") return combo;
|
|
return combo.replace(/(^|\+)[1-9]$/, "$1Digit");
|
|
}
|
|
|
|
/** Index navigation needs a final number; earlier combos may be any chord prefix. */
|
|
export function isShortcutCaptureComplete(bindingId: string, combos: readonly string[]): boolean {
|
|
const lastCombo = combos.at(-1);
|
|
if (!lastCombo) return false;
|
|
const binding = DEFAULT_BINDINGS.find((candidate) => candidate.id === bindingId);
|
|
return binding?.payload?.type !== "index" || /(^|\+)(Digit|[1-9])$/.test(lastCombo);
|
|
}
|
|
|
|
export function buildEffectiveBindings(overrides: ShortcutOverrides): ParsedShortcutBinding[] {
|
|
return DEFAULT_BINDINGS.map(function (binding) {
|
|
const override = overrides[binding.id];
|
|
if (override === UNASSIGNED_COMBO) {
|
|
return { ...binding, combo: "", parsedChord: [] };
|
|
}
|
|
// Storage is unvalidated JSON, so anything can turn up here.
|
|
if (typeof override !== "string") {
|
|
return binding;
|
|
}
|
|
let parsedChord: KeyCombo[];
|
|
try {
|
|
parsedChord = parseBindingChord(override);
|
|
} catch {
|
|
return binding;
|
|
}
|
|
const lastCombo = parsedChord.at(-1);
|
|
if (binding.repeat === false && lastCombo) {
|
|
lastCombo.repeat = false;
|
|
}
|
|
const when = withoutDefaultComboGuard(binding.when);
|
|
if (!binding.help?.defaultDisplayKeys) {
|
|
return { ...binding, combo: override, parsedChord, when };
|
|
}
|
|
const { defaultDisplayKeys: _defaultDisplayKeys, ...help } = binding.help;
|
|
return { ...binding, combo: override, parsedChord, when, help };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* `editable: false` is a statement about a binding's *default* combo, not
|
|
* about its action: the pane-focus defaults carry it so that Cmd+Shift+Arrow
|
|
* keeps selecting text in a field instead of moving pane focus. An override
|
|
* replaces that combo, so the guard no longer describes anything and has to
|
|
* go, the same way `defaultDisplayKeys` does — otherwise the combo the user
|
|
* picked in Settings silently refuses to fire wherever they are typing.
|
|
*
|
|
* The other guards stay. Platform, command center, terminal and focus scope
|
|
* are properties of the action and of where it makes sense, and none of them
|
|
* change because the keys did.
|
|
*/
|
|
function withoutDefaultComboGuard(when: ShortcutWhen | undefined): ShortcutWhen | undefined {
|
|
if (when?.editable !== false) return when;
|
|
const { editable: _editable, ...rest } = when;
|
|
return rest;
|
|
}
|
|
|
|
// --- Matching engine ---
|
|
|
|
function parseDigit(event: KeyboardShortcutInput): 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 matchesKeyOrCode(combo: KeyCombo, event: KeyboardShortcutInput): boolean {
|
|
if (combo.key === undefined) {
|
|
return event.code === combo.code;
|
|
}
|
|
const eventKey = event.key.toLowerCase();
|
|
if (eventKey === combo.key) return true;
|
|
if (combo.shift === true && combo.shiftedKey !== undefined && eventKey === combo.shiftedKey) {
|
|
return true;
|
|
}
|
|
// Recording an unsupported letter (for example Cyrillic и on KeyB)
|
|
// stores its physical A-Z key. Dispatch must accept that same key without
|
|
// giving Latin layouts such as Dvorak a second physical-key binding.
|
|
if (/^[a-z]$/.test(combo.key) && /^\p{Letter}$/u.test(event.key) && !/^[a-z]$/.test(eventKey)) {
|
|
return event.code === combo.code;
|
|
}
|
|
// macOS rewrites event.key when Option is held (Option+T -> "†",
|
|
// Option+[ -> "“"), so Alt-bound letter / bracket bindings can only
|
|
// match by event.code. Stay key-first for non-Alt bindings so Dvorak
|
|
// keeps its logical-character matching (e.g. Cmd+V on physical Period
|
|
// must paste, not trigger Cmd+.).
|
|
if (combo.alt === true && event.code === combo.code) return true;
|
|
return combo.codeFallback === true && event.code === combo.code;
|
|
}
|
|
|
|
function matchesCombo(combo: KeyCombo, event: KeyboardShortcutInput, isMac: boolean): boolean {
|
|
if (combo.mod) {
|
|
if (isMac) {
|
|
if (!event.metaKey) return false;
|
|
if (!!combo.ctrl !== event.ctrlKey) return false;
|
|
} else {
|
|
if (!event.ctrlKey) return false;
|
|
if (!!combo.meta !== event.metaKey) return false;
|
|
}
|
|
} else {
|
|
if (!!combo.meta !== event.metaKey) return false;
|
|
if (!!combo.ctrl !== event.ctrlKey) return false;
|
|
}
|
|
if (!!combo.alt !== event.altKey) return false;
|
|
if (!!combo.shift !== event.shiftKey) return false;
|
|
if (combo.repeat === false && event.repeat) return false;
|
|
|
|
if (combo.code === "Digit") {
|
|
return parseDigit(event) !== null;
|
|
}
|
|
return matchesKeyOrCode(combo, event);
|
|
}
|
|
|
|
export function matchesKeyboardShortcutContext(
|
|
when: ShortcutWhen | undefined,
|
|
context: KeyboardShortcutContext,
|
|
): boolean {
|
|
if (!when) return true;
|
|
if (when.mac !== undefined && when.mac !== context.isMac) return false;
|
|
if (when.desktopBindings !== undefined && when.desktopBindings !== context.usesDesktopBindings)
|
|
return false;
|
|
if (
|
|
when.editable === false &&
|
|
(context.focusScope === "message-input" || context.focusScope === "editable")
|
|
) {
|
|
return false;
|
|
}
|
|
if (when.terminal === false && context.focusScope === "terminal") return false;
|
|
if (when.commandCenter === false && context.commandCenterOpen) return false;
|
|
if (
|
|
when.focusScope !== undefined &&
|
|
!(typeof when.focusScope === "string"
|
|
? context.focusScope === when.focusScope
|
|
: when.focusScope.includes(context.focusScope))
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function resolvePayload(
|
|
def: ShortcutPayloadDef | undefined,
|
|
event: KeyboardShortcutInput,
|
|
): KeyboardShortcutPayload {
|
|
if (!def) return null;
|
|
switch (def.type) {
|
|
case "index": {
|
|
const index = parseDigit(event);
|
|
return index ? { index } : null;
|
|
}
|
|
case "delta":
|
|
return { delta: def.delta };
|
|
case "message-input":
|
|
return { kind: def.kind };
|
|
default:
|
|
throw new Error("unreachable");
|
|
}
|
|
}
|
|
|
|
const CHORD_TIMEOUT_MS = 1500;
|
|
|
|
function clearChordTimeout(timeoutId: ReturnType<typeof setTimeout> | null): void {
|
|
if (timeoutId !== null) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
function createChordTimeout(onChordReset: () => void): ReturnType<typeof setTimeout> {
|
|
return setTimeout(onChordReset, CHORD_TIMEOUT_MS);
|
|
}
|
|
|
|
function resetChordState(input: ChordState): ChordState {
|
|
clearChordTimeout(input.timeoutId);
|
|
return {
|
|
candidateIndices: [],
|
|
step: 0,
|
|
timeoutId: null,
|
|
};
|
|
}
|
|
|
|
function helpMatchesPlatform(
|
|
when: ShortcutWhen | undefined,
|
|
context: KeyboardShortcutPlatformContext,
|
|
): boolean {
|
|
if (when?.mac !== undefined && when.mac !== context.isMac) return false;
|
|
if (when?.desktopBindings !== undefined && when.desktopBindings !== context.usesDesktopBindings)
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
// --- Public API ---
|
|
|
|
function buildMatchFromBinding(
|
|
binding: ParsedShortcutBinding,
|
|
event: KeyboardShortcutInput,
|
|
): KeyboardShortcutMatch {
|
|
return {
|
|
action: binding.action,
|
|
payload: resolvePayload(binding.payload, event),
|
|
preventDefault: binding.preventDefault ?? true,
|
|
stopPropagation: binding.stopPropagation ?? true,
|
|
};
|
|
}
|
|
|
|
function resolveInitialChordStep(input: {
|
|
event: KeyboardShortcutInput;
|
|
context: KeyboardShortcutContext;
|
|
chordState: ChordState;
|
|
onChordReset: () => void;
|
|
bindings: readonly ParsedShortcutBinding[];
|
|
}): {
|
|
match: KeyboardShortcutMatch | null;
|
|
nextChordState: ChordState;
|
|
preventDefault: boolean;
|
|
} {
|
|
const { event, context, chordState, onChordReset, bindings } = input;
|
|
const advancingCandidateIndices: number[] = [];
|
|
let singleComboMatch: KeyboardShortcutMatch | null = null;
|
|
|
|
for (const [index, binding] of bindings.entries()) {
|
|
const firstCombo = binding.parsedChord[0];
|
|
if (!firstCombo) {
|
|
continue;
|
|
}
|
|
if (!matchesCombo(firstCombo, event, context.isMac)) {
|
|
continue;
|
|
}
|
|
if (!matchesKeyboardShortcutContext(binding.when, context)) {
|
|
continue;
|
|
}
|
|
if (binding.parsedChord.length > 1) {
|
|
advancingCandidateIndices.push(index);
|
|
continue;
|
|
}
|
|
if (!singleComboMatch) {
|
|
singleComboMatch = buildMatchFromBinding(binding, event);
|
|
}
|
|
}
|
|
|
|
if (advancingCandidateIndices.length > 0) {
|
|
return {
|
|
match: null,
|
|
nextChordState: {
|
|
candidateIndices: advancingCandidateIndices,
|
|
step: 1,
|
|
timeoutId: createChordTimeout(onChordReset),
|
|
},
|
|
preventDefault: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
match: singleComboMatch,
|
|
nextChordState: resetChordState(chordState),
|
|
preventDefault: false,
|
|
};
|
|
}
|
|
|
|
function resolveAdvancingChordStep(input: {
|
|
event: KeyboardShortcutInput;
|
|
context: KeyboardShortcutContext;
|
|
chordState: ChordState;
|
|
onChordReset: () => void;
|
|
bindings: readonly ParsedShortcutBinding[];
|
|
}): {
|
|
match: KeyboardShortcutMatch | null;
|
|
nextChordState: ChordState;
|
|
preventDefault: boolean;
|
|
} {
|
|
const { event, context, chordState, onChordReset, bindings } = input;
|
|
const matchingCandidateIndices: number[] = [];
|
|
let completedMatch: KeyboardShortcutMatch | null = null;
|
|
|
|
for (const index of chordState.candidateIndices) {
|
|
const binding = bindings[index];
|
|
if (!binding) {
|
|
continue;
|
|
}
|
|
const combo = binding.parsedChord[chordState.step];
|
|
if (!combo) {
|
|
continue;
|
|
}
|
|
if (!matchesCombo(combo, event, context.isMac)) {
|
|
continue;
|
|
}
|
|
if (!matchesKeyboardShortcutContext(binding.when, context)) {
|
|
continue;
|
|
}
|
|
if (chordState.step + 1 === binding.parsedChord.length) {
|
|
completedMatch = buildMatchFromBinding(binding, event);
|
|
break;
|
|
}
|
|
matchingCandidateIndices.push(index);
|
|
}
|
|
|
|
if (completedMatch) {
|
|
return {
|
|
match: completedMatch,
|
|
nextChordState: resetChordState(chordState),
|
|
preventDefault: false,
|
|
};
|
|
}
|
|
|
|
if (matchingCandidateIndices.length > 0) {
|
|
clearChordTimeout(chordState.timeoutId);
|
|
return {
|
|
match: null,
|
|
nextChordState: {
|
|
candidateIndices: matchingCandidateIndices,
|
|
step: chordState.step + 1,
|
|
timeoutId: createChordTimeout(onChordReset),
|
|
},
|
|
preventDefault: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
match: null,
|
|
nextChordState: resetChordState(chordState),
|
|
preventDefault: false,
|
|
};
|
|
}
|
|
|
|
export function resolveKeyboardShortcut(input: {
|
|
event: KeyboardShortcutInput;
|
|
context: KeyboardShortcutContext;
|
|
chordState: ChordState;
|
|
onChordReset: () => void;
|
|
bindings?: readonly ParsedShortcutBinding[];
|
|
}): {
|
|
match: KeyboardShortcutMatch | null;
|
|
nextChordState: ChordState;
|
|
preventDefault: boolean;
|
|
} {
|
|
const { event, context, chordState, onChordReset, bindings = DEFAULT_BINDINGS } = input;
|
|
// Pressing a modifier emits its own keydown before the combo that holds it,
|
|
// so a chord waiting on `Ctrl+J` sees a bare `Control` first. That keydown
|
|
// matches no combo, and resolving it would drop the chord back to its first
|
|
// step. It decides nothing: leave the chord where it is.
|
|
if (isModifierKeyCode(event.code)) {
|
|
return { match: null, nextChordState: chordState, preventDefault: false };
|
|
}
|
|
if (chordState.step === 0) {
|
|
return resolveInitialChordStep({ event, context, chordState, onChordReset, bindings });
|
|
}
|
|
return resolveAdvancingChordStep({ event, context, chordState, onChordReset, bindings });
|
|
}
|
|
|
|
export function getBindingIdForAction(
|
|
actionId: string,
|
|
platform: { isMac: boolean; usesDesktopBindings: boolean },
|
|
): string | null {
|
|
for (const binding of DEFAULT_BINDINGS) {
|
|
if (binding.help?.id !== actionId) {
|
|
continue;
|
|
}
|
|
if (!helpMatchesPlatform(binding.when, platform)) {
|
|
continue;
|
|
}
|
|
return binding.id;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The keys to display for one binding, derived from the combo that actually
|
|
* fires so a rebound shortcut never advertises the keys it shipped with.
|
|
*
|
|
* `help.defaultDisplayKeys` wins where it is set, but only on the shipped
|
|
* binding. Effective bindings discard it when an override replaces the combo,
|
|
* so rebound rows still derive the keys that now fire.
|
|
* `parsedChord` is the single source of truth for "has no keys": it is derived
|
|
* from `combo`, so it covers both a user-unassigned shortcut and a binding
|
|
* authored without a default.
|
|
*/
|
|
function displayChordForBinding(binding: ParsedShortcutBinding): ShortcutKey[][] | null {
|
|
if (binding.parsedChord.length === 0) {
|
|
return null;
|
|
}
|
|
const displayKeys = binding.help?.defaultDisplayKeys;
|
|
if (displayKeys) {
|
|
return [displayKeys];
|
|
}
|
|
return chordStringToShortcutKeys(binding.combo);
|
|
}
|
|
|
|
export function getDefaultKeysForAction(
|
|
actionId: string,
|
|
platform: { isMac: boolean; usesDesktopBindings: boolean },
|
|
bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS,
|
|
): ShortcutKey[][] | null {
|
|
for (const binding of bindings) {
|
|
if (binding.help?.id !== actionId) {
|
|
continue;
|
|
}
|
|
if (!helpMatchesPlatform(binding.when, platform)) {
|
|
continue;
|
|
}
|
|
return displayChordForBinding(binding);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The keys to display for a shortcut: the user's override if they set one,
|
|
* the default otherwise, and `null` when the shortcut has no keys at all —
|
|
* either the user unassigned it or it ships without a default combo.
|
|
*
|
|
* The single resolver behind every display surface (hint badges, the command
|
|
* palette, and the settings rows). It validates an override the same way
|
|
* matching does, so what is shown is always what actually fires.
|
|
*/
|
|
export function resolveShortcutKeysForAction(
|
|
actionId: string,
|
|
overrides: ShortcutOverrides,
|
|
platform: { isMac: boolean; usesDesktopBindings: boolean },
|
|
): ShortcutKey[][] | null {
|
|
const bindingId = getBindingIdForAction(actionId, platform);
|
|
if (bindingId === null) {
|
|
return null;
|
|
}
|
|
|
|
const defaultChord = getDefaultKeysForAction(actionId, platform);
|
|
|
|
const override = overrides[bindingId];
|
|
if (override === UNASSIGNED_COMBO || override === "") {
|
|
return null;
|
|
}
|
|
// Storage is unvalidated JSON: a missing key and a corrupt value both mean
|
|
// "fall back to the default".
|
|
if (typeof override !== "string") {
|
|
return defaultChord;
|
|
}
|
|
try {
|
|
parseBindingChord(override);
|
|
} catch {
|
|
// Matching falls back to the default for an unparseable override, so the
|
|
// display has to as well or it would advertise keys that do nothing.
|
|
return defaultChord;
|
|
}
|
|
return chordStringToShortcutKeys(override);
|
|
}
|
|
|
|
/**
|
|
* The `KeyboardEvent.key` whose hold reveals the sidebar workspace-jump number
|
|
* badges, or `null` when no badges should appear.
|
|
*
|
|
* It must match the modifier of the active `workspace.navigate.index` binding
|
|
* for this runtime, otherwise the badges appear for a modifier that does not
|
|
* actually jump. That binding is parameterized — its key is the `Digit`
|
|
* wildcard, which stands for any of 1-9 — so the badges are only honest when
|
|
* the effective binding is still a single combo built on that wildcard.
|
|
* Anything else (unassigned, rebound to one concrete digit, or a multi-step
|
|
* chord) yields `null`, because the 1-9 badges would be advertising more than
|
|
* the shortcut delivers.
|
|
*/
|
|
export function getWorkspaceIndexJumpModifierKey(
|
|
platform: { isMac: boolean; usesDesktopBindings: boolean },
|
|
bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS,
|
|
): "Alt" | "Meta" | "Control" | null {
|
|
const binding = bindings.find(function (candidate) {
|
|
return (
|
|
candidate.action === "workspace.navigate.index" &&
|
|
helpMatchesPlatform(candidate.when, platform)
|
|
);
|
|
});
|
|
if (!binding || binding.parsedChord.length !== 1) {
|
|
return null;
|
|
}
|
|
|
|
const combo = binding.parsedChord[0];
|
|
if (!combo || combo.code !== "Digit") {
|
|
return null;
|
|
}
|
|
// Exactly one modifier: holding it is what reveals the badges, so a combo
|
|
// needing a second one would show badges the user cannot act on.
|
|
const modifiers = [combo.mod, combo.meta, combo.ctrl, combo.alt, combo.shift];
|
|
if (modifiers.filter(Boolean).length !== 1) {
|
|
return null;
|
|
}
|
|
|
|
if (combo.mod) return platform.isMac ? "Meta" : "Control";
|
|
if (combo.meta) return "Meta";
|
|
if (combo.ctrl) return "Control";
|
|
if (combo.alt) return "Alt";
|
|
return null;
|
|
}
|
|
|
|
export function buildKeyboardShortcutHelpSections(
|
|
input: KeyboardShortcutPlatformContext,
|
|
bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS,
|
|
): KeyboardShortcutHelpSection[] {
|
|
const seenRows = new Set<string>();
|
|
const rowsBySection = new Map<ShortcutSectionId, KeyboardShortcutHelpRow[]>(
|
|
SHORTCUT_HELP_SECTION_ORDER.map((sectionId) => [sectionId, []]),
|
|
);
|
|
|
|
for (const binding of bindings) {
|
|
const help = binding.help;
|
|
if (!help) {
|
|
continue;
|
|
}
|
|
if (!helpMatchesPlatform(binding.when, input)) {
|
|
continue;
|
|
}
|
|
const rowKey = `${help.section}:${help.id}`;
|
|
if (seenRows.has(rowKey)) {
|
|
continue;
|
|
}
|
|
seenRows.add(rowKey);
|
|
|
|
const rows = rowsBySection.get(help.section);
|
|
if (!rows) {
|
|
continue;
|
|
}
|
|
rows.push({
|
|
id: help.id,
|
|
label: help.label,
|
|
labelKey: SHORTCUT_HELP_LABEL_KEYS[help.id] ?? help.label,
|
|
chord: displayChordForBinding(binding),
|
|
...(help.note ? { note: help.note } : {}),
|
|
...(SHORTCUT_HELP_NOTE_KEYS[help.id] ? { noteKey: SHORTCUT_HELP_NOTE_KEYS[help.id] } : {}),
|
|
});
|
|
}
|
|
|
|
return SHORTCUT_HELP_SECTION_ORDER.flatMap((sectionId) => {
|
|
const rows = rowsBySection.get(sectionId) ?? [];
|
|
if (rows.length === 0) {
|
|
return [];
|
|
}
|
|
const order = SHORTCUT_HELP_ROW_ORDER[sectionId];
|
|
const rank = (row: KeyboardShortcutHelpRow) => {
|
|
const index = order.indexOf(row.id);
|
|
return index === -1 ? order.length : index;
|
|
};
|
|
rows.sort((left, right) => rank(left) - rank(right));
|
|
return [
|
|
{
|
|
id: sectionId,
|
|
title: SHORTCUT_HELP_SECTION_TITLES[sectionId],
|
|
titleKey: SHORTCUT_HELP_SECTION_LABEL_KEYS[sectionId],
|
|
rows,
|
|
},
|
|
];
|
|
});
|
|
}
|