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:
Teknium
2026-10-10 20:04:59 -07:00
parent 1f2b9e0864
commit 0e0539c0f0
9 changed files with 415 additions and 2 deletions

View File

@@ -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.

View 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')
})
})

View File

@@ -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)
}

View File

@@ -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 ──────────────────────

View 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 }
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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` |