From 0e0539c0f01bd5abfbddfefec4c6d99158a243eb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:04:59 -0700 Subject: [PATCH] feat(desktop): plugins run built-in app actions (ctx.runAction) Plugins that wanted a built-in app action (toggle the browser panel, open Settings) faked its keyboard shortcut with a synthetic KeyboardEvent; that silently breaks when the user rebinds or unbinds the shortcut (browser-toggle catalog plugin). - ctx.runAction(id) runs the same handler the keybinding dispatcher and palette use: useKeybinds publishes its handler table through registerBuiltinActionRunner, read at call time, so bindings never matter - allowlist of view/navigation actions only (PLUGIN_APP_ACTIONS, typed PluginAppActionId union); destructive / privileged ids are refused - result-shaped, never throws: { ok: true } or { ok: false, reason: 'unknown' | 'denied' | 'unavailable', error } plus a console warning - ctx.listActions() lists allowed ids with localized labels - docs: API section, allowlist table, migration-table row; SDK exports --- apps/desktop/src/app/hooks/use-keybinds.ts | 20 +++ .../src/contrib/plugin-run-action.test.ts | 153 ++++++++++++++++++ apps/desktop/src/contrib/plugin.ts | 20 +++ apps/desktop/src/lib/keybinds/actions.ts | 33 ++++ .../src/lib/keybinds/plugin-actions.ts | 100 ++++++++++++ apps/desktop/src/sdk/index.ts | 2 +- apps/desktop/src/sdk/plugin-contract.ts | 3 + .../references/desktop-plugins.md | 6 + .../developer-guide/desktop-plugin-sdk.md | 80 ++++++++- 9 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/contrib/plugin-run-action.test.ts create mode 100644 apps/desktop/src/lib/keybinds/plugin-actions.ts diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 2b7f6ee4c3..79c2b818c0 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -27,6 +27,7 @@ import { import { handleApprovalKey, releaseApprovalKey } from '@/lib/keybinds/approval-keys' import { actionAllowedInInput, comboFromEvent, IS_MAC, isEditableTarget, isFocusWithin } from '@/lib/keybinds/combo' import { composerFocusKeysAllowed, isComposerFocusSoftCombo, typeToFocusChar } from '@/lib/keybinds/composer-focus-keys' +import { registerBuiltinActionRunner } from '@/lib/keybinds/plugin-actions' import { stepReasoningEffort, writeSessionReasoningEffort } from '@/lib/reasoning-step' import { openWorktreeDialog } from '@/store/coding-status' import { $commandPaletteOpen, openCommandPalettePage, toggleCommandPalette } from '@/store/command-palette' @@ -398,6 +399,25 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { 'profile.create': requestProfileCreate } + // Plugins run built-in actions by id (`ctx.runAction`) through this same + // table, read at call time, so a plugin run and a keypress share one handler + // and rebinding the chord never changes what the plugin gets. + useEffect( + () => + registerBuiltinActionRunner(actionId => { + const handler = handlersRef.current[actionId] + + if (!handler) { + return false + } + + handler() + + return true + }), + [] + ) + // A keyboard-driven overlay closing hands typing back to the composer: Radix // restores focus to the trigger (a toolbar button for the model pill), so // without this the Enter that committed a model also eats the next keystroke. diff --git a/apps/desktop/src/contrib/plugin-run-action.test.ts b/apps/desktop/src/contrib/plugin-run-action.test.ts new file mode 100644 index 0000000000..d36c4dcd71 --- /dev/null +++ b/apps/desktop/src/contrib/plugin-run-action.test.ts @@ -0,0 +1,153 @@ +import { renderHook } from '@testing-library/react' +import { createElement, type ReactNode } from 'react' +import { MemoryRouter } from 'react-router' +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' + +vi.mock('@/themes/context', () => ({ + useTheme: () => ({ resolvedMode: 'dark' as const, setMode: () => undefined }) +})) + +// The browser toggle is the handler the shortcut runs; spy on it so the test +// can tell "the plugin ran the same handler" from "something else happened". +const toggleBrowserTab = vi.hoisted(() => vi.fn()) + +vi.mock('@/store/preview', async importOriginal => ({ + ...(await importOriginal()), + toggleBrowserTab +})) + +import { useKeybinds } from '@/app/hooks/use-keybinds' +import { createPluginContext } from '@/contrib/plugin' +import { KEYBIND_ACTION_IDS } from '@/lib/keybinds/actions' +import { isMacPlatform } from '@/lib/platform' +import { PLUGIN_APP_ACTIONS, type PluginAppActionId, type PluginContext, type PluginRunActionResult } from '@/sdk' +import { resetAllBindings, setBinding } from '@/store/keybinds' + +const wrapper = ({ children }: { children: ReactNode }) => createElement(MemoryRouter, null, children) + +const deps = { + archiveSelectedSession: vi.fn(), + openNewSessionTab: vi.fn(), + requestGateway: () => Promise.resolve(undefined as T), + startFreshSession: vi.fn(), + toggleCommandCenter: vi.fn(), + toggleSelectedPin: vi.fn() +} + +const pressModShift = (key: string) => + window.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + code: `Key${key.toUpperCase()}`, + ctrlKey: !isMacPlatform(), + key, + metaKey: isMacPlatform(), + shiftKey: true + }) + ) + +let unmount: (() => void) | undefined + +beforeEach(() => { + toggleBrowserTab.mockClear() + Object.values(deps).forEach(fn => 'mockClear' in fn && fn.mockClear()) + resetAllBindings() + unmount = renderHook(() => useKeybinds(deps), { wrapper }).unmount +}) + +afterEach(() => { + unmount?.() + unmount = undefined + resetAllBindings() + vi.restoreAllMocks() +}) + +describe('ctx.runAction — plugins run built-in app actions', () => { + it('runs the same handler the keyboard shortcut runs', () => { + const ctx = createPluginContext('browser-toggle') + + pressModShift('l') + expect(toggleBrowserTab).toHaveBeenCalledTimes(1) + + expect(ctx.runAction('view.showBrowser')).toEqual({ ok: true }) + expect(toggleBrowserTab).toHaveBeenCalledTimes(2) + }) + + it('is unaffected by rebinding or unbinding the shortcut', () => { + const ctx = createPluginContext('browser-toggle') + + setBinding('view.showBrowser', ['mod+alt+b']) + + // The old chord no longer toggles: a plugin faking Ctrl+Shift+L breaks here. + pressModShift('l') + expect(toggleBrowserTab).not.toHaveBeenCalled() + + expect(ctx.runAction('view.showBrowser')).toEqual({ ok: true }) + expect(toggleBrowserTab).toHaveBeenCalledTimes(1) + + setBinding('view.showBrowser', []) + expect(ctx.runAction('view.showBrowser')).toEqual({ ok: true }) + expect(toggleBrowserTab).toHaveBeenCalledTimes(2) + }) + + it('refuses actions outside the allowlist without running them', () => { + const ctx = createPluginContext('sneaky') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const run = ctx.runAction as (id: string) => PluginRunActionResult + + expect(run('session.archive')).toMatchObject({ ok: false, reason: 'denied' }) + expect(run('profile.switch.1')).toMatchObject({ ok: false, reason: 'denied' }) + expect(run('composer.modelPicker')).toMatchObject({ ok: false, reason: 'denied' }) + expect(deps.archiveSelectedSession).not.toHaveBeenCalled() + + const unknown = run('app.quit') + + expect(unknown).toMatchObject({ ok: false, reason: 'unknown' }) + expect(unknown.ok ? '' : unknown.error).toContain("'app.quit'") + expect(run(undefined as unknown as string)).toMatchObject({ ok: false, reason: 'unknown' }) + + expect(toggleBrowserTab).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledTimes(5) + expect(String(warn.mock.calls[0]?.[0])).toContain('[plugin:sneaky]') + }) + + it('reports unavailable instead of throwing when the app shell is not mounted', () => { + unmount?.() + unmount = undefined + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + expect(createPluginContext('early').runAction('view.showBrowser')).toMatchObject({ + ok: false, + reason: 'unavailable' + }) + expect(toggleBrowserTab).not.toHaveBeenCalled() + }) + + it('lists exactly the allowlisted actions with labels', () => { + const listed = createPluginContext('browser-toggle').listActions() + + expect(listed.map(a => a.id)).toEqual([...PLUGIN_APP_ACTIONS]) + expect(listed.find(a => a.id === 'view.showBrowser')).toMatchObject({ category: 'view', label: 'Toggle browser' }) + expect(listed.every(a => a.label && a.label !== a.id)).toBe(true) + }) + + it('allowlists only real built-in view/navigation actions', () => { + const forbidden = + /archive|togglePin|profile\.|modelPicker|reasoning|voice|dictate|Terminal|newWindow|openFolder|Worktree|toggleHud|closeTab/ + + for (const id of PLUGIN_APP_ACTIONS) { + expect(KEYBIND_ACTION_IDS).toContain(id) + expect(id).not.toMatch(forbidden) + } + }) + + it('exports the typed API from @hermes/plugin-sdk', () => { + expectTypeOf().toEqualTypeOf<(typeof PLUGIN_APP_ACTIONS)[number]>() + expectTypeOf<'view.showBrowser'>().toMatchTypeOf() + expectTypeOf<'session.archive'>().not.toMatchTypeOf() + expectTypeOf().parameter(0).toEqualTypeOf() + expectTypeOf().returns.toEqualTypeOf() + expect(PLUGIN_APP_ACTIONS).toContain('view.showBrowser') + }) +}) diff --git a/apps/desktop/src/contrib/plugin.ts b/apps/desktop/src/contrib/plugin.ts index 93fa7c3725..d487729ac1 100644 --- a/apps/desktop/src/contrib/plugin.ts +++ b/apps/desktop/src/contrib/plugin.ts @@ -16,6 +16,13 @@ import type { ReadableAtom } from 'nanostores' import { pluginRest, type PluginRestOptions, pluginSocket } from '@/hermes' import { createPluginI18n, type PluginI18n } from '@/i18n' +import { + listPluginAppActions, + type PluginAppActionId, + type PluginAppActionInfo, + type PluginRunActionResult, + runPluginAppAction +} from '@/lib/keybinds/plugin-actions' import { readKey, writeKey } from '@/lib/storage' import { dispatchPluginNativeNotification, type PluginNativeNotificationInput } from '@/store/native-notifications' import { $petActive } from '@/store/pet' @@ -31,6 +38,7 @@ import type { Contribution } from './types' export type { PluginRestOptions } from '@/hermes' export type { HermesOpenTarget } from '@/lib/hermes-open-target' +export type { PluginAppActionId, PluginAppActionInfo, PluginRunActionResult } from '@/lib/keybinds/plugin-actions' export type { PluginNativeNotificationInput, PluginNotificationAction } from '@/store/native-notifications' export type { PetMessageTone, PetSayOptions } from '@/store/pet-plugin-messages' @@ -153,6 +161,16 @@ export interface PluginContext { * plugin and cleared when it unloads. Feature-detect on older hosts: * `ctx.pet?.say(...)`. */ pet: PluginPet + /** Run a built-in app action (toggle the browser panel, open Settings, + * focus the composer, …) through the same handler its keyboard shortcut + * and palette entry use, so rebinding or unbinding the shortcut changes + * nothing. Only view / navigation actions are allowed (`listActions()`); + * an unknown or denied id is refused with `{ ok: false, error }` and a + * console warning, never thrown. Feature-detect on older hosts: + * `ctx.runAction?.('view.showBrowser')`. */ + runAction: (id: PluginAppActionId) => PluginRunActionResult + /** The actions `runAction` accepts, with their localized labels. */ + listActions: () => PluginAppActionInfo[] /** Plugin-scoped persistence. */ storage: PluginStorage /** Plugin-scoped i18n: ship + register locale bundles under this plugin, @@ -349,6 +367,8 @@ export function createPluginContext(pluginId: string, onDispose?: (dispose: () = socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)), os: createPluginOs(pluginId), pet: createPluginPet(pluginId, track), + runAction: id => runPluginAppAction(pluginId, id), + listActions: listPluginAppActions, storage: createPluginStorage(pluginId), i18n: createPluginI18n(pluginId, track) } diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index 0d9fc5f73d..97c0a666b9 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -227,6 +227,39 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ export const KEYBIND_ACTION_IDS: readonly string[] = KEYBIND_ACTIONS.map(action => action.id) +// The built-in actions a plugin may run by id (`ctx.runAction`, see +// plugin-actions.ts): view / navigation only. Nothing destructive, nothing +// that switches profile / model, spawns a shell, or opens native dialogs. +export const PLUGIN_APP_ACTIONS = [ + // Panels and layout + 'view.showBrowser', + 'view.showFiles', + 'view.toggleSidebar', + 'view.toggleRightSidebar', + 'view.toggleReview', + 'view.toggleStatusbar', + 'view.findInPage', + // Composer and chat + 'composer.focus', + 'session.new', + 'session.focusSearch', + 'conversation.scrollPageUp', + 'conversation.scrollPageDown', + // Navigation + 'nav.commandPalette', + 'nav.settings', + 'nav.profiles', + 'nav.capabilities', + 'nav.messaging', + 'nav.artifacts', + 'nav.cron', + 'nav.agents', + 'keybinds.openPanel' +] as const + +/** An app action id a plugin may run with `ctx.runAction`. */ +export type PluginAppActionId = (typeof PLUGIN_APP_ACTIONS)[number] + const ACTION_BY_ID = new Map(KEYBIND_ACTIONS.map(action => [action.id, action])) // ── Contributed actions — the `keybinds` registry area ────────────────────── diff --git a/apps/desktop/src/lib/keybinds/plugin-actions.ts b/apps/desktop/src/lib/keybinds/plugin-actions.ts new file mode 100644 index 0000000000..4c55cbc72f --- /dev/null +++ b/apps/desktop/src/lib/keybinds/plugin-actions.ts @@ -0,0 +1,100 @@ +// Built-in app actions a plugin may run by id (`ctx.runAction`), through the +// SAME handler the keyboard shortcut and the command palette dispatch. A +// plugin that wants "toggle the browser panel" asks for the action, not for +// the chord: rebinding the shortcut in Settings ▸ Keyboard Shortcuts never +// changes what the plugin runs, and an unbound action still runs. +// +// Only view / navigation actions are exposed. Nothing that deletes, archives, +// signs out, answers an approval, switches profile / model / provider, spawns +// a shell, opens native dialogs, or touches app lifecycle (update, quit, +// reload) — those stay user-initiated. + +import { runtimeTranslations } from '@/i18n/runtime' + +import { + KEYBIND_ACTION_IDS, + keybindAction, + type KeybindCategory, + PLUGIN_APP_ACTIONS, + type PluginAppActionId +} from './actions' + +export { PLUGIN_APP_ACTIONS, type PluginAppActionId } from './actions' + +/** One row of `ctx.listActions()`. */ +export interface PluginAppActionInfo { + id: PluginAppActionId + /** The localized label the Keyboard Shortcuts panel shows. */ + label: string + category: KeybindCategory +} + +/** What `ctx.runAction` resolves to. It never throws. */ +export type PluginRunActionResult = + | { ok: true } + | { + ok: false + /** `unknown`: no such action. `denied`: a real action that is not on + * the plugin allowlist. `unavailable`: the app shell that owns the + * handlers is not mounted (e.g. during early startup). */ + reason: 'denied' | 'unavailable' | 'unknown' + error: string + } + +const ALLOWED: ReadonlySet = new Set(PLUGIN_APP_ACTIONS) + +export function isPluginAppAction(id: unknown): id is PluginAppActionId { + return typeof id === 'string' && ALLOWED.has(id) +} + +/** Runs a built-in action's handler by id; false when it has none. */ +type BuiltinActionRunner = (id: string) => boolean + +let runner: BuiltinActionRunner | null = null + +/** The keybinding dispatcher (`useKeybinds`) installs its handler table here + * while it is mounted, so plugin runs and keypresses share one handler. */ +export function registerBuiltinActionRunner(next: BuiltinActionRunner): () => void { + runner = next + + return () => { + if (runner === next) { + runner = null + } + } +} + +export function listPluginAppActions(): PluginAppActionInfo[] { + // Action ids contain dots, so read the block rather than a dot-path key. + const labels: Record = runtimeTranslations().keybinds.actions + + return PLUGIN_APP_ACTIONS.map(id => ({ + id, + label: labels[id] ?? id, + category: keybindAction(id)?.category ?? 'view' + })) +} + +function refuse(pluginId: string, reason: 'denied' | 'unavailable' | 'unknown', error: string): PluginRunActionResult { + console.warn(`[plugin:${pluginId}] runAction: ${error}`) + + return { ok: false, reason, error } +} + +export function runPluginAppAction(pluginId: string, id: unknown): PluginRunActionResult { + const label = typeof id === 'string' ? `'${id}'` : String(id) + + if (!isPluginAppAction(id)) { + const known = typeof id === 'string' && KEYBIND_ACTION_IDS.includes(id) + + return known + ? refuse(pluginId, 'denied', `${label} is not available to plugins. See ctx.listActions() for the allowed ids.`) + : refuse(pluginId, 'unknown', `${label} is not a Hermes app action. See ctx.listActions() for the allowed ids.`) + } + + if (!runner || !runner(id)) { + return refuse(pluginId, 'unavailable', `${label} cannot run yet: the app shell is not ready.`) + } + + return { ok: true } +} diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 237ace7e29..127b15fa6d 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -1951,7 +1951,7 @@ export * as icons from '@/lib/icons' * commit (`isComposing` or the legacy keyCode 229). Use it on every plugin * text field whose bare Enter performs an action. */ export { isSubmitEnter } from '@/lib/ime' -export { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions' +export { type KeybindContribution, KEYBINDS_AREA, PLUGIN_APP_ACTIONS } from '@/lib/keybinds/actions' export { formatModifierToken } from '@/lib/keybinds/combo' /** A `Map` with a ceiling, for the module-level caches a plugin keeps across * a renderer that stays open for days. Only for values that can be diff --git a/apps/desktop/src/sdk/plugin-contract.ts b/apps/desktop/src/sdk/plugin-contract.ts index 58e3383870..898b1885a8 100644 --- a/apps/desktop/src/sdk/plugin-contract.ts +++ b/apps/desktop/src/sdk/plugin-contract.ts @@ -4,6 +4,8 @@ export type { HermesPlugin, PetMessageTone, PetSayOptions, + PluginAppActionId, + PluginAppActionInfo, PluginContext, PluginContribution, PluginNativeNotificationInput, @@ -11,6 +13,7 @@ export type { PluginOs, PluginPet, PluginRestOptions, + PluginRunActionResult, PluginSettingsPage, PluginSettingsSubpage, PluginStorage diff --git a/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md b/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md index 707f5879d7..eee55adecb 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md @@ -134,6 +134,12 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` / error/waiting states win; nothing shows without a visible pet (`ctx.pet.visible`). `ctx.pet.clear(id?)`. Never locate the pet canvas in the DOM or overlay it yourself. +- `ctx.runAction('view.showBrowser')` — run a built-in app action through the + same handler as its shortcut/palette entry, so a rebound shortcut can't break + it; returns `{ ok: true }` or `{ ok: false, reason: 'unknown'|'denied'|'unavailable', error }` + (never throws). View/navigation actions only; `ctx.listActions()` lists them + (`PluginAppActionId` / `PLUGIN_APP_ACTIONS`). Never dispatch a synthetic + `KeyboardEvent` to trigger an app shortcut. - `ctx.i18n.register({ en, ja, ... })` — ship your OWN locale bundles, scoped to your plugin (never edit core `en.ts`). Values are literal strings or interpolator functions; nested trees are addressed by dot-path. Read them diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md index a54df00fe3..fccd1d1b5c 100644 --- a/website/docs/developer-guide/desktop-plugin-sdk.md +++ b/website/docs/developer-guide/desktop-plugin-sdk.md @@ -376,6 +376,11 @@ ctx.registerMany([ Keybinds are user-rebindable in settings; `defaults` is just the initial binding. +To trigger one of the app's **own** actions (toggle the browser panel, open +Settings, focus the composer), never dispatch a synthetic `KeyboardEvent` for its +shortcut — the user may have rebound it. Call +[`ctx.runAction(id)`](#run-app-actions) instead. + ### Themes A theme contribution ships a full `DesktopTheme` as its `data` (name, label, @@ -556,6 +561,7 @@ and its siblings; the pet-wallet row uses the [pet bubble](#pet-bubble) door): | memory-review (#115966) | `host.request('slash.exec', { session_id, command })` for `/memory …` — already SDK-only | optional: `host.composer.insertText(sid, '/memory pending', { mode: 'prefix' })` to seat the command for the user instead of executing it | | intelligent-tool-break (#115964) | "Message" button only toasts "type /break" (no composer write) | `host.composer.setDraft(host.state.focusedSessionId.get(), '/break ')` then `host.composer.focus(null)` restores the intended behaviour | | pet-wallet (#135178) | `document.querySelector('canvas[aria-label$=" pet"]')` to find the core pet, a `position:fixed; z-index:9999` overlay on `document.body` that follows it every frame, document-wide capture-phase pointer listeners | `ctx.pet.say(text, { id: 'balance', tone?, ttlMs? })` — the core bubble shows it over the pet, in-window and popped out, labelled with the plugin name; `ctx.pet.visible` tells you when there is no pet so you can fall back to your status-bar chip | +| browser-toggle | `document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'L', ctrlKey: true, shiftKey: true, … }))` to fake the built-in browser-panel shortcut; silently stops working when the user rebinds or unbinds it | `ctx.runAction('view.showBrowser')` — runs the same handler as the shortcut and the palette entry, whatever the binding ([run app actions](#run-app-actions)) | `sessionId` in the table is the id the plugin's UI is bound to; for a composer slot render it is `host.state.focusedSessionId.get()`. @@ -1092,6 +1098,8 @@ ctx.os.writeClipboard(text) // system clipboard → Promise — is a pet on screen? +ctx.runAction('view.showBrowser') // run a built-in app action → { ok: true } | { ok: false, reason, error } +ctx.listActions() // [{ id, label, category }] — the ids runAction accepts host.navigate('/route') // hash-route navigation host.openSession(id, { profile?, intent? }) // open a stored session core-style; // profile: soft-swap to that profile's backend first @@ -1309,6 +1317,76 @@ Pointer input on the pet (drag, shift-click pop-out, overlay click) belongs to the host and has no plugin hook. Use a palette command, a status-bar item, or your own pane for actions. +### Run app actions — `ctx.runAction` {#run-app-actions} + +The app's own commands — the ones behind keyboard shortcuts and the command +palette — can be run by id. The plugin gets exactly the handler the shortcut +runs, so the user rebinding (or unbinding) that shortcut in Settings ▸ Keyboard +Shortcuts changes nothing for you: + +```ts +import { TITLEBAR_AREAS } from '@hermes/plugin-sdk' +import { jsx } from 'react/jsx-runtime' + +register(ctx) { + ctx.register({ + id: 'browser', + area: TITLEBAR_AREAS.right, + render: () => jsx('button', { onClick: () => ctx.runAction('view.showBrowser'), children: 'Browser' }) + }) + + // Results are values, never exceptions. + const result = ctx.runAction('nav.settings') + if (!result.ok) console.log(result.reason, result.error) + + // Discover what is allowed (labels are localized, same as the shortcuts panel). + for (const { id, label } of ctx.listActions()) console.log(id, label) +} +``` + +```ts +interface PluginContext { + runAction(id: PluginAppActionId): PluginRunActionResult + listActions(): Array<{ id: PluginAppActionId; label: string; category: string }> +} + +type PluginRunActionResult = + | { ok: true } + | { ok: false; reason: 'unknown' | 'denied' | 'unavailable'; error: string } +``` + +Only view and navigation actions are allowed. `PluginAppActionId` is the typed +union; `PLUGIN_APP_ACTIONS` is the same list at runtime: + +| Id | What it does | +|---|---| +| `view.showBrowser` | Toggle the browser panel | +| `view.showFiles` | Show the file browser | +| `view.toggleSidebar` | Toggle the left sidebar | +| `view.toggleRightSidebar` | Toggle the right side | +| `view.toggleReview` | Toggle the review (git) pane | +| `view.toggleStatusbar` | Toggle the status bar | +| `view.findInPage` | Open find-in-page | +| `composer.focus` | Focus the composer | +| `session.new` | Start a new chat | +| `session.focusSearch` | Focus session search | +| `conversation.scrollPageUp` / `conversation.scrollPageDown` | Scroll the transcript a page | +| `nav.commandPalette` | Toggle the command palette | +| `nav.settings`, `nav.profiles`, `nav.capabilities`, `nav.messaging`, `nav.artifacts`, `nav.cron`, `nav.agents` | Open that page | +| `keybinds.openPanel` | Open Settings ▸ Keyboard Shortcuts | + +Anything else is refused: `{ ok: false, reason: 'denied' }` for a real app +action that is not on the list (archive, pin, profile / model / reasoning +switches, terminals, new window, open folder, HUD, close tab, …), +`reason: 'unknown'` for an id that does not exist. Either way nothing runs and a +`[plugin:] runAction: …` warning is logged. `reason: 'unavailable'` means the +app shell that owns the handlers is not mounted yet (very early startup); retry +from a user action. Destructive and privileged operations (deleting sessions, +signing out, approvals, model/provider changes, updates, quitting) are not app +actions a plugin can run. + +Feature-detect on older hosts: `ctx.runAction?.('view.showBrowser')`. + ### Desktop appearance settings — `host.settings` `host.settings` is the supported door for the small set of Desktop-local @@ -1891,7 +1969,7 @@ pipeline as a trust boundary. | Category | Exports | |----------|---------| | Host | `host` (`.state.*`, `.settings`, `.notify`, `.notifyError`, `.navigate`, `.onEvent`, `.logs`, `.status`, `.restartGateway`, `.request`, `.composer`, `.sessions`, `.skills`, `.toolsets`, `.profiles`, `.pluginDecisions`) | -| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginOs`, `PluginPet`, `PetSayOptions`, `PetMessageTone`, `PluginRestOptions`, `PluginNativeNotificationInput`, `PluginNotificationAction`, `HermesOpenTarget`, `Contribution` | +| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginOs`, `PluginPet`, `PetSayOptions`, `PetMessageTone`, `PluginAppActionId`, `PluginAppActionInfo`, `PluginRunActionResult`, `PLUGIN_APP_ACTIONS`, `PluginRestOptions`, `PluginNativeNotificationInput`, `PluginNotificationAction`, `HermesOpenTarget`, `Contribution` | | Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `WORKSPACE_PAGE_HEADER_AREA`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS`, `MODEL_MENU_ROW_AREA`, `SESSION_ROW_AREAS`, `SIDEBAR_NAV_PREFS_AREA`, `APPEARANCE_AREAS`, `SETTINGS_PLUGINS_AREA` | | Area payloads | `PluginSettingsPage`, `PluginSettingsSubpage` (+ `pluginSettingsHref`), `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider`, `SessionRowSlotContribution`, `SidebarNavPrefsContribution` | | React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute`, `WorkspacePageHeaderControl` |