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
This commit is contained in:
@@ -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.
|
||||
|
||||
153
apps/desktop/src/contrib/plugin-run-action.test.ts
Normal file
153
apps/desktop/src/contrib/plugin-run-action.test.ts
Normal file
@@ -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<object>()),
|
||||
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: <T>() => 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<PluginAppActionId>().toEqualTypeOf<(typeof PLUGIN_APP_ACTIONS)[number]>()
|
||||
expectTypeOf<'view.showBrowser'>().toMatchTypeOf<PluginAppActionId>()
|
||||
expectTypeOf<'session.archive'>().not.toMatchTypeOf<PluginAppActionId>()
|
||||
expectTypeOf<PluginContext['runAction']>().parameter(0).toEqualTypeOf<PluginAppActionId>()
|
||||
expectTypeOf<PluginContext['runAction']>().returns.toEqualTypeOf<PluginRunActionResult>()
|
||||
expect(PLUGIN_APP_ACTIONS).toContain('view.showBrowser')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ──────────────────────
|
||||
|
||||
100
apps/desktop/src/lib/keybinds/plugin-actions.ts
Normal file
100
apps/desktop/src/lib/keybinds/plugin-actions.ts
Normal file
@@ -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<string> = 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<string, string | undefined> = 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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<boole
|
||||
ctx.pet.say(text, { id?, tone?, ttlMs? }) // line in the core pet's speech bubble → disposer
|
||||
ctx.pet.clear(id?) // drop one line (or all of yours)
|
||||
ctx.pet.visible // ReadableAtom<boolean> — 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:<id>] 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` |
|
||||
|
||||
Reference in New Issue
Block a user