Imported from AlvaroVFon/opencode-agent-monitor (
src/tui/AGENTS.md). Install upstream withnpx skills add AlvaroVFon/opencode-agent-monitor --skill tui. Copyright stays with the author.
AGENTS.md — src/tui/
TUI plugin for OpenCode. Reads per-session .jsonl files (written by the server plugin in ../server/), aggregates events in memory, and renders a live sidebar panel + a fullscreen dialog (Ctrl+A).
Entry point
agent-monitor-tui.tsxexportsdefault { id, tui }— theTuiPluginshape.SIDEBAR_ORDER(currently1) is the slot ordering constant — tested by static source analysis in../test/tui/sidebar-order.test.ts. Do not hardcode a number inapi.slots.register; reference the constant.- Trace dir resolution order (do not change without updating tests):
options.traceDirfrom the plugin's options objectprocess.env.AGENT_MONITOR_DIRjoin(homedir(), ".config", "opencode", ".tracing")(default)
Architecture
{sessionID}.jsonl → SessionWatcher → AggregatorStore.ingest(event) → emitSnapshot()
→ Solid signal
→ Components
→ TUI slots
SessionWatcher(session-watcher.ts) — per-session incremental file reader. Resolves file path fromtraceDir+sessionIDviasessionFS.sessionFilePath.fs.watch+ 250ms polling fallback. Tracks byte cursor. Detects truncation/rotation. Silently skips malformed JSON lines. ReplacesJsonlTailer.AggregatorStore(aggregator-store.ts) — in-memory state machine. MaintainsbyAgent,bySession,byModel,byAgentModel. CallsonSnapshotcallback on everyingest(). Supportsingest(event, { silent: true })+flush()for batch loading.snapshot()returns a deep-clonedMetricsSnapshot.- Per-session cursor is persisted via
api.kv.get("agent_monitor_cursor_{safeSessionId}")/api.kv.set(...). Persistence is debounced (1s timer) and also flushed inonDispose.
@opentui/solid JSX runtime
All components use the @opentui/solid JSX runtime. Available elements:
<box>,<text>,<span>,<scrollbox>- Props:
flexDirection,padding,style={{ fg: ..., bold: true, dim: true }} - Hooks:
useKeyboardfrom@opentui/solidfor key handling
This is not DOM JSX and not standard Solid — it is the OpenTUI renderer. Do not assume web style or class semantics.
ADR-003: Components are implementation-only
No unit tests for Solid components. The TUI tests cover pure formatters, the session watcher, the aggregator store, and a static-analysis test for SIDEBAR_ORDER. Components (agent-cost-panel.tsx, fullscreen-stats-dialog.tsx) are validated manually in the TUI host.
When adding a feature to a component:
- Extract logic into a pure function in
formatters/(e.g.formatTotalsRow,formatPanelHeader,toggleCollapsed,capitalizeName,getAgentColor). These get TDD coverage. - The component itself is a thin wiring layer that calls those helpers.
- If a piece of state lives in the component (e.g. a
collapsedsignal), back it with a pure helper so its transitions are testable. - If a piece of state lives in the data layer (
lastActiveAgentonAggregatorStore), test it at the store level.
This is ADR-005. Don't try to test components with jsdom or similar — the project explicitly avoids a DOM environment.
Formatter conventions (formatters/)
All formatters are pure functions that take typed inputs and return either a string or a small object. No I/O, no state, no side effects.
- Use
n.toLocaleString("en-US")for human-readable numbers. - Format costs as
$X.toFixed(4). formatDuration(ms)— caps at hours, no days/years. Scale:<1s→Xms,<60s→X.Xs,<60m→XmXs,>=1h→XhXm.formatAgentRow(agent, aggregate)— one-line summary;formatAgentRows(byAgent)returns the full list sorted by cost descending.getAgentColor(name)— deterministic DJB2 hash into a 5-color palette (accent,secondary,info,success,warning).- Test pattern: pure function unit tests in
../test/tui/format-*.test.ts. Fixture factories withoverridesparameter.
AggregatorStore details
ingest(event)dispatches onevent.typeand updates relevant maps via privateaddLlm/addToolhelpers.- Five internal
Maps:byAgent,bySession,byModel,byAgentModel(nested), plus alastActiveAgentfield. lastActiveAgentis out-of-order safe (ADR-006): updated only onllm_callevents, and only if the incomingtimestamp >= current.lastActiveAgent.timestamp. Tool calls, session events, and agent delegations do not change it.snapshot()deep-clones everything viaObject.fromEntriesand aggregate cloning. Callers can mutate the result safely.reset()clears all state includinglastActiveAgent.onSnapshotcallback fires synchronously insideingest(). The TUI entry wraps this in a Solid signal setter.
SessionWatcher details
- Constructor:
new SessionWatcher(traceDir, sessionID, { onLine, onError, pollIntervalMs? }). - Resolves file path via
sessionFS.sessionFilePath(traceDir, sessionID). start(cursor?)— if cursor is provided, skips to that byte position. Idempotent.stop()— clears watcher and poll timer. Sets_started = falsesostart()can be called again.- Truncation/rotation detection: if
stats.size < _cursoror the first line changes, treats it as a reset. - Malformed JSON lines are silently swallowed — do not add
console.warnfor them. onErrorcallbacks are wrapped in try/catch — a throw inside the callback does not crash the watcher.
Test patterns
Tests live in ../test/tui/. Same node:test + node:assert/strict setup as the server side.
Pure formatter tests
- No mocking. No fixtures beyond simple factory functions like
makeAggregateandmakeSnapshot. - Assert on exact output shape, including the object key set (e.g.
assert.deepEqual(Object.keys(result).sort(), ['avgCostPerCall', 'calls', 'errors'])). - Test edge cases: zero values, thousands separators, single-agent vs N agents, empty inputs.
Aggregator store tests (aggregator-store.test.ts)
- Fixture factories:
makeLlmCallEvent,makeToolCallEvent,makeSessionCreatedEvent,makeSessionErrorEvent,makeAgentDelegationEvent, all withoverrides. lastActiveAgenthas its owndescribeblock with 12 cases — out-of-order timestamp safety, non-regression on other event types, reset behavior, snapshot clone immutability.- Cross-validation test against
scripts/metrics.mtswas removed together with the script (superseded by CLI).
Static source analysis test (sidebar-order.test.ts)
- Cannot import the TUI module directly because it transitively pulls in
@opentui/solid, which uses top-level await and is incompatible with the test runner's transform. - Workaround: read the
.tsxfile withfs.readFileSync, extract theSIDEBAR_ORDERconstant with regex, and assert on it. Same approach used to assert the constant is referenced inapi.slots.register(not a hardcoded literal).
ESM import extensions
All local imports in src/tui/ use .js extensions (e.g. import { ... } from "../shared/metrics.types.js"). This is required by the project's ESM resolution. Do not drop the extension.
Lifecycle and cleanup
The TUI plugin registers and must clean up on onDispose:
api.slots.register({...})— returns nothing to unregister; slot lifecycle is managed by the TUI host.api.keymap.registerLayer({...})— returns anunregisterfunction; call it inonDispose.watcher.stop()— clear watcher and poll timer; flush per-session cursor toapi.kvif there's a pending debounce.- The
SessionWatcherlifecycle is managed reactively viacreateEffectinSidebarContentPanel, watchingprops.sessionID. On session switch: persist old cursor, stop old watcher, create new watcher at stored cursor, and batch-load initial events with{ silent: true }+flush().
Adding a new panel feature
- Extract the formatting/state logic into a pure function in
formatters/. - Add a unit test in
../test/tui/format-<name>.test.tswith edge cases. - Wire the pure function into the component as a thin call.
- If the feature needs a new signal/state, put it in the component only if it's UI-local; otherwise expose it from
AggregatorStoreand test at the store level.