Tui Widgets
Author live widget apps for the Hermes TUI dock.
Skill metadata
| Source | Bundled (installed by default) |
| Path | skills/productivity/tui-widgets |
| Version | 1.0.0 |
| Author | Hermes Agent |
| License | MIT |
| Tags | tui, widgets, sdk, ui |
Reference: full SKILL.md
The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active.
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_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.