feat: add editable keyboard shortcuts

This commit is contained in:
Federico Jaramillo Martinez
2026-06-08 14:57:24 +02:00
parent f3e19d1463
commit 351ed03bf8
8 changed files with 800 additions and 81 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add a keyboard shortcuts settings editor with manual entry, recording, disabling, reset-to-default controls, and conflict/shadowing indicators.
+9 -4
View File
@@ -31,7 +31,7 @@ import { NavigationSectionsController, type NavigationSection } from "../appShel
import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController";
import { readRoute, writeRoute, type AppRoute } from "../route";
import { readSettingsSection, writeSettingsSection, type SettingsSection } from "../settingsRoute";
import { applyShortcutPreferences } from "../shortcutPreferences";
import { applyActiveShortcutPreferences } from "../shortcutPreferences";
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionRunFilter } from "../workspaceDeletion";
import "./MachineList";
@@ -184,7 +184,8 @@ export class PiWebApp extends LitElement {
}
private readonly onKeyDown = (event: KeyboardEvent) => {
if (this.keyboard.handle(event, this.getActions())) {
if (this.settingsSection !== undefined) return;
if (this.keyboard.handle(event, this.getDefaultActions(), { shortcuts: this.shortcutConfig })) {
event.preventDefault();
event.stopPropagation();
}
@@ -994,7 +995,11 @@ export class PiWebApp extends LitElement {
}
private getActions(): AppAction[] {
return applyShortcutPreferences([...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.navigationFocusActions()], this.shortcutConfig);
return applyActiveShortcutPreferences(this.getDefaultActions(), this.shortcutConfig);
}
private getDefaultActions(): AppAction[] {
return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.navigationFocusActions()];
}
private navigationFocusActions(): AppAction[] {
@@ -1457,7 +1462,7 @@ export class PiWebApp extends LitElement {
${state.projectDialogOpen ? html`<project-dialog .machineId=${selectedMachineId(state)} .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
</div>
`;
}
+12 -1
View File
@@ -61,7 +61,18 @@ export class SettingsDialog extends LitElement {
private renderActiveSection(): TemplateResult {
if (this.section === "shortcuts") {
return html`<settings-shortcuts-panel .actions=${this.actions} .configResponse=${this.configResponse}></settings-shortcuts-panel>`;
return html`
<settings-shortcuts-panel
.actions=${this.actions}
.configResponse=${this.configResponse}
.loading=${this.loading}
.saving=${this.saving}
.error=${this.error}
.savedMessage=${this.savedMessage}
.onReload=${() => this.loadConfig()}
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
></settings-shortcuts-panel>
`;
}
if (this.section === "plugins") {
return html`
@@ -1,55 +1,283 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { AppAction } from "../../actions";
import type { PiWebConfigResponse, PiWebShortcutConfig } from "../../api";
import { formatShortcut } from "../../keyboardShortcuts";
import type { PiWebConfigResponse, PiWebConfigValues, PiWebShortcutConfig } from "../../api";
import { formatShortcut, isShortcutSequenceStarter, parseShortcutInput, resolveShortcutBindings, shortcutSequenceTimeoutMs, shortcutTokenFromEvent, type ShortcutBindingResolution } from "../../keyboardShortcuts";
const RECORD_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
@customElement("settings-shortcuts-panel")
export class SettingsShortcutsPanel extends LitElement {
@property({ attribute: false }) actions: AppAction[] = [];
@property({ attribute: false }) configResponse: PiWebConfigResponse | undefined;
@property({ type: Boolean }) loading = false;
@property({ type: Boolean }) saving = false;
@property() error = "";
@property() savedMessage = "";
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
@state() private drafts: Record<string, string> = {};
@state() private localError = "";
@state() private recording: RecordingState | undefined;
private recordingTimer: number | undefined;
private recordingListenerActive = false;
private readonly onRecordKeyDown = (event: KeyboardEvent): void => {
const recording = this.recording;
if (recording === undefined) return;
event.preventDefault();
event.stopPropagation();
if (event.key === "Escape") {
this.stopRecording();
return;
}
const token = shortcutTokenFromEvent(event);
if (token === undefined) {
this.localError = "Press a letter, number, punctuation, function, or navigation key. Press Esc to cancel recording.";
return;
}
if (recording.tokens.length === 0 && !isShortcutSequenceStarter(token)) {
this.localError = "Start shortcuts with Ctrl/⌘ or Alt so normal typing is not captured.";
return;
}
const tokens = [...recording.tokens, token];
this.localError = "";
this.drafts = { [recording.actionId]: tokens.join(" ") };
this.recording = { actionId: recording.actionId, tokens };
this.armRecordingTimer();
};
protected override willUpdate(changed: PropertyValues<this>): void {
if (changed.has("configResponse") && this.configResponse !== undefined) {
this.drafts = {};
this.localError = "";
this.stopRecording();
}
}
override disconnectedCallback(): void {
this.stopRecording();
super.disconnectedCallback();
}
override render(): TemplateResult {
const groups = shortcutGroups(this.actions);
const shortcutResolutions = this.shortcutResolutions();
return html`
<div class="section-heading">
<div>
<h2>Keyboard shortcuts</h2>
<p>Review registered app actions and the shortcut config that will become editable here. Manual config entries use action ids and can override a default shortcut or set it to <code>null</code> to disable it.</p>
<p>Edit app shortcuts by action. Type a shortcut such as <code>mod+k</code> or <code>mod+g p</code>, record one from the keyboard, disable it with None, or reset it to the default. When shortcuts conflict, custom shortcuts win before defaults; ties are resolved by action id, and shorter shortcuts shadow longer sequences with the same prefix.</p>
</div>
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
<div class="shortcut-note">Config key: <code>shortcuts</code>. Example: <code>{ "core:view.chat": "mod+1", "core:session.stop": null }</code></div>
${groups.length === 0 ? html`<div class="loading-card">No actions registered.</div>` : groups.map((group) => html`
<section class="shortcut-group">
<h3>${group.name}</h3>
<div class="shortcut-list">
${group.actions.map((action) => this.renderShortcutRow(action))}
</div>
</section>
`)}
${this.renderMessages()}
${this.configResponse === undefined && this.loading ? html`<div class="loading-card">Loading shortcuts…</div>` : html`
<div class="config-path-card">
<span>Config file</span>
<code>${this.configResponse?.path ?? "Unknown"}</code>
<small>Shortcut overrides are saved under <code>shortcuts</code>. A value of <code>null</code> disables the action shortcut.</small>
</div>
${groups.length === 0 ? html`<div class="loading-card">No actions registered.</div>` : groups.map((group) => html`
<section class="shortcut-group">
<h3>${group.name}</h3>
<div class="shortcut-list">
${group.actions.map((action) => this.renderShortcutRow(action, shortcutResolutions.get(action.id)))}
</div>
</section>
`)}
`}
`;
}
private renderShortcutRow(action: AppAction): TemplateResult {
private renderMessages(): TemplateResult | null {
const error = this.localError || this.error;
if (error !== "") return html`<div class="message error-message">${error}</div>`;
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`;
return null;
}
private renderShortcutRow(action: AppAction, resolution: ShortcutBindingResolution | undefined): TemplateResult {
const shortcuts = this.configResponse?.config.shortcuts;
const configured = shortcutPreference(action.id, shortcuts);
const shortcut = configured === null ? undefined : configured ?? action.shortcut;
const state = shortcutState(action, shortcuts);
const inputText = this.shortcutInputText(action);
const parsedInput = inputText.trim() === "" ? undefined : parseShortcutInput(inputText);
const previewShortcut = parsedInput?.ok === true ? parsedInput.shortcut : effectiveShortcut(action, shortcuts);
const hasConfiguredShortcut = configured !== undefined;
const hasDraft = this.drafts[action.id] !== undefined;
const displayState = hasDraft && inputText.trim() !== "" ? "custom" : state;
const recordingHint = this.recordingHint(action.id);
const conflictLabel = shortcutConflictLabel(resolution);
return html`
<div class="shortcut-row">
<article class=${shortcutRowClass(resolution)}>
<div class="shortcut-main">
<strong>${action.title}</strong>
${action.description !== undefined && action.description !== "" ? html`<small>${action.description}</small>` : null}
<small class="shortcut-id">${action.id}</small>
<small>${action.shortcut !== undefined && action.shortcut !== "" ? html`Default: <kbd>${formatShortcut(action.shortcut)}</kbd>` : "No default shortcut"}</small>
</div>
<div class="shortcut-value">
${shortcut !== undefined && shortcut !== "" ? html`<kbd>${formatShortcut(shortcut)}</kbd>` : html`<span class="unassigned">${state === "disabled" ? "Disabled" : "Unassigned"}</span>`}
<small class=${state}>${shortcutStateLabel(state)}</small>
<div class="shortcut-editor">
<div class="shortcut-status">
${previewShortcut !== undefined && previewShortcut !== "" ? html`<kbd>${formatShortcut(previewShortcut)}</kbd>` : html`<span class="unassigned">${state === "disabled" ? "Disabled" : "Unassigned"}</span>`}
<small class=${displayState}>${shortcutStateLabel(displayState)}${hasDraft ? " · Unsaved" : ""}</small>
${conflictLabel === undefined ? null : html`<small class=${shortcutConflictClass(resolution)}>${conflictLabel}</small>`}
</div>
<label class="shortcut-input-label">
<span>Shortcut</span>
<input
class="shortcut-input"
data-action-id=${action.id}
.value=${inputText}
placeholder=${action.shortcut ?? "mod+k"}
autocomplete="off"
autocapitalize="off"
spellcheck="false"
?disabled=${this.saving}
@input=${(event: Event) => { this.updateDraft(action.id, inputValue(event)); }}
>
</label>
${recordingHint !== "" ? html`<small class="recording-hint">${recordingHint}</small>` : null}
<div class="shortcut-actions">
<button class="primary" ?disabled=${this.loading || this.saving || !hasDraft || inputText.trim() === ""} @click=${() => { void this.saveShortcut(action); }}>Save</button>
<button ?disabled=${this.loading || this.saving} @click=${() => { void this.toggleRecording(action.id); }}>${this.recording?.actionId === action.id ? "Cancel recording" : "Record"}</button>
<button ?disabled=${this.loading || this.saving || configured === null} @click=${() => { void this.setShortcutNone(action.id); }}>None</button>
<button ?disabled=${this.loading || this.saving || !hasConfiguredShortcut} @click=${() => { void this.resetShortcut(action.id); }}>Reset</button>
</div>
</div>
</div>
</article>
`;
}
private shortcutInputText(action: AppAction): string {
const draft = this.drafts[action.id];
if (draft !== undefined) return draft;
const configured = shortcutPreference(action.id, this.configResponse?.config.shortcuts);
if (configured === null) return "";
return configured ?? action.shortcut ?? "";
}
private recordingHint(actionId: string): string {
const recording = this.recording;
if (recording?.actionId !== actionId) return "";
if (recording.tokens.length === 0) return "Recording: press Ctrl/⌘ or Alt with a key. Press Esc to cancel.";
return `Recording: ${formatShortcut(recording.tokens.join(" "))}. Press another key to add a sequence, or wait to finish.`;
}
private updateDraft(actionId: string, value: string): void {
this.drafts = { [actionId]: value };
this.localError = "";
}
private async saveShortcut(action: AppAction): Promise<void> {
this.stopRecording();
const input = this.shortcutInputText(action).trim();
const parsed = parseShortcutInput(input);
if (!parsed.ok) {
this.localError = parsed.message;
return;
}
this.localError = "";
await this.saveShortcutPreference(action.id, parsed.shortcut);
}
private async setShortcutNone(actionId: string): Promise<void> {
this.stopRecording();
this.localError = "";
await this.saveShortcutPreference(actionId, null);
}
private async resetShortcut(actionId: string): Promise<void> {
this.stopRecording();
this.localError = "";
await this.saveShortcutPreference(actionId, undefined);
}
private async saveShortcutPreference(actionId: string, shortcut: string | null | undefined): Promise<void> {
const config: PiWebConfigValues = { ...(this.configResponse?.config ?? {}) };
const currentShortcuts = config.shortcuts ?? {};
const shortcuts = shortcut === undefined ? withoutShortcutPreference(currentShortcuts, actionId) : { ...currentShortcuts, [actionId]: shortcut };
if (Object.keys(shortcuts).length === 0) {
delete config.shortcuts;
} else {
config.shortcuts = shortcuts;
}
await this.onSave?.(config);
}
private shortcutResolutions(): Map<string, ShortcutBindingResolution> {
return new Map(resolveShortcutBindings(this.actions, this.previewShortcutConfig(), { enabledOnly: true }).map((resolution) => [resolution.action.id, resolution]));
}
private previewShortcutConfig(): PiWebShortcutConfig | undefined {
const shortcuts = { ...(this.configResponse?.config.shortcuts ?? {}) };
for (const [actionId, draft] of Object.entries(this.drafts)) {
const trimmedDraft = draft.trim();
if (trimmedDraft === "") continue;
const parsed = parseShortcutInput(trimmedDraft);
if (parsed.ok) shortcuts[actionId] = parsed.shortcut;
}
return Object.keys(shortcuts).length === 0 ? undefined : shortcuts;
}
private async toggleRecording(actionId: string): Promise<void> {
if (this.recording?.actionId === actionId) {
this.stopRecording();
return;
}
this.stopRecording();
this.localError = "";
this.recording = { actionId, tokens: [] };
this.ensureRecordingListener();
await this.updateComplete;
this.focusShortcutInput(actionId);
}
private focusShortcutInput(actionId: string): void {
for (const input of this.renderRoot.querySelectorAll<HTMLInputElement>(".shortcut-input")) {
if (input.dataset["actionId"] === actionId) {
input.focus();
input.select();
return;
}
}
}
private armRecordingTimer(): void {
this.clearRecordingTimer();
this.recordingTimer = window.setTimeout(() => {
this.recordingTimer = undefined;
this.stopRecording();
}, shortcutSequenceTimeoutMs);
}
private stopRecording(): void {
this.clearRecordingTimer();
this.removeRecordingListener();
this.recording = undefined;
}
private clearRecordingTimer(): void {
if (this.recordingTimer === undefined) return;
window.clearTimeout(this.recordingTimer);
this.recordingTimer = undefined;
}
private ensureRecordingListener(): void {
if (this.recordingListenerActive) return;
window.addEventListener("keydown", this.onRecordKeyDown, RECORD_SHORTCUT_LISTENER_OPTIONS);
this.recordingListenerActive = true;
}
private removeRecordingListener(): void {
if (!this.recordingListenerActive) return;
window.removeEventListener("keydown", this.onRecordKeyDown, RECORD_SHORTCUT_LISTENER_OPTIONS);
this.recordingListenerActive = false;
}
static override styles = css`
:host { display: block; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
@@ -58,37 +286,83 @@ export class SettingsShortcutsPanel extends LitElement {
h2 { font-size: 17px; line-height: 1.25; }
h3 { font-size: 13px; line-height: 1.3; }
p { color: var(--pi-muted); line-height: 1.45; }
button, input { font: inherit; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
button:disabled, input:disabled { opacity: .55; cursor: not-allowed; }
.primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); }
.secondary { flex: 0 0 auto; }
.message, .loading-card, .config-path-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.message { margin-bottom: 12px; }
.error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
.success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
.loading-card, .config-path-card { color: var(--pi-muted); }
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; }
.config-path-card span { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
.loading-card, .shortcut-note { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.loading-card, .shortcut-note { color: var(--pi-muted); }
.shortcut-note { margin-bottom: 14px; }
.shortcut-group { margin: 0 0 16px; }
.shortcut-group h3 { margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
.shortcut-list { border: 1px solid var(--pi-border); border-radius: 10px; overflow: hidden; }
.shortcut-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-surface); }
.shortcut-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 48%); gap: 14px; align-items: start; padding: 12px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-surface); }
.shortcut-row.shadowed { background: color-mix(in srgb, var(--pi-warning) 5%, var(--pi-surface)); }
.shortcut-row.shadowing { background: color-mix(in srgb, var(--pi-accent) 5%, var(--pi-surface)); }
.shortcut-row:last-child { border-bottom: 0; }
.shortcut-main { min-width: 0; display: grid; gap: 3px; }
.shortcut-main { min-width: 0; display: grid; gap: 4px; }
.shortcut-main strong, .shortcut-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shortcut-main small { color: var(--pi-muted); }
.shortcut-id { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.shortcut-value { justify-self: end; display: grid; justify-items: end; gap: 3px; }
kbd { justify-self: end; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); padding: 3px 7px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
.unassigned { justify-self: end; color: var(--pi-muted); font-size: 12px; }
.shortcut-value small { color: var(--pi-muted); font-size: 11px; }
.shortcut-value small.custom { color: var(--pi-accent); }
.shortcut-value small.disabled { color: var(--pi-warning); }
.shortcut-editor { min-width: 0; display: grid; gap: 8px; }
.shortcut-status { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.shortcut-status small { color: var(--pi-muted); font-size: 11px; }
.shortcut-status small.custom { color: var(--pi-accent); }
.shortcut-status small.disabled { color: var(--pi-warning); }
.shortcut-status small.conflict { border: 1px solid currentColor; border-radius: 999px; padding: 2px 7px; }
.shortcut-status small.conflict.shadowing { color: var(--pi-accent); }
.shortcut-status small.conflict.shadowed { color: var(--pi-warning); }
.shortcut-input-label { min-width: 0; display: grid; gap: 5px; }
.shortcut-input-label span { color: var(--pi-muted); font-size: 11px; font-weight: 700; text-transform: uppercase; }
input { box-sizing: border-box; width: 100%; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; outline: none; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
input:focus { border-color: var(--pi-accent); box-shadow: 0 0 0 1px var(--pi-accent-border); }
.shortcut-actions { display: flex; justify-content: flex-end; gap: 7px; flex-wrap: wrap; }
kbd { border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); padding: 3px 7px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
.unassigned { color: var(--pi-muted); font-size: 12px; }
.recording-hint { color: var(--pi-accent); font-size: 12px; }
@media (max-width: 760px) {
.section-heading { display: grid; gap: 12px; }
.section-heading .secondary { justify-self: start; }
.shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; }
.shortcut-value { justify-self: start; justify-items: start; }
kbd, .unassigned { justify-self: start; }
.shortcut-status, .shortcut-actions { justify-content: flex-start; }
}
`;
}
interface RecordingState {
actionId: string;
tokens: string[];
}
type ShortcutState = "default" | "custom" | "disabled" | "unassigned";
function shortcutRowClass(resolution: ShortcutBindingResolution | undefined): string {
if (resolution?.active === false) return "shortcut-row shadowed";
if (resolution?.active === true && resolution.shadows.length > 0) return "shortcut-row shadowing";
return "shortcut-row";
}
function shortcutConflictClass(resolution: ShortcutBindingResolution | undefined): string {
return resolution?.active === false ? "conflict shadowed" : "conflict shadowing";
}
function shortcutConflictLabel(resolution: ShortcutBindingResolution | undefined): string | undefined {
if (resolution === undefined) return undefined;
if (!resolution.active) return `Shadowed by ${resolution.shadowedBy?.action.title ?? "another action"}`;
const shadowedCount = resolution.shadows.length;
if (shadowedCount === 0) return undefined;
const shadowedNames = resolution.shadows.slice(0, 2).map((binding) => binding.action.title).join(", ");
const suffix = shadowedCount > 2 ? `, +${String(shadowedCount - 2)} more` : "";
return `Shadows ${String(shadowedCount)} ${shadowedCount === 1 ? "action" : "actions"}: ${shadowedNames}${suffix}`;
}
function shortcutGroups(actions: AppAction[]): { name: string; actions: AppAction[] }[] {
const grouped = new Map<string, AppAction[]>();
for (const action of [...actions].sort(compareActions)) {
@@ -107,6 +381,16 @@ function shortcutPreference(actionId: string, shortcuts: PiWebShortcutConfig | u
return shortcuts[actionId];
}
function withoutShortcutPreference(shortcuts: PiWebShortcutConfig, actionId: string): PiWebShortcutConfig {
return Object.fromEntries(Object.entries(shortcuts).filter(([shortcutActionId]) => shortcutActionId !== actionId));
}
function effectiveShortcut(action: AppAction, shortcuts: PiWebShortcutConfig | undefined): string | undefined {
const configured = shortcutPreference(action.id, shortcuts);
if (configured === null) return undefined;
return configured ?? action.shortcut;
}
function shortcutState(action: AppAction, shortcuts: PiWebShortcutConfig | undefined): ShortcutState {
const configured = shortcutPreference(action.id, shortcuts);
if (configured === null) return "disabled";
@@ -117,8 +401,12 @@ function shortcutState(action: AppAction, shortcuts: PiWebShortcutConfig | undef
function shortcutStateLabel(state: ShortcutState): string {
switch (state) {
case "default": return "Default";
case "custom": return "Config override";
case "disabled": return "Config disabled";
case "custom": return "Custom";
case "disabled": return "Disabled";
case "unassigned": return "No default";
}
}
function inputValue(event: Event): string {
return event.target instanceof HTMLInputElement ? event.target.value : "";
}
+110 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { AppAction } from "./actions";
import { KeyboardShortcutDispatcher, type ShortcutKeyEvent } from "./keyboardShortcuts";
import { KeyboardShortcutDispatcher, parseShortcutInput, resolveShortcutBindings, shortcutTokenFromEvent, type ShortcutKeyEvent } from "./keyboardShortcuts";
function keyEvent(key: string, modifiers: Partial<ShortcutKeyEvent> = {}): ShortcutKeyEvent {
return {
@@ -16,10 +16,14 @@ function keyEvent(key: string, modifiers: Partial<ShortcutKeyEvent> = {}): Short
}
function action(shortcut: string, enabled = true) {
return actionWithId(shortcut, shortcut, enabled);
}
function actionWithId(id: string, shortcut: string, enabled = true) {
const run = vi.fn();
const value: AppAction = {
id: shortcut,
title: shortcut,
id,
title: id,
shortcut,
enabled,
run,
@@ -48,6 +52,26 @@ describe("KeyboardShortcutDispatcher", () => {
expect(run).not.toHaveBeenCalled();
});
it("matches manually typed Ctrl shortcuts as the cross-platform Mod modifier", () => {
const dispatcher = new KeyboardShortcutDispatcher();
const { value, run } = action("ctrl+k");
const handled = dispatcher.handle(keyEvent("k", { ctrlKey: true }), [value]);
expect(handled).toBe(true);
expect(run).toHaveBeenCalledTimes(1);
});
it("ignores shift-only shortcuts so capitalized typing is never captured", () => {
const dispatcher = new KeyboardShortcutDispatcher();
const { value, run } = action("shift+r");
const handled = dispatcher.handle(keyEvent("r", { shiftKey: true }), [value]);
expect(handled).toBe(false);
expect(run).not.toHaveBeenCalled();
});
it("ignores disabled matching shortcuts", () => {
const dispatcher = new KeyboardShortcutDispatcher();
const { value, run } = action("mod+enter", false);
@@ -77,6 +101,42 @@ describe("KeyboardShortcutDispatcher", () => {
expect(run).toHaveBeenCalledTimes(1);
});
it("deterministically runs the lowest action id when default shortcuts conflict", () => {
const dispatcher = new KeyboardShortcutDispatcher();
const later = actionWithId("plugin:z", "mod+k");
const earlier = actionWithId("plugin:a", "mod+k");
const handled = dispatcher.handle(keyEvent("k", { ctrlKey: true }), [later.value, earlier.value]);
expect(handled).toBe(true);
expect(earlier.run).toHaveBeenCalledTimes(1);
expect(later.run).not.toHaveBeenCalled();
});
it("runs custom shortcut winners before default shortcut conflicts", () => {
const dispatcher = new KeyboardShortcutDispatcher();
const defaultAction = actionWithId("plugin:a", "mod+j");
const customAction = actionWithId("plugin:z", "mod+k");
const handled = dispatcher.handle(keyEvent("j", { ctrlKey: true }), [defaultAction.value, customAction.value], { shortcuts: { "plugin:z": "mod+j" } });
expect(handled).toBe(true);
expect(customAction.run).toHaveBeenCalledTimes(1);
expect(defaultAction.run).not.toHaveBeenCalled();
});
it("uses the same shadowing rules when a standalone shortcut is a sequence prefix", () => {
const dispatcher = new KeyboardShortcutDispatcher();
const standalone = actionWithId("plugin:standalone", "mod+g");
const sequence = actionWithId("plugin:sequence", "mod+g p");
const handled = dispatcher.handle(keyEvent("g", { ctrlKey: true }), [standalone.value, sequence.value]);
expect(handled).toBe(true);
expect(standalone.run).toHaveBeenCalledTimes(1);
expect(sequence.run).not.toHaveBeenCalled();
});
it("falls back to a standalone modified shortcut when a pending sequence misses", () => {
const dispatcher = new KeyboardShortcutDispatcher();
const sequence = action("mod+g p");
@@ -88,3 +148,50 @@ describe("KeyboardShortcutDispatcher", () => {
expect(standalone.run).toHaveBeenCalledTimes(1);
});
});
describe("shortcut conflict resolution", () => {
it("reports which duplicate bindings shadow and which are shadowed", () => {
const defaultAction = actionWithId("plugin:a", "mod+k");
const customAction = actionWithId("plugin:z", "mod+j");
const resolutions = resolveShortcutBindings([defaultAction.value, customAction.value], { "plugin:z": "mod+k" });
const defaultResolution = resolutions.find((resolution) => resolution.action.id === "plugin:a");
const customResolution = resolutions.find((resolution) => resolution.action.id === "plugin:z");
expect(customResolution?.active).toBe(true);
expect(customResolution?.shadows.map((binding) => binding.action.id)).toEqual(["plugin:a"]);
expect(defaultResolution?.active).toBe(false);
expect(defaultResolution?.shadowedBy?.action.id).toBe("plugin:z");
});
it("reports sequence bindings shadowed by shorter shortcut prefixes", () => {
const standalone = actionWithId("plugin:standalone", "mod+g");
const sequence = actionWithId("plugin:sequence", "mod+g p");
const resolutions = resolveShortcutBindings([sequence.value, standalone.value]);
const standaloneResolution = resolutions.find((resolution) => resolution.action.id === "plugin:standalone");
const sequenceResolution = resolutions.find((resolution) => resolution.action.id === "plugin:sequence");
expect(standaloneResolution?.active).toBe(true);
expect(standaloneResolution?.shadows.map((binding) => binding.action.id)).toEqual(["plugin:sequence"]);
expect(sequenceResolution?.active).toBe(false);
expect(sequenceResolution?.shadowedBy?.action.id).toBe("plugin:standalone");
});
});
describe("shortcut input parsing", () => {
it("normalizes manually typed shortcuts", () => {
expect(parseShortcutInput("Ctrl + Shift + K")).toEqual({ ok: true, shortcut: "mod+shift+k", tokens: ["mod+shift+k"] });
expect(parseShortcutInput("cmd+g p")).toEqual({ ok: true, shortcut: "mod+g p", tokens: ["mod+g", "p"] });
});
it("rejects shortcuts that would capture normal typing", () => {
expect(parseShortcutInput("r")).toEqual({ ok: false, message: "Shortcuts must start with Ctrl/⌘ or Alt so normal typing is not captured." });
expect(parseShortcutInput("shift+r")).toEqual({ ok: false, message: "Shortcuts must start with Ctrl/⌘ or Alt so normal typing is not captured." });
});
it("builds canonical tokens from recorded key events", () => {
expect(shortcutTokenFromEvent(keyEvent("K", { metaKey: true, shiftKey: true }))).toBe("mod+shift+k");
expect(shortcutTokenFromEvent(keyEvent("ArrowDown", { altKey: true }))).toBe("alt+arrowdown");
});
});
+311 -36
View File
@@ -1,6 +1,12 @@
import type { AppAction } from "./actions";
const sequenceTimeoutMs = 1200;
export const shortcutSequenceTimeoutMs = 1200;
const modifierOrder = ["mod", "alt", "shift"] as const;
type ShortcutModifier = typeof modifierOrder[number];
export type ShortcutPreferenceConfig = Record<string, string | null>;
export type ShortcutBindingSource = "default" | "custom";
export interface ShortcutKeyEvent {
key: string;
@@ -12,25 +18,49 @@ export interface ShortcutKeyEvent {
target: EventTarget | null;
}
export type ShortcutParseResult =
| { ok: true; shortcut: string; tokens: string[] }
| { ok: false; message: string };
export interface ShortcutBindingSummary {
action: AppAction;
shortcut: string;
source: ShortcutBindingSource;
}
export interface ShortcutBindingResolution extends ShortcutBindingSummary {
tokens: string[];
key: string;
order: number;
active: boolean;
shadows: ShortcutBindingSummary[];
shadowedBy?: ShortcutBindingSummary;
}
interface ShortcutBinding extends ShortcutBindingSummary {
tokens: string[];
key: string;
order: number;
}
export class KeyboardShortcutDispatcher {
private pendingTokens: string[] = [];
private pendingTimer: ReturnType<typeof setTimeout> | undefined;
handle(event: ShortcutKeyEvent, actions: AppAction[]): boolean {
const token = eventToken(event);
handle(event: ShortcutKeyEvent, actions: AppAction[], options: { shortcuts?: ShortcutPreferenceConfig } = {}): boolean {
const token = shortcutTokenFromEvent(event);
if (token === undefined) return false;
const shortcuts = actions
.filter((action) => action.shortcut !== undefined && action.enabled !== false)
.map((action) => ({ action, tokens: normalizeShortcut(action.shortcut ?? "") }))
.filter((entry) => entry.tokens.length > 0);
const shortcuts = resolveShortcutBindings(actions, options.shortcuts, { enabledOnly: true })
.filter((binding) => binding.active)
.map((binding) => ({ action: binding.action, tokens: binding.tokens }));
if (this.pendingTokens.length > 0) {
const handledPending = this.handleSequence([...this.pendingTokens, token], shortcuts);
if (handledPending) return true;
this.clearPending();
if (!isModifiedShortcut(token)) return false;
} else if (!isModifiedShortcut(token)) return false;
if (!isShortcutSequenceStarter(token)) return false;
} else if (!isShortcutSequenceStarter(token)) return false;
return this.handleSequence([token], shortcuts);
}
@@ -62,7 +92,7 @@ export class KeyboardShortcutDispatcher {
this.pendingTimer = globalThis.setTimeout(() => {
this.pendingTokens = [];
this.pendingTimer = undefined;
}, sequenceTimeoutMs);
}, shortcutSequenceTimeoutMs);
}
private clearPending(): void {
@@ -74,25 +104,67 @@ export class KeyboardShortcutDispatcher {
}
}
export function resolveShortcutBindings(actions: AppAction[], shortcuts?: ShortcutPreferenceConfig, options: { enabledOnly?: boolean } = {}): ShortcutBindingResolution[] {
const bindings = actions.flatMap((action, order) => {
if (options.enabledOnly === true && action.enabled === false) return [];
const binding = shortcutBindingForAction(action, shortcuts, order);
return binding === undefined ? [] : [binding];
});
const bindingsByKey = new Map<string, ShortcutBinding[]>();
for (const binding of bindings) {
bindingsByKey.set(binding.key, [...(bindingsByKey.get(binding.key) ?? []), binding]);
}
const exactWinnersByKey = new Map<string, ShortcutBinding>();
for (const conflictSet of bindingsByKey.values()) {
const winner = [...conflictSet].sort(compareShortcutBindings)[0];
if (winner !== undefined) exactWinnersByKey.set(winner.key, winner);
}
const exactWinners = [...exactWinnersByKey.values()].sort(compareShortcutPrefixCandidates);
const shadowsByWinner = new Map<ShortcutBinding, ShortcutBinding[]>();
const winnerByBinding = new Map<ShortcutBinding, ShortcutBinding>();
for (const binding of bindings) {
const exactWinner = exactWinnersByKey.get(binding.key);
if (exactWinner === undefined) continue;
const winner = prefixWinnerFor(exactWinner, exactWinners) ?? exactWinner;
winnerByBinding.set(binding, winner);
if (binding !== winner) shadowsByWinner.set(winner, [...(shadowsByWinner.get(winner) ?? []), binding]);
}
return bindings.map((binding) => {
const winner = winnerByBinding.get(binding);
const active = winner === binding;
const shadowedBy = winner === undefined || active ? undefined : shortcutBindingSummary(winner);
const shadows = active ? [...(shadowsByWinner.get(binding) ?? [])].sort(compareShortcutBindings).map(shortcutBindingSummary) : [];
return {
...binding,
active,
shadows,
...(shadowedBy === undefined ? {} : { shadowedBy }),
};
}).sort((left, right) => left.order - right.order);
}
export function parseShortcutInput(shortcut: string): ShortcutParseResult {
return parseShortcut(shortcut, { requireFirstChordActivator: true });
}
export function normalizeShortcut(shortcut: string): string[] {
const parsed = parseShortcut(shortcut, { requireFirstChordActivator: false });
return parsed.ok ? parsed.tokens : [];
}
export function formatShortcut(shortcut: string): string {
return normalizeShortcut(shortcut)
.map((token) => token
.split("+")
.map((part) => {
if (part === "mod") return isMac() ? "⌘" : "Ctrl";
if (part === "shift") return "Shift";
if (part === "alt") return isMac() ? "⌥" : "Alt";
if (part === "ctrl") return "Ctrl";
if (part === "enter") return "Enter";
if (part === "escape") return "Esc";
if (part === ".") return ".";
return part.length === 1 ? part.toUpperCase() : `${part.charAt(0).toUpperCase()}${part.slice(1)}`;
})
.map(formatShortcutPart)
.join("+"))
.join(" ");
}
function eventToken(event: ShortcutKeyEvent): string | undefined {
export function shortcutTokenFromEvent(event: ShortcutKeyEvent): string | undefined {
if (event.isComposing) return undefined;
const key = normalizeKey(event.key);
if (key === undefined) return undefined;
@@ -104,21 +176,228 @@ function eventToken(event: ShortcutKeyEvent): string | undefined {
return modifiers.join("+");
}
function normalizeShortcut(shortcut: string): string[] {
return shortcut
.trim()
.toLowerCase()
.split(/\s+/u)
.filter((token) => token !== "")
.map((token) => token.split("+").filter((part) => part !== "").join("+"));
export function isShortcutSequenceStarter(token: string): boolean {
return token.split("+").includes("mod") || token.split("+").includes("alt");
}
function shortcutBindingForAction(action: AppAction, shortcuts: ShortcutPreferenceConfig | undefined, order: number): ShortcutBinding | undefined {
const configured = shortcutPreference(action.id, shortcuts);
if (configured === null) return undefined;
const shortcut = configured ?? action.shortcut;
if (shortcut === undefined || shortcut === "") return undefined;
const tokens = normalizeShortcut(shortcut);
const firstToken = tokens[0];
if (firstToken === undefined || !isShortcutSequenceStarter(firstToken)) return undefined;
return {
action,
shortcut: tokens.join(" "),
source: configured === undefined ? "default" : "custom",
tokens,
key: shortcutBindingKey(tokens),
order,
};
}
function shortcutPreference(actionId: string, shortcuts: ShortcutPreferenceConfig | undefined): string | null | undefined {
if (shortcuts === undefined || !Object.hasOwn(shortcuts, actionId)) return undefined;
return shortcuts[actionId];
}
function shortcutBindingKey(tokens: string[]): string {
return tokens.join("\u0000");
}
function shortcutBindingSummary(binding: ShortcutBinding): ShortcutBindingSummary {
return { action: binding.action, shortcut: binding.shortcut, source: binding.source };
}
function compareShortcutBindings(left: ShortcutBinding, right: ShortcutBinding): number {
return shortcutSourceRank(left.source) - shortcutSourceRank(right.source)
|| compareStrings(left.action.id, right.action.id)
|| compareStrings(left.action.title, right.action.title)
|| left.order - right.order;
}
function compareShortcutPrefixCandidates(left: ShortcutBinding, right: ShortcutBinding): number {
return left.tokens.length - right.tokens.length || compareShortcutBindings(left, right);
}
function prefixWinnerFor(binding: ShortcutBinding, exactWinners: ShortcutBinding[]): ShortcutBinding | undefined {
return exactWinners.find((candidate) => candidate !== binding && candidate.tokens.length < binding.tokens.length && startsWithTokens(binding.tokens, candidate.tokens));
}
function shortcutSourceRank(source: ShortcutBindingSource): number {
switch (source) {
case "custom": return 0;
case "default": return 1;
}
}
function compareStrings(left: string, right: string): number {
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
function parseShortcut(shortcut: string, options: { requireFirstChordActivator: boolean }): ShortcutParseResult {
const cleaned = shortcut.trim().toLowerCase().replace(/\s*\+\s*/gu, "+");
if (cleaned === "") return { ok: false, message: "Enter a shortcut, choose None, or reset to the default." };
const tokens: string[] = [];
const chordInputs = cleaned.split(/\s+/u).filter((token) => token !== "");
for (const [index, chordInput] of chordInputs.entries()) {
const parsed = parseShortcutChord(chordInput);
if (!parsed.ok) return parsed;
if (index === 0 && options.requireFirstChordActivator && !isShortcutSequenceStarter(parsed.token)) {
return { ok: false, message: "Shortcuts must start with Ctrl/⌘ or Alt so normal typing is not captured." };
}
tokens.push(parsed.token);
}
return { ok: true, shortcut: tokens.join(" "), tokens };
}
type ShortcutChordParseResult =
| { ok: true; token: string }
| { ok: false; message: string };
function parseShortcutChord(chord: string): ShortcutChordParseResult {
const parts = chord.split("+").filter((part) => part !== "");
if (parts.length === 0) return { ok: false, message: "Shortcut chords must include a key." };
const modifiers = new Set<ShortcutModifier>();
let key: string | undefined;
for (const part of parts) {
const modifier = modifierAlias(part);
if (modifier !== undefined) {
if (modifiers.has(modifier)) return { ok: false, message: `Shortcut has duplicate ${formatShortcutPart(modifier)} modifiers.` };
modifiers.add(modifier);
continue;
}
const normalizedKey = normalizeShortcutKeyName(part);
if (normalizedKey === undefined) return { ok: false, message: `Unsupported shortcut key: ${part}` };
if (key !== undefined) return { ok: false, message: "Each shortcut chord can include only one non-modifier key." };
key = normalizedKey;
}
if (key === undefined) return { ok: false, message: "Shortcut chords must include a key." };
const orderedModifiers = modifierOrder.filter((modifier) => modifiers.has(modifier));
return { ok: true, token: [...orderedModifiers, key].join("+") };
}
function modifierAlias(part: string): ShortcutModifier | undefined {
switch (part) {
case "mod":
case "meta":
case "cmd":
case "command":
case "ctrl":
case "control":
case "primary":
return "mod";
case "alt":
case "option":
case "opt":
return "alt";
case "shift":
return "shift";
default:
return undefined;
}
}
function normalizeShortcutKeyName(key: string): string | undefined {
const alias = keyAlias(key);
if (alias !== undefined) return alias;
if (/^f(?:[1-9]|1[0-9]|2[0-4])$/u.test(key)) return key;
if (key.length === 1) return key;
return undefined;
}
function normalizeKey(key: string): string | undefined {
if (key === " ") return "space";
if (key.length === 1) return key.toLowerCase();
const normalized = key.toLowerCase();
if (["enter", "escape", "tab", "arrowup", "arrowdown", "arrowleft", "arrowright", "backspace", "delete"].includes(normalized)) return normalized;
return undefined;
return normalizeShortcutKeyName(normalized);
}
function keyAlias(key: string): string | undefined {
switch (key) {
case " ":
case "spacebar":
case "space":
return "space";
case "esc":
case "escape":
return "escape";
case "return":
case "enter":
return "enter";
case "del":
case "delete":
return "delete";
case "backspace":
return "backspace";
case "tab":
return "tab";
case "up":
case "arrowup":
return "arrowup";
case "down":
case "arrowdown":
return "arrowdown";
case "left":
case "arrowleft":
return "arrowleft";
case "right":
case "arrowright":
return "arrowright";
case "pageup":
case "pagedown":
case "home":
case "end":
return key;
case "+":
case "plus":
return "plus";
case "period":
case "dot":
return ".";
case "comma":
return ",";
case "slash":
return "/";
case "backslash":
return "\\";
case "minus":
return "-";
default:
return undefined;
}
}
function formatShortcutPart(part: string): string {
if (part === "mod") return isMac() ? "⌘" : "Ctrl";
if (part === "shift") return "Shift";
if (part === "alt") return isMac() ? "⌥" : "Alt";
if (part === "enter") return "Enter";
if (part === "escape") return "Esc";
if (part === "space") return "Space";
if (part === "tab") return "Tab";
if (part === "backspace") return "Backspace";
if (part === "delete") return "Delete";
if (part === "arrowup") return "↑";
if (part === "arrowdown") return "↓";
if (part === "arrowleft") return "←";
if (part === "arrowright") return "→";
if (part === "pageup") return "PageUp";
if (part === "pagedown") return "PageDown";
if (part === "home") return "Home";
if (part === "end") return "End";
if (part === "plus") return "+";
if (/^f(?:[1-9]|1[0-9]|2[0-4])$/u.test(part)) return part.toUpperCase();
return part.length === 1 ? part.toUpperCase() : `${part.charAt(0).toUpperCase()}${part.slice(1)}`;
}
function sameTokens(left: string[], right: string[]): boolean {
@@ -129,10 +408,6 @@ function startsWithTokens(tokens: string[], prefix: string[]): boolean {
return prefix.every((token, index) => tokens[index] === token);
}
function isModifiedShortcut(token: string): boolean {
return token.includes("+");
}
function isMac(): boolean {
return navigator.userAgent.toLowerCase().includes("mac");
return typeof navigator !== "undefined" && navigator.userAgent.toLowerCase().includes("mac");
}
+21 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { AppAction } from "./actions";
import { applyShortcutPreferences } from "./shortcutPreferences";
import { applyActiveShortcutPreferences, applyShortcutPreferences } from "./shortcutPreferences";
const noop = () => undefined;
@@ -26,6 +26,26 @@ describe("shortcut preferences", () => {
action({ id: "core:view.chat" }),
]);
});
it("keeps only active shortcuts when applying preferences for display", () => {
expect(applyActiveShortcutPreferences([
action({ id: "core:z", title: "Later", shortcut: "mod+k" }),
action({ id: "core:a", title: "Earlier", shortcut: "mod+k" }),
], undefined)).toEqual([
action({ id: "core:z", title: "Later" }),
action({ id: "core:a", title: "Earlier", shortcut: "mod+k" }),
]);
});
it("hides default shortcut labels shadowed by user-defined shortcuts", () => {
expect(applyActiveShortcutPreferences([
action({ id: "core:a", title: "Default", shortcut: "mod+1" }),
action({ id: "core:z", title: "Custom", shortcut: "mod+2" }),
], { "core:z": "mod+1" })).toEqual([
action({ id: "core:a", title: "Default" }),
action({ id: "core:z", title: "Custom", shortcut: "mod+1" }),
]);
});
});
function action(patch: Partial<AppAction>): AppAction {
+8
View File
@@ -1,11 +1,19 @@
import type { AppAction } from "./actions";
import type { PiWebShortcutConfig } from "./api";
import { resolveShortcutBindings } from "./keyboardShortcuts";
export function applyShortcutPreferences(actions: AppAction[], shortcuts: PiWebShortcutConfig | undefined): AppAction[] {
if (shortcuts === undefined) return actions;
return actions.map((action) => applyShortcutPreference(action, shortcuts));
}
export function applyActiveShortcutPreferences(actions: AppAction[], shortcuts: PiWebShortcutConfig | undefined): AppAction[] {
const activeShortcutActionIds = new Set(resolveShortcutBindings(actions, shortcuts, { enabledOnly: true })
.filter((binding) => binding.active)
.map((binding) => binding.action.id));
return applyShortcutPreferences(actions, shortcuts).map((action) => action.shortcut !== undefined && !activeShortcutActionIds.has(action.id) ? withoutShortcut(action) : action);
}
export function applyShortcutPreference(action: AppAction, shortcuts: PiWebShortcutConfig): AppAction {
if (!Object.hasOwn(shortcuts, action.id)) return action;
const shortcut = shortcuts[action.id];