6.3 KiB
TUI Widgets — Live Panels for the Ink TUI Dock
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_fileto create~/.hermes/tui-widgets/<name>.mjs(seetemplates/clock.mjsfor a complete working widget). - If the TUI is running it hot-loads the file within ~a second (the
widgets directory is watched);
/widgets-reloadforces a rescan. - The widget's id becomes its slash command automatically (
/<id>), with itshelpin the/completion popover. No other registration exists. - Auto-open (no command needed): end
register(sdk)withsdk.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
Dialogan explicitwidth; charts already return exactly thewidthyou ask for (short series pad-left while history warms up). - Pad dynamic numbers:
String(v).padStart(6)—51 ms→112 msmust 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 trendsdk.sparkRows(series, width, rows)→ multi-row column chart (top line first) — the mission-control panel look; taller cells gain resolutionsdk.gauge(ratio, width)→█████░░░fill bar for a 0..1 valuesdk.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;renderreturns a CARD (usuallyDialog), neverOverlay. Placement viazone— 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. Setwidthon 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.
- Docks (chrome rows):
mode: 'modal'(default) — owns every keypress;reducereturns next state, the same reference to swallow a key, ornullto close;renderwraps content inOverlayfor placement.- Async data: fire the fetch from
init, land results withsdk.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/skinand 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 thesdkparameter;h(...)builds elements. - Don't ship a modal without a close path (
Esc/qreturningnull). - 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.logif 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.