diff --git a/src/client/src/actions.ts b/src/client/src/actions.ts new file mode 100644 index 0000000..a214b62 --- /dev/null +++ b/src/client/src/actions.ts @@ -0,0 +1,13 @@ +export interface AppAction { + id: string; + title: string; + description?: string; + shortcut?: string; + group?: string; + enabled?: boolean; + run: () => void | Promise; +} + +export function enabledActions(actions: AppAction[]): AppAction[] { + return actions.filter((action) => action.enabled !== false); +} diff --git a/src/client/src/appActions.test.ts b/src/client/src/appActions.test.ts new file mode 100644 index 0000000..089efe7 --- /dev/null +++ b/src/client/src/appActions.test.ts @@ -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 = {}) { + 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> = {}): 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, + }; +} diff --git a/src/client/src/appActions.ts b/src/client/src/appActions.ts new file mode 100644 index 0000000..6870c7e --- /dev/null +++ b/src/client/src/appActions.ts @@ -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; + selectMainView: (view: AppState["mainView"]) => void; + refreshFiles: () => void | Promise; + refreshGit: () => void | Promise; + startSession: () => void | Promise; + stopActiveWork: () => void | Promise; +} + +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; +} diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index 3f59924..fc34e42 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -17,6 +17,7 @@ export interface AppState { sessionStatuses: Record; sessionActivities: Record; commandDialog: Extract | 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: [], diff --git a/src/client/src/components/ActionPalette.ts b/src/client/src/components/ActionPalette.ts new file mode 100644 index 0000000..473b05e --- /dev/null +++ b/src/client/src/components/ActionPalette.ts @@ -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` +
this.onCancel?.()}> +
{ event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}> +
+ { + if (event.target instanceof HTMLInputElement) { + this.queryText = event.target.value; + this.selectedIndex = 0; + } + }} + > + +
+
+ ${actions.length === 0 ? html`
No actions found.
` : actions.map((action, index) => html` + + `)} +
+
+
+ `; + } + + 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; +} diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 8932e88..32705ea 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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` { 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)}>`; } + 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 {
${this.renderWorkspacePanel()}
${this.renderWorkspacePanel()} + ${state.actionPaletteOpen ? html` { this.setState({ actionPaletteOpen: false }); this.runAction(actionId); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}>` : null} `; } diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 09cb47c..7eb1753 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; } diff --git a/src/client/src/keyboardShortcuts.test.ts b/src/client/src/keyboardShortcuts.test.ts new file mode 100644 index 0000000..c4c5782 --- /dev/null +++ b/src/client/src/keyboardShortcuts.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/client/src/keyboardShortcuts.ts b/src/client/src/keyboardShortcuts.ts new file mode 100644 index 0000000..37aedd4 --- /dev/null +++ b/src/client/src/keyboardShortcuts.ts @@ -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"); +}