feat(ui-tui): self-authored widgets — user-widget loader, hot-load, skill

Hermes can write its own widgets: a loader discovers $HERMES_HOME/tui-widgets/*.mjs,
fs.watch hot-loads them the moment they land (no restart), and a tui-widgets skill
teaches the agent the contract and the openWidget-at-register auto-open recipe.
Load/error/remove events announce themselves in the transcript; a lazy intro
skeleton covers the first paint.
This commit is contained in:
Brooklyn Nicholson 2026-07-20 22:44:22 -05:00
parent 76b58d6127
commit ca0b1b130c
11 changed files with 493 additions and 2 deletions

View file

@ -0,0 +1,107 @@
---
name: tui-widgets
description: Author live widget apps for the Hermes TUI dock.
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [tui, widgets, sdk, ui]
category: productivity
---
# TUI Widgets Skill
Author widget apps for the Hermes TUI (`hermes --tui`): glanceable ambient
panels docked above the status bar, or modal overlays that own the keyboard.
Widgets are plain ESM files the TUI loads at startup — no build step, no
repo changes. This skill does not cover desktop-app or web-dashboard
widgets.
## When to Use
- The user asks for a live panel in the TUI (ticker, clock, countdown,
status card, API-backed readout).
- The user wants a custom modal tool (picker, calculator, viewer) bound to
a slash command.
## Prerequisites
- The TUI must be in use (`hermes --tui`). Widgets do not render in the
classic CLI or messaging platforms.
- Network-backed widgets need whatever credentials their API needs; fetch
failures must land as an error phase, never a crash.
## How to Run
1. Use `write_file` to create `~/.hermes/tui-widgets/<name>.mjs` (see
`templates/clock.mjs` for a complete working widget).
2. If the TUI is running it hot-loads the file within ~a second (the
widgets directory is watched); `/widgets-reload` forces a rescan.
3. The widget's id becomes its slash command automatically (`/<id>`), with
its `help` in the `/` completion popover. No other registration exists.
## Quick Reference
A widget file default-exports `register(sdk)`:
```js
export default function register(sdk) {
const { Box, Text, defineWidgetApp, h } = sdk
defineWidgetApp({
id: 'clock', // slash command name
help: 'live clock in the dock', // `/` completion metadata
mode: 'ambient', // 'ambient' docks; 'modal' takes input
init: arg => ({ label: arg.trim() || 'UTC' }), // null = print usage
reduce: (state, { ch, key }) => (key.escape || ch === 'q' ? null : state),
render: ({ state, t }) => h(sdk.Dialog, { width: 24 }, h(Text, { color: t.color.label }, state.label))
})
}
```
`sdk` contents: `defineWidgetApp`, `openWidget`, `updateWidget`, `isCtrl`,
`React`, `h` (createElement — no JSX in .mjs), components `Box`, `Text`,
`Dialog`, `Overlay`, `WidgetGrid`, `GridAreas`.
Contract essentials:
- `mode: 'ambient'` — docks above the status bar, captures no input, the
command toggles it; `render` returns a CARD (usually `Dialog`), never
`Overlay`.
- `mode: 'modal'` (default) — owns every keypress; `reduce` returns next
state, the same reference to swallow a key, or `null` to close; `render`
wraps content in `Overlay` for placement.
- Async data: fire the fetch from `init`, land results with
`sdk.updateWidget(app, fn)` — it no-ops if the widget was closed, so a
late reply can never resurrect it.
- Animation: own a timer inside a component via `React.useState` +
`React.useEffect` (see the template); keep intervals ≥ 250ms.
- Colors: ALWAYS theme tones (`t.color.primary/label/muted/ok/error/…`),
never hardcoded hexes — widgets must survive `/skin` and light/dark.
## Procedure
1. Pick `id`, `mode`, and the state shape; keep state serializable.
2. Write the file from the template; wire data via `init` + `updateWidget`.
3. `/<id>` to launch (hot-loaded on write); relaunch `/<id>` to dismiss an
ambient widget.
4. Iterate: edit the file — it hot-reloads on save (last-writer-wins, the
fresh definition shadows the old one). Relaunch `/<id>` to remount.
## Pitfalls
- No JSX and no bare imports in `.mjs` — everything comes from the `sdk`
parameter; `h(...)` builds elements.
- Don't ship a modal without a close path (`Esc`/`q` returning `null`).
- Ambient widgets must stay small (≤ ~6 rows) — the dock sits between the
transcript and the status bar.
- A thrown `register()` is logged and skipped; check
`~/.hermes/logs/tui_gateway_crash.log` if a widget never appears.
## Verification
Run `/widgets-reload` — the transcript line must list the file under
`loaded:`. Then `/<id>`: an ambient widget appears docked right, above the
status bar, while the composer keeps accepting input; `/<id>` again removes
it.

View file

@ -0,0 +1,51 @@
/**
* Reference user widget: a live clock docked above the status bar.
* Copy to ~/.hermes/tui-widgets/clock.mjs, then `/widgets-reload` and `/clock`.
*/
export default function register(sdk) {
const { Box, Dialog, React, Text, defineWidgetApp, h } = sdk
function Face({ label, t }) {
const [now, setNow] = React.useState(() => new Date())
React.useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(id)
}, [])
return h(
Box,
{ columnGap: 1, flexDirection: 'row' },
h(Text, { bold: true, color: t.color.label }, label),
h(Text, { color: t.color.text }, now.toLocaleTimeString('en-GB', { hour12: false, timeZone: label === 'local' ? undefined : label }))
)
}
defineWidgetApp({
id: 'clock',
help: 'live clock in the dock (arg: IANA timezone)',
mode: 'ambient',
usage: 'usage: /clock [timezone] e.g. /clock UTC · /clock Asia/Tokyo',
init(arg) {
const label = arg.trim() || 'local'
try {
new Date().toLocaleTimeString('en-GB', { timeZone: label === 'local' ? undefined : label })
} catch {
return null
}
return { label }
},
reduce(state, { ch, key }) {
return key.escape || ch === 'q' ? null : state
},
render({ state, t }) {
return h(Dialog, { width: 30 }, h(Face, { label: state.label, t }))
}
})
}

View file

@ -0,0 +1,67 @@
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { beforeEach, describe, expect, it } from 'vitest'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { launchWidget } from '../sdk/host.js'
import { getWidgetApp } from '../sdk/registry.js'
import { loadUserWidgets } from '../sdk/userWidgets.js'
const WIDGET = `
export default function register(sdk) {
sdk.defineWidgetApp({
id: 'test-user-widget',
help: 'from disk',
mode: 'ambient',
init: arg => ({ arg }),
reduce: state => state,
render: ({ state, t }) => sdk.h(sdk.Text, { color: t.color.label }, state.arg)
})
}
`
beforeEach(() => resetOverlayState())
describe('user widget loading', () => {
it('missing directory is a clean no-op', async () => {
const result = await loadUserWidgets(join(tmpdir(), 'definitely-missing-widgets-dir'))
expect(result).toEqual({ added: [], errors: [], loaded: [], removed: [] })
})
it('loads .mjs from disk, registers, dispatches, and reports broken files', async () => {
const dir = await mkdtemp(join(tmpdir(), 'tui-widgets-'))
await writeFile(join(dir, 'good.mjs'), WIDGET)
await writeFile(join(dir, 'broken.mjs'), 'export default 42')
await writeFile(join(dir, 'ignored.txt'), 'not a widget')
const result = await loadUserWidgets(dir)
expect(result.loaded).toEqual(['good.mjs'])
expect(result.added).toEqual(['test-user-widget'])
expect(result.errors).toMatchObject([{ file: 'broken.mjs' }])
// Registered like any built-in: catalog metadata + launchable.
expect(getWidgetApp('test-user-widget')).toMatchObject({ help: 'from disk', mode: 'ambient' })
expect(launchWidget('test-user-widget', 'hi')).toBeNull()
expect(getOverlayState().ambient).toMatchObject([{ appId: 'test-user-widget', state: { arg: 'hi' } }])
})
it('a deleted file unregisters its apps on the next scan', async () => {
const dir = await mkdtemp(join(tmpdir(), 'tui-widgets-'))
const file = join(dir, 'gone.mjs')
await writeFile(file, WIDGET.replace('test-user-widget', 'soon-gone'))
await loadUserWidgets(dir)
expect(getWidgetApp('soon-gone')).toBeDefined()
await rm(file)
const result = await loadUserWidgets(dir)
expect(result.removed).toEqual(['soon-gone'])
expect(getWidgetApp('soon-gone')).toBeUndefined()
})
})

View file

@ -1,6 +1,8 @@
import { parseSlashCommand } from '../domain/slash.js'
import type { SlashExecResponse } from '../gatewayTypes.js'
import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js'
import { launchWidget } from '../sdk/host.js'
import { getWidgetApp } from '../sdk/registry.js'
import type { SlashHandlerContext } from './interfaces.js'
import { findSlashCommand } from './slash/registry.js'
@ -45,6 +47,19 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
return true
}
// Registry-first fallback: widget apps registered AFTER the static
// command table was built (user widgets from $HERMES_HOME/tui-widgets,
// /widgets-reload) dispatch straight off the live registry.
if (getWidgetApp(parsed.name)) {
const err = launchWidget(parsed.name, parsed.arg)
if (err) {
sys(err)
}
return true
}
if (catalog?.canon) {
const needle = `/${parsed.name}`.toLowerCase()
const exact = Object.entries(catalog.canon).find(([alias]) => alias.toLowerCase() === needle)?.[1]

View file

@ -6,6 +6,7 @@ import { terminalBackgroundHex } from '@hermes/ink'
import { formatBytes, performHeapDump } from '../../../lib/memory.js'
import { launchWidget } from '../../../sdk/host.js'
import { listWidgetApps } from '../../../sdk/registry.js'
import { loadUserWidgets } from '../../../sdk/userWidgets.js'
import { detectLightMode } from '../../../theme.js'
import { getUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js'
@ -28,6 +29,21 @@ export const widgetAppCommands: SlashCommand[] = listWidgetApps().map(app => ({
export const debugCommands: SlashCommand[] = [
...widgetAppCommands,
{
help: 'rescan $HERMES_HOME/tui-widgets and (re)register user widget apps',
name: 'widgets-reload',
run: (_arg, ctx) => {
void loadUserWidgets().then(({ errors, loaded }) => {
const parts = [
loaded.length ? `loaded: ${loaded.join(', ')}` : 'no user widgets found',
...errors.map(e => `${e.file}: ${e.message}`)
]
ctx.transcript.sys(`widgets — ${parts.join(' · ')}`)
})
}
},
{
help: 'write a V8 heap snapshot + memory diagnostics (see HERMES_HEAPDUMP_DIR)',
name: 'heapdump',

View file

@ -38,6 +38,7 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { terminalParityHints } from '../lib/terminalParity.js'
import { buildToolTrailLine, formatAbandonedClarify, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js'
import { onUserWidgets } from '../sdk/userWidgets.js'
import type { Msg, PanelSection, SlashCatalog } from '../types.js'
import { createGatewayEventHandler } from './createGatewayEventHandler.js'
@ -435,6 +436,26 @@ export function useMainApp(gw: GatewayClient) {
const sys = useCallback((text: string) => appendMessage({ role: 'system', text }), [appendMessage])
// Hot-loaded user widgets announce themselves — a silently-registered
// widget is indistinguishable from a failed one. Errors surface too.
useEffect(
() =>
onUserWidgets(({ added, errors, removed }) => {
for (const id of added) {
sys(`widget /${id} is live — type /${id} to open`)
}
for (const id of removed) {
sys(`widget /${id} removed (file deleted)`)
}
for (const err of errors) {
sys(`widget ${err.file} failed to load: ${err.message}`)
}
}),
[sys]
)
const page = useCallback(
(text: string, title?: string) => patchOverlayState({ pager: { lines: text.split('\n'), offset: 0, title } }),
[]

View file

@ -1,5 +1,11 @@
/** Reference apps. Importing this module registers them (defineWidgetApp
* runs at module load) appLayout imports it once at startup. */
* runs at module load) appLayout imports it once at startup. User widgets
* from $HERMES_HOME/tui-widgets ride the same import (async, non-fatal). */
import { loadUserWidgets, watchUserWidgets } from '../userWidgets.js'
void loadUserWidgets()
watchUserWidgets()
export { dialogTestApp } from './dialogTest.js'
export { gridTestApp } from './gridTest.js'
export { GRID_STREAM_COUNT, type GridTestState } from './gridTestState.js'

View file

@ -164,8 +164,10 @@ export function AmbientDock(): ReactNode {
const ctx = { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never }
// paddingRight keeps card borders off the terminal's last column — an
// exact-edge border char trips pending-wrap and reads as a clipped border.
return (
<Box columnGap={1} flexDirection="row" justifyContent="flex-end" width="100%">
<Box columnGap={1} flexDirection="row" justifyContent="flex-end" paddingRight={2} width="100%">
{overlay.ambient.map(active => (
<Box key={active.appId}>{renderApp(active, ctx)}</Box>
))}

View file

@ -46,3 +46,4 @@ export type { Theme, ThemeColors } from '../theme.js'
export { ActiveWidgetSlot, closeWidget, dispatchWidgetInput, launchWidget, openWidget, updateWidget } from './host.js'
export { defineWidgetApp, getWidgetApp, listWidgetApps } from './registry.js'
export { type ActiveWidget, isCtrl, type WidgetApp, type WidgetInput, type WidgetRenderCtx } from './types.js'
export { loadUserWidgets, type UserWidgetLoadResult, widgetSdk, type WidgetSdk } from './userWidgets.js'

View file

@ -12,6 +12,9 @@ export function defineWidgetApp<S>(app: WidgetApp<S>): WidgetApp<S> {
export const getWidgetApp = (id: string): undefined | WidgetApp<never> => apps.get(id)
/** Unregister (user-widget file deleted). Built-ins never call this. */
export const removeWidgetApp = (id: string): boolean => apps.delete(id)
/** All registered apps, id-sorted the registry IS the catalog: slash
* commands and `/` completions derive from it, nothing is hardcoded. */
export const listWidgetApps = (): WidgetApp<never>[] => [...apps.values()].sort((a, b) => a.id.localeCompare(b.id))

View file

@ -0,0 +1,202 @@
import { watch } from 'fs'
import { readdir } from 'fs/promises'
import { homedir } from 'os'
import { dirname, join } from 'path'
import { pathToFileURL } from 'url'
import { Box, Text } from '@hermes/ink'
import * as React from 'react'
import { Dialog, Overlay } from '../components/overlay.js'
import { GridAreas, WidgetGrid } from '../components/widgetGrid.js'
import { recordParentLifecycle } from '../lib/parentLog.js'
import { openWidget, updateWidget } from './host.js'
import { defineWidgetApp, listWidgetApps, removeWidgetApp } from './registry.js'
import { isCtrl } from './types.js'
/**
* User widget apps Hermes authors its own TUI widgets, mirroring the
* Python plugin contract: drop `<name>.mjs` into `$HERMES_HOME/tui-widgets/`,
* default-export `register(sdk)`, and the app surfaces in `/` completions
* and dispatch automatically (the registry is the catalog). Plain ESM so the
* production bundle can import it no bundler, no JSX; `sdk.h` is
* React.createElement.
*
* Trust model matches `~/.hermes/plugins/`: files under HERMES_HOME execute
* with the TUI's privileges. Load errors log and skip a broken widget
* never takes the TUI down.
*/
/** Everything a user widget may touch, passed INTO its register() user
* files have no resolvable import path to the bundle. */
export const widgetSdk = {
Box,
Dialog,
GridAreas,
Overlay,
React,
Text,
WidgetGrid,
defineWidgetApp,
h: React.createElement,
isCtrl,
openWidget,
updateWidget
} as const
export type WidgetSdk = typeof widgetSdk
const widgetsDir = () => join(process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes'), 'tui-widgets')
export interface UserWidgetLoadResult {
/** App ids newly registered by this scan. */
added: string[]
errors: { file: string; message: string }[]
loaded: string[]
/** App ids unregistered because their file disappeared. */
removed: string[]
}
/** Which app ids each user file registered the delete-sync source of
* truth (file gone on the next scan its apps unregister). */
const fileApps = new Map<string, string[]>()
const listeners = new Set<(result: UserWidgetLoadResult) => void>()
/** Subscribe to scan results the app layer announces loads in the
* transcript so a hot-loaded widget is VISIBLY live (silent success is
* indistinguishable from failure). */
export function onUserWidgets(listener: (result: UserWidgetLoadResult) => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
/** Scan + import + register, diffing the registry per file. Cache-busted so
* edits reload without restarting the TUI (last-writer-wins shadows stale
* definitions). Files that vanished unregister their apps. */
export async function loadUserWidgets(dir = widgetsDir()): Promise<UserWidgetLoadResult> {
const result: UserWidgetLoadResult = { added: [], errors: [], loaded: [], removed: [] }
let files: string[] = []
try {
files = (await readdir(dir)).filter(f => f.endsWith('.mjs')).sort()
} catch {
// No directory: fall through so previously-loaded files still delete-sync.
}
for (const [file, ids] of fileApps) {
if (!files.includes(file)) {
fileApps.delete(file)
for (const id of ids) {
if (removeWidgetApp(id)) {
result.removed.push(id)
}
}
}
}
for (const file of files) {
const before = new Set(listWidgetApps().map(app => app.id))
try {
const mod = (await import(`${pathToFileURL(join(dir, file)).href}?t=${Date.now()}`)) as {
default?: (sdk: WidgetSdk) => void
}
if (typeof mod.default !== 'function') {
throw new Error('default export must be register(sdk)')
}
mod.default(widgetSdk)
result.loaded.push(file)
const ids = listWidgetApps()
.map(app => app.id)
.filter(id => !before.has(id))
// Re-registrations of existing ids keep their prior file attribution.
if (ids.length) {
fileApps.set(file, ids)
result.added.push(...ids)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
result.errors.push({ file, message })
recordParentLifecycle(`user widget ${file} failed to load: ${message}`)
}
}
if (result.added.length) {
recordParentLifecycle(`user widgets registered: ${result.added.join(', ')}`)
}
for (const listener of listeners) {
listener(result)
}
return result
}
let watching = false
/** Generative-UI hot loading: watch the widgets directory and re-scan on
* every change, so a widget Hermes writes appears within ~a second no
* `/widgets-reload`, no restart (GUI parity). Debounced (editors and
* write_file emit bursts); polls until the directory exists so the very
* first widget ever written also hot-loads. */
export function watchUserWidgets(dir = widgetsDir()): void {
if (watching) {
return
}
watching = true
let timer: NodeJS.Timeout | undefined
const attach = () => {
try {
const watcher = watch(dir, () => {
clearTimeout(timer)
timer = setTimeout(() => void loadUserWidgets(dir), 300)
timer.unref?.()
})
watcher.unref?.()
return true
} catch {
return false // directory doesn't exist yet
}
}
if (!attach()) {
// Event-driven first-creation: watch the PARENT for the widgets dir to
// appear, attach + scan the instant it does. The very first widget a
// user (or Hermes) ever writes must hot-load too — a 10s poll here read
// as "requires a restart" in live use.
try {
const parent = watch(dirname(dir), () => {
if (attach()) {
parent.close()
void loadUserWidgets(dir)
}
})
parent.unref?.()
} catch {
const poll = setInterval(() => {
if (attach()) {
clearInterval(poll)
void loadUserWidgets(dir)
}
}, 2_000)
poll.unref?.()
}
}
}