From ca0b1b130c9ab997eb8115e3f9f70bf336ae1610 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 22:44:22 -0500 Subject: [PATCH] =?UTF-8?q?feat(ui-tui):=20self-authored=20widgets=20?= =?UTF-8?q?=E2=80=94=20user-widget=20loader,=20hot-load,=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- skills/productivity/tui-widgets/SKILL.md | 107 ++++++++++ .../tui-widgets/templates/clock.mjs | 51 +++++ ui-tui/src/__tests__/userWidgets.test.ts | 67 ++++++ ui-tui/src/app/createSlashHandler.ts | 15 ++ ui-tui/src/app/slash/commands/debug.ts | 16 ++ ui-tui/src/app/useMainApp.ts | 21 ++ ui-tui/src/sdk/apps/index.ts | 8 +- ui-tui/src/sdk/host.tsx | 4 +- ui-tui/src/sdk/index.ts | 1 + ui-tui/src/sdk/registry.ts | 3 + ui-tui/src/sdk/userWidgets.ts | 202 ++++++++++++++++++ 11 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 skills/productivity/tui-widgets/SKILL.md create mode 100644 skills/productivity/tui-widgets/templates/clock.mjs create mode 100644 ui-tui/src/__tests__/userWidgets.test.ts create mode 100644 ui-tui/src/sdk/userWidgets.ts diff --git a/skills/productivity/tui-widgets/SKILL.md b/skills/productivity/tui-widgets/SKILL.md new file mode 100644 index 000000000000..8c19acaac862 --- /dev/null +++ b/skills/productivity/tui-widgets/SKILL.md @@ -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/.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 (`/`), 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. `/` to launch (hot-loaded on write); relaunch `/` 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 `/` 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 `/`: an ambient widget appears docked right, above the +status bar, while the composer keeps accepting input; `/` again removes +it. diff --git a/skills/productivity/tui-widgets/templates/clock.mjs b/skills/productivity/tui-widgets/templates/clock.mjs new file mode 100644 index 000000000000..fea22af3499f --- /dev/null +++ b/skills/productivity/tui-widgets/templates/clock.mjs @@ -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 })) + } + }) +} diff --git a/ui-tui/src/__tests__/userWidgets.test.ts b/ui-tui/src/__tests__/userWidgets.test.ts new file mode 100644 index 000000000000..59aa6f71c313 --- /dev/null +++ b/ui-tui/src/__tests__/userWidgets.test.ts @@ -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() + }) +}) diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index a164511c7585..dc798e842c6d 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -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] diff --git a/ui-tui/src/app/slash/commands/debug.ts b/ui-tui/src/app/slash/commands/debug.ts index 7fa0b3797c71..e93208f1b8e5 100644 --- a/ui-tui/src/app/slash/commands/debug.ts +++ b/ui-tui/src/app/slash/commands/debug.ts @@ -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', diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 80a863c45bf5..586d30716e42 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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 } }), [] diff --git a/ui-tui/src/sdk/apps/index.ts b/ui-tui/src/sdk/apps/index.ts index 34037745f223..35bd0587f649 100644 --- a/ui-tui/src/sdk/apps/index.ts +++ b/ui-tui/src/sdk/apps/index.ts @@ -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' diff --git a/ui-tui/src/sdk/host.tsx b/ui-tui/src/sdk/host.tsx index 72713f23b2bd..197f77e71ee0 100644 --- a/ui-tui/src/sdk/host.tsx +++ b/ui-tui/src/sdk/host.tsx @@ -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 ( - + {overlay.ambient.map(active => ( {renderApp(active, ctx)} ))} diff --git a/ui-tui/src/sdk/index.ts b/ui-tui/src/sdk/index.ts index 1c2336cfc3c3..772948e57695 100644 --- a/ui-tui/src/sdk/index.ts +++ b/ui-tui/src/sdk/index.ts @@ -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' diff --git a/ui-tui/src/sdk/registry.ts b/ui-tui/src/sdk/registry.ts index 2a1264987316..3a94af11357a 100644 --- a/ui-tui/src/sdk/registry.ts +++ b/ui-tui/src/sdk/registry.ts @@ -12,6 +12,9 @@ export function defineWidgetApp(app: WidgetApp): WidgetApp { export const getWidgetApp = (id: string): undefined | WidgetApp => 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[] => [...apps.values()].sort((a, b) => a.id.localeCompare(b.id)) diff --git a/ui-tui/src/sdk/userWidgets.ts b/ui-tui/src/sdk/userWidgets.ts new file mode 100644 index 000000000000..f423204e1f88 --- /dev/null +++ b/ui-tui/src/sdk/userWidgets.ts @@ -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 `.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() + +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 { + 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?.() + } + } +}