Files
paseo/packages/app/src/keyboard/keyboard-shortcuts.ts
T
Mohamed Boudra 6c1b48d296 Archive a workspace from its sidebar row, with undo (#8904)
* feat(app): archive a workspace from its sidebar row and undo it

Hovering a workspace row on desktop shows an archive button next to the
row menu. Archiving shows a toast with Undo and a Cmd/Ctrl+Z shortcut so a
mis-click costs one keystroke instead of a trip through History.

The daemon serializes archive and undo per workspace across every
connection. An undo that lands while an archive is still tearing down
aborts it at the next checkpoint and restores only the agents that archive
took down; deleting the worktree directory is the point of no return, after
which undo recreates the worktree.

* fix(server,app): close archive and undo races found in review

- An archive request now claims the workspace's archive queue before
  looking up the workspace, so an undo sent right behind it cancels it
  instead of running first and losing to the archive.
- A repeat archive of an already archived workspace no longer wipes the
  agents the earlier archive saved for undo.
- Undo no longer brings back an agent that was archived before the
  workspace archive and only loaded to read its history.
- Each local archive or undo request owns its own intent token, so a
  late response to an earlier request cannot release a newer request's
  hold on the row. An archive canceled by another client's undo puts the
  row back.
- The undo failure toast shows translated copy; the cause goes to the log.

* fix(app): keep a workspace's layout when another client restores it during the undo window

The undo window purged tabs and layout whenever it closed after a
finished archive. If another client restored the workspace before this
client's toast closed, the restored workspace lost its saved
arrangement. The archive now commits only if the workspace is still gone
when the window closes.

* fix(app): show the archived toast's Undo as a button

* fix(app): keep a workspace's agent tabs when it is opened while its undo runs

* fix(app): keep a row whose undo is running when a directory snapshot omits it
2026-11-06 16:16:11 +07:00

1800 lines
53 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",
"undo-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",
},
},
// --- Undo archive workspace ---
// Only claims the key while an archive can be undone; otherwise text undo runs as usual.
{
id: "workspace-archive-undo-cmd-z-mac",
action: "workspace.archive.undo",
combo: "Cmd+Z",
when: { mac: true, commandCenter: false, editable: false, terminal: false },
help: {
id: "undo-archive-workspace",
section: "workspaces",
label: "Undo archive workspace",
},
},
{
id: "workspace-archive-undo-ctrl-z-non-mac",
action: "workspace.archive.undo",
combo: "Ctrl+Z",
when: { mac: false, commandCenter: false, editable: false, terminal: false },
help: {
id: "undo-archive-workspace",
section: "workspaces",
label: "Undo 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,
},
];
});
}