Add keyboard action palette

This commit is contained in:
Federico Jaramillo Martinez
2026-05-08 10:58:51 +02:00
parent a98ccf20f9
commit a2d1d5acd8
9 changed files with 556 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
export interface AppAction {
id: string;
title: string;
description?: string;
shortcut?: string;
group?: string;
enabled?: boolean;
run: () => void | Promise<void>;
}
export function enabledActions(actions: AppAction[]): AppAction[] {
return actions.filter((action) => action.enabled !== false);
}
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from "vitest";
import { createAppActions } from "./appActions";
import { initialAppState, type AppState } from "./appState";
function createContext(statePatch: Partial<AppState> = {}) {
const calls: string[] = [];
const context = {
state: { ...initialAppState(), ...statePatch },
openActionPalette: vi.fn(() => { calls.push("openActionPalette"); }),
focusPrompt: vi.fn(() => { calls.push("focusPrompt"); }),
addProject: vi.fn(() => { calls.push("addProject"); }),
selectMainView: vi.fn((view: AppState["mainView"]) => { calls.push(`selectMainView:${view}`); }),
refreshFiles: vi.fn(() => { calls.push("refreshFiles"); }),
refreshGit: vi.fn(() => { calls.push("refreshGit"); }),
startSession: vi.fn(() => { calls.push("startSession"); }),
stopActiveWork: vi.fn(() => { calls.push("stopActiveWork"); }),
};
return { context, calls };
}
describe("createAppActions", () => {
it("disables workspace and session actions when no workspace/session is selected", () => {
const { context } = createContext();
const actions = createAppActions(context);
expect(actions.find((action) => action.id === "view.files")?.enabled).toBe(false);
expect(actions.find((action) => action.id === "session.start")?.enabled).toBe(false);
expect(actions.find((action) => action.id === "session.stop")?.enabled).toBe(false);
expect(actions.find((action) => action.id === "actions.show")?.enabled).toBeUndefined();
});
it("enables workspace actions when a workspace is selected", () => {
const { context } = createContext({ selectedWorkspace: testWorkspace() });
const actions = createAppActions(context);
expect(actions.find((action) => action.id === "view.files")?.enabled).toBe(true);
expect(actions.find((action) => action.id === "session.start")?.enabled).toBe(true);
});
it("routes refresh current to the active workspace tool", () => {
const { context, calls } = createContext({
selectedWorkspace: testWorkspace(),
workspaceTool: "git",
});
const action = createAppActions(context).find((candidate) => candidate.id === "workspace.refresh-current");
if (action !== undefined) void action.run();
expect(calls).toEqual(["refreshGit"]);
});
it("only enables stop while a session is actively working", () => {
const selectedSession = testSession();
const inactive = createAppActions(createContext({ selectedSession }).context);
const active = createAppActions(createContext({ selectedSession, status: testStatus({ isStreaming: true }) }).context);
expect(inactive.find((action) => action.id === "session.stop")?.enabled).toBe(false);
expect(active.find((action) => action.id === "session.stop")?.enabled).toBe(true);
});
});
function testWorkspace(): AppState["selectedWorkspace"] {
return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitWorktree: false };
}
function testSession(): AppState["selectedSession"] {
return { id: "s1", path: "/tmp/project/.pi/sessions/s1", cwd: "/tmp/project", created: "now", modified: "now", messageCount: 0, firstMessage: "" };
}
function testStatus(patch: Partial<NonNullable<AppState["status"]>> = {}): AppState["status"] {
return {
sessionId: "s1",
isStreaming: false,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
...patch,
};
}
+111
View File
@@ -0,0 +1,111 @@
import type { AppAction } from "./actions";
import type { AppState } from "./appState";
export interface AppActionContext {
state: AppState;
openActionPalette: () => void;
focusPrompt: () => void;
addProject: () => void | Promise<void>;
selectMainView: (view: AppState["mainView"]) => void;
refreshFiles: () => void | Promise<void>;
refreshGit: () => void | Promise<void>;
startSession: () => void | Promise<void>;
stopActiveWork: () => void | Promise<void>;
}
export function createAppActions(context: AppActionContext): AppAction[] {
const hasWorkspace = context.state.selectedWorkspace !== undefined;
const hasSession = context.state.selectedSession !== undefined;
const isBusy = isActive(context.state.status);
return [
{
id: "actions.show",
title: "Show Actions",
description: "Open the command palette",
shortcut: "mod+k",
group: "General",
run: context.openActionPalette,
},
{
id: "prompt.focus",
title: "Focus Prompt",
description: "Move keyboard focus to the message composer",
group: "General",
enabled: hasSession,
run: context.focusPrompt,
},
{
id: "project.add",
title: "Add Project",
group: "Project",
run: context.addProject,
},
{
id: "view.chat",
title: "Go to Chat",
shortcut: "mod+1",
group: "Navigation",
run: () => { context.selectMainView("chat"); },
},
{
id: "view.files",
title: "Go to Files",
shortcut: "mod+2",
group: "Navigation",
enabled: hasWorkspace,
run: () => { context.selectMainView("files"); },
},
{
id: "view.git",
title: "Go to Git",
shortcut: "mod+3",
group: "Navigation",
enabled: hasWorkspace,
run: () => { context.selectMainView("git"); },
},
{
id: "workspace.refresh-files",
title: "Refresh Files",
shortcut: "mod+shift+f",
group: "Workspace",
enabled: hasWorkspace,
run: context.refreshFiles,
},
{
id: "workspace.refresh-git",
title: "Refresh Git",
shortcut: "mod+shift+g",
group: "Workspace",
enabled: hasWorkspace,
run: context.refreshGit,
},
{
id: "workspace.refresh-current",
title: "Refresh Current Panel",
shortcut: "mod+shift+r",
group: "Workspace",
enabled: hasWorkspace,
run: () => context.state.workspaceTool === "git" ? context.refreshGit() : context.refreshFiles(),
},
{
id: "session.start",
title: "Start Session",
shortcut: "mod+enter",
group: "Session",
enabled: hasWorkspace,
run: context.startSession,
},
{
id: "session.stop",
title: "Stop Active Work",
shortcut: "mod+.",
group: "Session",
enabled: hasSession && isBusy,
run: context.stopActiveWork,
},
];
}
function isActive(status: AppState["status"]): boolean {
return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true;
}
+2
View File
@@ -17,6 +17,7 @@ export interface AppState {
sessionStatuses: Record<string, SessionStatus>;
sessionActivities: Record<string, SessionActivity>;
commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
actionPaletteOpen: boolean;
workspaceTool: "files" | "git";
mainView: "chat" | "files" | "git";
fileTree: FileTreeEntry[];
@@ -48,6 +49,7 @@ export function initialAppState(): AppState {
sessionStatuses: {},
sessionActivities: {},
commandDialog: undefined,
actionPaletteOpen: false,
workspaceTool: "files",
mainView: "chat",
fileTree: [],
@@ -0,0 +1,95 @@
import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import type { AppAction } from "../actions";
import { formatShortcut } from "../keyboardShortcuts";
import { actionPaletteStyles } from "./shared";
@customElement("action-palette")
export class ActionPalette extends LitElement {
@property({ attribute: false }) actions: AppAction[] = [];
@property({ attribute: false }) onRun?: (actionId: string) => void;
@property({ attribute: false }) onCancel?: () => void;
@query("input") private input?: HTMLInputElement;
@state() private queryText = "";
@state() private selectedIndex = 0;
override render() {
const actions = this.filteredActions();
return html`
<div class="backdrop" @mousedown=${() => this.onCancel?.()}>
<section @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>
<header>
<input
.value=${this.queryText}
placeholder="Search actions..."
@input=${(event: Event) => {
if (event.target instanceof HTMLInputElement) {
this.queryText = event.target.value;
this.selectedIndex = 0;
}
}}
>
<button title="Close" @click=${() => this.onCancel?.()}>×</button>
</header>
<div class="options">
${actions.length === 0 ? html`<div class="empty">No actions found.</div>` : actions.map((action, index) => html`
<button class=${index === this.selectedIndex ? "selected" : ""} @click=${() => { this.run(action); }}>
<span class="main">
<strong>${action.title}</strong>
${action.description !== undefined && action.description !== "" ? html`<small>${action.description}</small>` : null}
</span>
${action.shortcut !== undefined ? html`<kbd>${formatShortcut(action.shortcut)}</kbd>` : null}
${action.group !== undefined && action.group !== "" ? html`<small class="group">${action.group}</small>` : null}
</button>
`)}
</div>
</section>
</div>
`;
}
override firstUpdated() {
this.input?.focus();
}
protected override updated(changed: PropertyValues) {
if (!changed.has("actions") && !changed.has("queryText")) return;
const maxIndex = Math.max(0, this.filteredActions().length - 1);
if (this.selectedIndex > maxIndex) this.selectedIndex = maxIndex;
}
private filteredActions(): AppAction[] {
const query = this.queryText.trim().toLowerCase();
return this.actions
.filter((action) => action.enabled !== false)
.filter((action) => {
if (query === "") return true;
const haystack = [action.title, action.description ?? "", action.group ?? "", action.shortcut ?? ""].join(" ").toLowerCase();
return haystack.includes(query);
});
}
private handleKeyDown(event: KeyboardEvent) {
const actions = this.filteredActions();
if (event.key === "Escape") {
event.preventDefault();
this.onCancel?.();
} else if (event.key === "ArrowDown") {
event.preventDefault();
if (actions.length > 0) this.selectedIndex = (this.selectedIndex + 1) % actions.length;
} else if (event.key === "ArrowUp") {
event.preventDefault();
if (actions.length > 0) this.selectedIndex = (this.selectedIndex - 1 + actions.length) % actions.length;
} else if (event.key === "Enter") {
event.preventDefault();
const action = actions[this.selectedIndex];
if (action !== undefined) this.run(action);
}
}
private run(action: AppAction) {
this.onRun?.(action.id);
}
static override styles = actionPaletteStyles;
}
+34
View File
@@ -1,12 +1,15 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import type { Project, SessionInfo, Workspace } from "../api";
import type { AppAction } from "../actions";
import { createAppActions } from "../appActions";
import { initialAppState, type AppState } from "../appState";
import { FileExplorerController } from "../controllers/fileExplorerController";
import { GitController } from "../controllers/gitController";
import { ProjectController } from "../controllers/projectController";
import { SessionController } from "../controllers/sessionController";
import { WorkspaceController } from "../controllers/workspaceController";
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { readRoute, writeRoute } from "../route";
import "./ProjectList";
import "./WorkspaceList";
@@ -17,6 +20,7 @@ import "./PromptEditor";
import type { PromptEditor } from "./PromptEditor";
import "./StatusBar";
import "./CommandPicker";
import "./ActionPalette";
import "./WorkspacePanel";
import { appStyles } from "./shared";
@@ -52,17 +56,27 @@ export class PiWebApp extends LitElement {
(patch) => { this.setState(patch); },
() => { this.updateUrl(); },
);
private readonly keyboard = new KeyboardShortcutDispatcher();
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
private readonly onKeyDown = (event: KeyboardEvent) => {
if (this.keyboard.handle(event, this.getActions())) {
event.preventDefault();
event.stopPropagation();
}
};
override connectedCallback(): void {
super.connectedCallback();
window.addEventListener("popstate", this.onPopState);
window.addEventListener("keydown", this.onKeyDown);
this.sessions.connectStatusUpdates();
void this.loadProjectsAndRestoreRoute();
}
override disconnectedCallback(): void {
window.removeEventListener("popstate", this.onPopState);
window.removeEventListener("keydown", this.onKeyDown);
this.keyboard.reset();
this.sessions.dispose();
this.git.dispose();
super.disconnectedCallback();
@@ -162,6 +176,25 @@ export class PiWebApp extends LitElement {
return html`<workspace-panel .workspace=${this.state.selectedWorkspace} .tool=${this.state.workspaceTool} .fileTree=${this.state.fileTree} .expandedDirs=${this.state.expandedDirs} .selectedFilePath=${this.state.selectedFilePath} .selectedFileContent=${this.state.selectedFileContent} .fileTreeStale=${this.state.fileTreeStale} .gitStatus=${this.state.gitStatus} .selectedDiffPath=${this.state.selectedDiffPath} .selectedDiff=${this.state.selectedDiff} .gitStale=${this.state.gitStale} .onSelectTool=${(tool: "files" | "git") => { this.selectWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}></workspace-panel>`;
}
private getActions(): AppAction[] {
return createAppActions({
state: this.state,
openActionPalette: () => { this.setState({ actionPaletteOpen: true }); },
focusPrompt: () => { this.promptEditor?.focusInput(); },
addProject: () => this.projects.addProject(),
selectMainView: (view) => { this.selectMainView(view); },
refreshFiles: () => this.files.refreshFiles(),
refreshGit: () => this.git.refreshGit(),
startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()),
stopActiveWork: () => this.sessions.stopActiveWork(),
});
}
private runAction(actionId: string) {
const action = this.getActions().find((candidate) => candidate.id === actionId && candidate.enabled !== false);
if (action !== undefined) void action.run();
}
override render() {
const state = this.state;
return html`
@@ -191,6 +224,7 @@ export class PiWebApp extends LitElement {
<div class="mobile-panel">${this.renderWorkspacePanel()}</div>
</main>
${this.renderWorkspacePanel()}
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(actionId: string) => { this.setState({ actionPaletteOpen: false }); this.runAction(actionId); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
</div>
`;
}
+20
View File
@@ -198,6 +198,26 @@ export const commandPickerStyles = css`
small { display: block; margin-top: 4px; color: #8b949e; }
`;
export const actionPaletteStyles = css`
:host { position: fixed; inset: 0; z-index: 20; color: #e6edf3; font: 14px system-ui, sans-serif; }
.backdrop { display: grid; align-items: start; justify-items: center; width: 100%; height: 100%; background: #0008; padding-top: min(12vh, 90px); box-sizing: border-box; }
section { width: min(720px, calc(100vw - 40px)); max-height: min(640px, calc(100vh - 40px)); display: flex; flex-direction: column; border: 1px solid #30363d; border-radius: 12px; background: #0d1117; box-shadow: 0 20px 60px #000b; overflow: hidden; }
header { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 10px; border-bottom: 1px solid #30363d; }
input { min-width: 0; border: 0; outline: none; background: transparent; color: #e6edf3; font: 16px system-ui, sans-serif; padding: 8px; }
input::placeholder { color: #6e7681; }
button { border: 0; background: transparent; color: #e6edf3; cursor: pointer; }
header button { color: #8b949e; font-size: 22px; padding: 2px 8px; }
.options { min-height: 0; overflow: auto; }
.options button { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 12px; width: 100%; padding: 10px 12px; border-bottom: 1px solid #21262d; text-align: left; }
.options button.selected, .options button:hover { background: #0d2847; }
.main { min-width: 0; }
strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
small { display: block; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.group { grid-column: 1 / -1; font-size: 12px; }
kbd { align-self: center; border: 1px solid #30363d; border-radius: 6px; background: #161b22; color: #8b949e; padding: 2px 6px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
.empty { padding: 24px; color: #8b949e; text-align: center; }
`;
export const promptEditorStyles = css`
:host { display: block; color: #e6edf3; font: 14px system-ui, sans-serif; }
footer { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 12px; border-top: 1px solid #30363d; }
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest";
import type { AppAction } from "./actions";
import { KeyboardShortcutDispatcher, type ShortcutKeyEvent } from "./keyboardShortcuts";
function keyEvent(key: string, modifiers: Partial<ShortcutKeyEvent> = {}): ShortcutKeyEvent {
return {
key,
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
isComposing: false,
target: null,
...modifiers,
};
}
function action(shortcut: string, enabled = true) {
const run = vi.fn();
const value: AppAction = {
id: shortcut,
title: shortcut,
shortcut,
enabled,
run,
};
return { value, run };
}
describe("KeyboardShortcutDispatcher", () => {
it("runs an enabled matching modified shortcut", () => {
const dispatcher = new KeyboardShortcutDispatcher();
const { value, run } = action("mod+k");
const handled = dispatcher.handle(keyEvent("k", { metaKey: true }), [value]);
expect(handled).toBe(true);
expect(run).toHaveBeenCalledTimes(1);
});
it("ignores plain letters so normal typing is never captured", () => {
const dispatcher = new KeyboardShortcutDispatcher();
const { value, run } = action("r");
const handled = dispatcher.handle(keyEvent("r"), [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);
const handled = dispatcher.handle(keyEvent("Enter", { ctrlKey: true }), [value]);
expect(handled).toBe(false);
expect(run).not.toHaveBeenCalled();
});
it("requires shift for shift shortcuts", () => {
const dispatcher = new KeyboardShortcutDispatcher();
const { value, run } = action("mod+shift+r");
expect(dispatcher.handle(keyEvent("r", { ctrlKey: true }), [value])).toBe(false);
expect(dispatcher.handle(keyEvent("r", { ctrlKey: true, shiftKey: true }), [value])).toBe(true);
expect(run).toHaveBeenCalledTimes(1);
});
});
+131
View File
@@ -0,0 +1,131 @@
import type { AppAction } from "./actions";
const sequenceTimeoutMs = 1200;
export interface ShortcutKeyEvent {
key: string;
metaKey: boolean;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
isComposing: boolean;
target: EventTarget | null;
}
export class KeyboardShortcutDispatcher {
private pendingTokens: string[] = [];
private pendingTimer: number | undefined;
handle(event: ShortcutKeyEvent, actions: AppAction[]): boolean {
const token = eventToken(event);
if (token === undefined || !isModifiedShortcut(token)) 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 sequence = this.pendingTokens.length > 0 && !isModifiedShortcut(token)
? [...this.pendingTokens, token]
: [token];
const exact = shortcuts.find((entry) => sameTokens(entry.tokens, sequence));
if (exact !== undefined) {
this.clearPending();
void exact.action.run();
return true;
}
const hasPrefix = shortcuts.some((entry) => startsWithTokens(entry.tokens, sequence));
if (hasPrefix) {
this.setPending(sequence);
return true;
}
this.clearPending();
return false;
}
reset(): void {
this.clearPending();
}
private setPending(tokens: string[]): void {
this.clearPending();
this.pendingTokens = tokens;
this.pendingTimer = window.setTimeout(() => {
this.pendingTokens = [];
this.pendingTimer = undefined;
}, sequenceTimeoutMs);
}
private clearPending(): void {
this.pendingTokens = [];
if (this.pendingTimer !== undefined) {
window.clearTimeout(this.pendingTimer);
this.pendingTimer = undefined;
}
}
}
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)}`;
})
.join("+"))
.join(" ");
}
function eventToken(event: ShortcutKeyEvent): string | undefined {
if (event.isComposing) return undefined;
const key = normalizeKey(event.key);
if (key === undefined) return undefined;
const modifiers: string[] = [];
if (event.metaKey || event.ctrlKey) modifiers.push("mod");
if (event.altKey) modifiers.push("alt");
if (event.shiftKey) modifiers.push("shift");
modifiers.push(key);
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("+"));
}
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;
}
function sameTokens(left: string[], right: string[]): boolean {
return left.length === right.length && startsWithTokens(left, right);
}
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");
}