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
- Use
write_file to create ~/.hermes/tui-widgets/<name>.mjs (see
templates/clock.mjs for a complete working widget).
- If the TUI is running it hot-loads the file within ~a second (the
widgets directory is watched);
/widgets-reload forces a rescan.
- The widget's id becomes its slash command automatically (
/<id>), with
its help in the / completion popover. No other registration exists.
- Auto-open (no command needed): end
register(sdk) with
sdk.openWidget(app, app.init('')) — the widget docks itself the moment
the file loads. Only do this when the user asked for it; note it re-docks
on every /widgets-reload.
Quick Reference
A widget file default-exports register(sdk):
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, and loaders Shimmer,
ShimmerRows, useShimmerPhase — use ShimmerRows for loading phases
instead of a bare "loading…" line.
Expand/collapse: sdk.Accordion — the same primitive the session panel's
tool/skill sections use. h(Accordion, { t, title: 'details', count: 3, defaultOpen: false }, body) toggles on CLICK (works in ambient widgets,
which receive no keys); modal apps may pass open + onToggle to drive it
from reducer state instead.
Stable sizing (cards must NEVER resize while ticking):
- Give
Dialog an explicit width; charts already return exactly the
width you ask for (short series pad-left while history warms up).
- Pad dynamic numbers:
String(v).padStart(6) — 51 ms → 112 ms must
not change the line length.
- Keep row counts constant per phase; swap content, not structure.
Charts (pure string builders — color the result with theme tones):
sdk.sparkline(series, width?) → ▂▃▅▇█▆ one-row trend
sdk.sparkRows(series, width, rows) → multi-row column chart (top line
first) — the mission-control panel look; taller cells gain resolution
sdk.gauge(ratio, width) → █████░░░ fill bar for a 0..1 value
sdk.hbars(values, width) → horizontal bar chart, one bar per value,
eighth-block tips, scaled to the max
Keep a rolling series in component state (push per tick, cap ~120 samples)
and render sparkRows for dashboard panels, sparkline for one-liners.
Contract essentials:
mode: 'ambient' — captures no input, the command toggles it; render
returns a CARD (usually Dialog), never Overlay. Placement via zone — every zone RESERVES real space (nothing ever
paints over the transcript):
- Docks (chrome rows):
dock-top (under the top status bar),
dock-bottom (default — above the bottom one).
- Rails (side columns beside the transcript; text reflows around them):
top-left, top-right, bottom-left, bottom-right — corner names
pick the rail side and its top/bottom anchor. Set width on the app
to the card's width (match your Dialog width; default 44) — the rail
reserves exactly that many columns.
Map the user's words to the nearest zone: "top right" → top-right,
"above/next to the status bar" → a dock. Rails suit narrow cards
(~30-46 cols); full-width or short-and-wide content belongs in a dock.
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
- Pick
id, mode, and the state shape; keep state serializable.
- Write the file from the template; wire data via
init + updateWidget.
/<id> to launch (hot-loaded on write); relaunch /<id> to dismiss an
ambient widget.
- 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.
1---2name: tui-widgets3description: Author live widget apps for the Hermes TUI dock.4license: MIT5---6
7# TUI Widgets Skill
8
9Author widget apps for the Hermes TUI (`hermes --tui`): glanceable ambient
10panels docked above the status bar, or modal overlays that own the keyboard.
11Widgets are plain ESM files the TUI loads at startup — no build step, no
12repo changes. This skill does not cover desktop-app or web-dashboard
13widgets.
14
15## When to Use
16
17- The user asks for a live panel in the TUI (ticker, clock, countdown,
18 status card, API-backed readout).
19- The user wants a custom modal tool (picker, calculator, viewer) bound to
20 a slash command.
21
22## Prerequisites
23
24- The TUI must be in use (`hermes --tui`). Widgets do not render in the
25 classic CLI or messaging platforms.
26- Network-backed widgets need whatever credentials their API needs; fetch
27 failures must land as an error phase, never a crash.
28
29## How to Run
30
311. Use `write_file` to create `~/.hermes/tui-widgets/<name>.mjs` (see
32 `templates/clock.mjs` for a complete working widget).
332. If the TUI is running it hot-loads the file within ~a second (the
34 widgets directory is watched); `/widgets-reload` forces a rescan.
353. The widget's id becomes its slash command automatically (`/<id>`), with
36 its `help` in the `/` completion popover. No other registration exists.
374. Auto-open (no command needed): end `register(sdk)` with
38 `sdk.openWidget(app, app.init(''))` — the widget docks itself the moment
39 the file loads. Only do this when the user asked for it; note it re-docks
40 on every `/widgets-reload`.
41
42## Quick Reference
43
44A widget file default-exports `register(sdk)`:
45
46```js
47export default function register(sdk) {
48 const { Box, Text, defineWidgetApp, h } = sdk
49
50 defineWidgetApp({
51 id: 'clock', // slash command name
52 help: 'live clock in the dock', // `/` completion metadata
53 mode: 'ambient', // 'ambient' docks; 'modal' takes input
54 init: arg => ({ label: arg.trim() || 'UTC' }), // null = print usage
55 reduce: (state, { ch, key }) => (key.escape || ch === 'q' ? null : state),
56 render: ({ state, t }) => h(sdk.Dialog, { width: 24 }, h(Text, { color: t.color.label }, state.label))
57 })
58}
59```
60
61`sdk` contents: `defineWidgetApp`, `openWidget`, `updateWidget`, `isCtrl`,
62`React`, `h` (createElement — no JSX in .mjs), components `Box`, `Text`,
63`Dialog`, `Overlay`, `WidgetGrid`, `GridAreas`, and loaders `Shimmer`,
64`ShimmerRows`, `useShimmerPhase` — use `ShimmerRows` for loading phases
65instead of a bare "loading…" line.
66
67Expand/collapse: `sdk.Accordion` — the same primitive the session panel's
68tool/skill sections use. `h(Accordion, { t, title: 'details', count: 3,
69defaultOpen: false }, body)` toggles on CLICK (works in ambient widgets,
70which receive no keys); modal apps may pass `open` + `onToggle` to drive it
71from reducer state instead.
72
73Stable sizing (cards must NEVER resize while ticking):
74
75- Give `Dialog` an explicit `width`; charts already return exactly the
76 `width` you ask for (short series pad-left while history warms up).
77- Pad dynamic numbers: `String(v).padStart(6)` — `51 ms` → `112 ms` must
78 not change the line length.
79- Keep row counts constant per phase; swap content, not structure.
80
81Charts (pure string builders — color the result with theme tones):
82
83- `sdk.sparkline(series, width?)` → `▂▃▅▇█▆` one-row trend
84- `sdk.sparkRows(series, width, rows)` → multi-row column chart (top line
85 first) — the mission-control panel look; taller cells gain resolution
86- `sdk.gauge(ratio, width)` → `█████░░░` fill bar for a 0..1 value
87- `sdk.hbars(values, width)` → horizontal bar chart, one bar per value,
88 eighth-block tips, scaled to the max
89
90Keep a rolling series in component state (push per tick, cap ~120 samples)
91and render `sparkRows` for dashboard panels, `sparkline` for one-liners.
92
93Contract essentials:
94
95- `mode: 'ambient'` — captures no input, the command toggles it; `render`
96 returns a CARD (usually `Dialog`), never `Overlay`. Placement via `zone` — every zone RESERVES real space (nothing ever
97 paints over the transcript):
98 - Docks (chrome rows): `dock-top` (under the top status bar),
99 `dock-bottom` (default — above the bottom one).
100 - Rails (side columns beside the transcript; text reflows around them):
101 `top-left`, `top-right`, `bottom-left`, `bottom-right` — corner names
102 pick the rail side and its top/bottom anchor. Set `width` on the app
103 to the card's width (match your Dialog width; default 44) — the rail
104 reserves exactly that many columns.
105 Map the user's words to the nearest zone: "top right" → `top-right`,
106 "above/next to the status bar" → a dock. Rails suit narrow cards
107 (~30-46 cols); full-width or short-and-wide content belongs in a dock.
108- `mode: 'modal'` (default) — owns every keypress; `reduce` returns next
109 state, the same reference to swallow a key, or `null` to close; `render`
110 wraps content in `Overlay` for placement.
111- Async data: fire the fetch from `init`, land results with
112 `sdk.updateWidget(app, fn)` — it no-ops if the widget was closed, so a
113 late reply can never resurrect it.
114- Animation: own a timer inside a component via `React.useState` +
115 `React.useEffect` (see the template); keep intervals ≥ 250ms.
116- Colors: ALWAYS theme tones (`t.color.primary/label/muted/ok/error/…`),
117 never hardcoded hexes — widgets must survive `/skin` and light/dark.
118
119## Procedure
120
1211. Pick `id`, `mode`, and the state shape; keep state serializable.
1222. Write the file from the template; wire data via `init` + `updateWidget`.
1233. `/<id>` to launch (hot-loaded on write); relaunch `/<id>` to dismiss an
124 ambient widget.
1254. Iterate: edit the file — it hot-reloads on save (last-writer-wins, the
126 fresh definition shadows the old one). Relaunch `/<id>` to remount.
127
128## Pitfalls
129
130- No JSX and no bare imports in `.mjs` — everything comes from the `sdk`
131 parameter; `h(...)` builds elements.
132- Don't ship a modal without a close path (`Esc`/`q` returning `null`).
133- Ambient widgets must stay small (≤ ~6 rows) — the dock sits between the
134 transcript and the status bar.
135- A thrown `register()` is logged and skipped; check
136 `~/.hermes/logs/tui_gateway_crash.log` if a widget never appears.
137
138## Verification
139
140Run `/widgets-reload` — the transcript line must list the file under
141`loaded:`. Then `/<id>`: an ambient widget appears docked right, above the
142status bar, while the composer keeps accepting input; `/<id>` again removes
143it.