Guidance for AI coding agents working with code in this repository.
An OpenCode TUI plugin that renders a Timing panel into the session sidebar, showing per-session API/wall-clock timing derived purely from message timestamps. Published to npm as @foae/opencode-timings. Two source files: src/timing.ts (pure logic) and src/tui.tsx (the Solid component + slot registration).
bun run typecheck—tsc --noEmitoversrcandtest.bun test— runs the unit tests intest/(Bun's built-in runner). A single file:bun test test/timing.test.ts; a single case:bun test -t "<name substring>".bun run build—scripts/build.ts: compilessrc/todist/with babel-preset-solid (see Packaging).bun run prepublishOnly— typecheck + test + build; the publish gate. Runs automatically onnpm/bun publish.
The package ships compiled dist/tui.js/dist/timing.js (see files + exports in package.json); exports["./tui"] points at dist/tui.js. Runtime is Bun, not Node. Typecheck + tests are the local gates. End-to-end verification IS possible on this machine: drive opencode inside tmux (tmux new-session -d -s x 'cd <scratch-dir> && opencode', tmux send-keys, tmux capture-pane -p) after copying a freshly built dist/ into the installed cache at ~/.cache/opencode/packages/@foae/opencode-timings@latest/node_modules/@foae/opencode-timings/.
Two files, deliberately split so the math is testable without the rendering runtime:
src/timing.ts— pure, JSX-free, unit-tested. All metric computation (computeTiming), formatting (fmtDuration,bar,sparkline), row assembly (buildRows), and config parsing (parseConfig). It imports only a type from the SDK, so it loads under plainbun testwith no opentui/Solid preload. Put new logic here, not intui.tsx, and cover it intest/timing.test.ts.src/tui.tsx— the Solid component + plugin registration. Imports everything pure from./timing.tsand adds only theSidebarTimingViewcomponent, the event/timer wiring, andapi.slots.register.
Key facts that aren't obvious from a single read:
-
SolidJS, not React.
jsxImportSourceis@opentui/solid(set in bothtsconfig.jsonand the@jsxImportSourcepragma). Reactive values are accessor functions (timing(),rows()); elements are lowercase opentui intrinsics (<box>,<text>), not DOM. Lists use<Index>/<For>, not.map()(which recreates rather than reconciles). Plugin API types live innode_modules/@opencode-ai/plugin/dist/tui.d.ts— consultTuiPluginApi/TuiState/TuiHostSlotMapthere. -
Entry shape. Default export is a
TuiPluginModule({ id, tui }).tui(api, options)callsapi.slots.register({ order, slots: { sidebar_content(...) } }).SIDEBAR_ORDER = 155places it just below the built-in Quota panel (order 150) and above the variable-height MCP/LSP/Todo/Files sections. -
Zero context-window pollution is the core design constraint. The panel only reads session messages via
api.state.session.messages(sessionID)and computes client-side. It must never inject into the message stream — preserve this when changing data sources. -
Metrics (
computeTiming). Derived frommsg.time.created/msg.time.completed:api= sum ofcompleted − createdover completed assistant messages (inference time). Only counted whendur > 0, which also discards negative (clock-skew) and missing-createdrows.wall= span from earliest to latest timestamp across all messages (includes user read/think time).apiPctis clamped to 0–100: summed per-turn inference over a single wall span can exceed 100% with overlapping/retried records or clock skew, so the percent and the gauge bar both clamp.- Also
turns,avg,slowest, and a per-turndurationsarray for the sparkline.
-
Refresh model. Event-driven via
api.event.onformessage.updated,message.removed,session.updated,session.idle. The per-event property paths intentionally differ —message.updated/session.updatedcarry a nestedinfoobject (info.sessionID/info.id, no top-levelsessionID), whilemessage.removed/session.idlecarrysessionIDdirectly. This matches the SDK event types; do not "standardize" them toproperties.sessionID(it'sundefinedfor two of the four and silently breaks refresh). AcreateEffectonprops.sessionIDre-reads on session switch and re-arms the async-hydration recoverysetTimeouts (400/1500/4000ms) per session; a 15ssetIntervalis a low-frequency backstop.read()is wrapped in try/catch (returnsEMPTY) becauseapi.statecan throw mid-read if a session is torn down. All timers/listeners are torn down viaonCleanup. -
Config.
parseConfigreads the tuple-form options (["@foae/opencode-timings", { mode, fields }]):modeis"fancy"(default) or"simple";fieldstoggles individual values (ratio,api,wall,turns,avg,slow,sparkline), all defaultingtrue. Each field gates exactly one value —buildRowspacks related values (api/wall,turns/avg) onto shared lines withpackRow, dropping each independently, so the field-to-value mapping stays clean.ratiois theapi/wallgauge (bar + percent in fancy, percent only in simple);sparklineis fancy-only.parseConfigiteratesDEFAULT_FIELDS, so adding a field there is enough to make it parseable. Unknown/malformed options fall back to defaults rather than throwing.
- Ship compiled
dist/, never raw.tsx. OpenCode installs npm plugins under~/.cache/opencode/packages/<spec>/node_modules/..., and since@opentui/solid0.4.x (OpenCode 1.18.x) the host's Solid JSX transform has a filter that excludesnode_modules— raw.tsxfalls back to Bun's plain automatic JSX runtime, which evaluates{expr}children eagerly exactly once: the panel renders its mount-time snapshot (all zeros) and never updates, with no error anywhere.scripts/build.tstherefore pre-compiles withbabel-preset-solid(generate: "universal",moduleName: "@opentui/solid"— the same preset+options the host's own transform uses) and asserts the output is JSX-free and imports the@opentui/solidruntime. This matches how other working TUI plugins (e.g.@slkiser/opencode-quota) ship. - Bare
@opentui/solid/solid-jsimports indistare mapped to the host's instances at load time by OpenCode's Bun runtime plugin (ensureRuntimePluginSupportregisters exact-specifier resolvers), so the compiled code shares the host's reactive runtime even though npm also installs local copies of the peers into the plugin'snode_modules. @opentui/core,@opentui/solid,solid-jsarepeerDependencies(withdevDependenciesmirrors for typecheck/test/build). Keep the peer ranges aligned with the host. Do not move these todependencies(forks the Solid/opentui runtime).@opencode-ai/pluginand@opencode-ai/sdkare type-only imports (erased at runtime) — they stay in dev/peer deps, neverdependencies.
- Keep rendering robust to empty/partial state — every path must produce a valid panel (reading zeros) before the first turn.
EMPTYis the canonical zero state. - Every sidebar row is self-labeling (no bare numbers/glyphs). The header comment in
src/tui.tsx, the metric doc insrc/timing.ts, and the README's Metrics table must stay in sync with rendered output — update all when changing rows.