@foae/opencode-timings 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +20 -3
  2. package/package.json +17 -10
  3. package/src/timing.ts +183 -0
  4. package/src/tui.tsx +48 -170
package/README.md CHANGED
@@ -9,6 +9,8 @@ Files panels, reading the session's messages directly from the TUI's reactive
9
9
  state. Nothing is ever injected into the message stream, so there is **zero
10
10
  context-window pollution**.
11
11
 
12
+ ![The Timing panel in OpenCode's sidebar — api/wall 34%, api 19s · wall 56s, turns 5 · avg 4s, slowest 6s — sitting between the built-in Quota and LSP sections](https://raw.githubusercontent.com/foae/opencode-timings/main/opencode-timings-screenshot.png)
13
+
12
14
  ```
13
15
  Timing
14
16
  api/wall ██████░░ 78%
@@ -61,7 +63,7 @@ Pass options using the tuple form (`[spec, options]`) in `tui.json`:
61
63
  "plugin": [
62
64
  ["@foae/opencode-timings@latest", {
63
65
  "mode": "fancy",
64
- "fields": { "api": true, "wall": true, "turns": true, "avg": true, "slow": true, "sparkline": true }
66
+ "fields": { "ratio": true, "api": true, "wall": true, "turns": true, "avg": true, "slow": true, "sparkline": true }
65
67
  }]
66
68
  ]
67
69
  }
@@ -69,8 +71,8 @@ Pass options using the tuple form (`[spec, options]`) in `tui.json`:
69
71
 
70
72
  | Option | Values | Default | Meaning |
71
73
  |----------|--------|---------|---------|
72
- | `mode` | `"fancy"` \| `"simple"` | `"fancy"` | `fancy` draws a bar gauge for the API/wall ratio and a sparkline of recent turn durations; `simple` is plain labeled rows. |
73
- | `fields` | object of booleans | all `true` | Toggle individual values: `api`, `wall`, `turns`, `avg`, `slow`, `sparkline` (`sparkline` is fancy-only). |
74
+ | `mode` | `"fancy"` \| `"simple"` | `"fancy"` | `fancy` draws the gauge bar on the `api/wall` row and adds the per-turn sparkline; `simple` is the same rows without the bar or sparkline. |
75
+ | `fields` | object of booleans | all `true` | Each toggles exactly one value: `ratio` (the `api/wall` gauge + percent), `api`, `wall`, `turns`, `avg`, `slow`, `sparkline` (`sparkline` is fancy-only). Values that share a line drop out individually. |
74
76
 
75
77
  With no options (a plain `"@foae/opencode-timings@latest"` string), it defaults to `fancy` mode with all fields shown.
76
78
 
@@ -78,6 +80,21 @@ With no options (a plain `"@foae/opencode-timings@latest"` string), it defaults
78
80
 
79
81
  - OpenCode `1.15.x` or newer (uses the TUI slot plugin API).
80
82
 
83
+ ## Development
84
+
85
+ Built and run with [Bun](https://bun.sh). The package ships raw source — there is no build step; OpenCode loads `src/tui.tsx` directly.
86
+
87
+ - `src/timing.ts` — pure timing math, formatting, and config parsing (no JSX), unit-tested.
88
+ - `src/tui.tsx` — the SolidJS sidebar component and the slot registration.
89
+
90
+ ```sh
91
+ bun install
92
+ bun run typecheck # tsc --noEmit
93
+ bun test # unit tests for the pure logic in src/timing.ts
94
+ ```
95
+
96
+ `opentui`, `solid-js`, and the OpenCode plugin/SDK are **peer dependencies** — at runtime they come from the OpenCode host so the plugin shares its renderer; the `devDependencies` mirror them for local typecheck and tests.
97
+
81
98
  ## License
82
99
 
83
100
  MIT — see [LICENSE](./LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foae/opencode-timings",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "OpenCode TUI sidebar panel showing per-session timing: total API/inference time, wall-clock, turns, average and slowest turn. Zero context-window pollution.",
5
5
  "license": "MIT",
6
6
  "author": "foae",
@@ -39,21 +39,28 @@
39
39
  "README.md",
40
40
  "LICENSE"
41
41
  ],
42
- "scripts": {
43
- "typecheck": "tsc --noEmit"
42
+ "engines": {
43
+ "bun": ">=1.0.0"
44
44
  },
45
- "dependencies": {
46
- "@opentui/core": "^0.1.107",
47
- "@opentui/solid": "^0.1.107",
48
- "solid-js": "^1.9.12"
45
+ "scripts": {
46
+ "typecheck": "tsc --noEmit",
47
+ "test": "bun test",
48
+ "prepublishOnly": "bun run typecheck && bun run test"
49
49
  },
50
50
  "peerDependencies": {
51
- "@opencode-ai/plugin": "^1.14.29"
51
+ "@opencode-ai/plugin": "^1.15.0",
52
+ "@opentui/core": ">=0.2.16",
53
+ "@opentui/solid": ">=0.2.16",
54
+ "solid-js": "^1.9.12"
52
55
  },
53
56
  "devDependencies": {
54
- "@opencode-ai/plugin": "^1.14.29",
55
- "@opencode-ai/sdk": "^1.14.29",
57
+ "@opencode-ai/plugin": "^1.15.0",
58
+ "@opencode-ai/sdk": "^1.15.0",
59
+ "@opentui/core": "^0.3.1",
60
+ "@opentui/solid": "^0.3.1",
61
+ "@types/bun": "^1.1.0",
56
62
  "@types/node": "^22.0.0",
63
+ "solid-js": "1.9.12",
57
64
  "typescript": "^5.8.0"
58
65
  }
59
66
  }
package/src/timing.ts ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Pure timing computation, formatting, and config parsing for the
3
+ * opencode-timings panel — deliberately free of JSX and any opentui/solid
4
+ * import so it loads under a plain TypeScript runtime (e.g. `bun test`) without
5
+ * OpenCode's Solid JSX preload. The rendering lives in `tui.tsx`.
6
+ *
7
+ * Metrics (per session):
8
+ * - api total assistant inference time = sum of (time.completed - time.created)
9
+ * over every completed assistant message.
10
+ * - wall span from the first to the last message timestamp (includes the time
11
+ * you spend reading/typing between turns), so api is a fraction of it.
12
+ * - apiPct api's share of wall-clock, clamped to 0–100 for the gauge/percent.
13
+ * - turns number of completed assistant messages, plus the average duration.
14
+ * - slowest the single slowest assistant message.
15
+ * - durations per-turn durations, fed to the sparkline.
16
+ */
17
+ import type { Message } from "@opencode-ai/sdk/v2"
18
+
19
+ // Narrower than the simple-mode rows so the "api/wall" label + gauge + percent
20
+ // fit one sidebar line without wrapping.
21
+ export const BAR_WIDTH = 8
22
+ export const SPARK_POINTS = 12
23
+ export const SPARK_LEVELS = "▁▂▃▄▅▆▇█"
24
+
25
+ export type Mode = "fancy" | "simple"
26
+ // Each field toggles exactly one displayed value. `ratio` is the api/wall gauge
27
+ // (the panel's headline metric); `sparkline` is fancy-only. Order matches the
28
+ // top-to-bottom render order.
29
+ export type Fields = {
30
+ ratio: boolean
31
+ api: boolean
32
+ wall: boolean
33
+ turns: boolean
34
+ avg: boolean
35
+ slow: boolean
36
+ sparkline: boolean
37
+ }
38
+ export const DEFAULT_FIELDS: Fields = {
39
+ ratio: true,
40
+ api: true,
41
+ wall: true,
42
+ turns: true,
43
+ avg: true,
44
+ slow: true,
45
+ sparkline: true,
46
+ }
47
+
48
+ export type Timing = {
49
+ apiMs: number
50
+ wallMs: number
51
+ turns: number
52
+ avgMs: number
53
+ slowestMs: number
54
+ apiPct: number
55
+ durations: number[]
56
+ }
57
+ export const EMPTY: Timing = { apiMs: 0, wallMs: 0, turns: 0, avgMs: 0, slowestMs: 0, apiPct: 0, durations: [] }
58
+
59
+ export function computeTiming(messages: ReadonlyArray<Message>): Timing {
60
+ let apiMs = 0
61
+ let slowestMs = 0
62
+ let minTs = Number.POSITIVE_INFINITY
63
+ let maxTs = 0
64
+ const durations: number[] = []
65
+
66
+ for (const msg of messages) {
67
+ const created = msg.time?.created
68
+ if (typeof created === "number") {
69
+ if (created < minTs) minTs = created
70
+ if (created > maxTs) maxTs = created
71
+ }
72
+ if (msg.role === "assistant") {
73
+ const completed = msg.time?.completed
74
+ if (typeof completed === "number") {
75
+ if (completed > maxTs) maxTs = completed
76
+ const dur = completed - (typeof created === "number" ? created : completed)
77
+ if (dur > 0) {
78
+ apiMs += dur
79
+ durations.push(dur)
80
+ if (dur > slowestMs) slowestMs = dur
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ const turns = durations.length
87
+ if (turns === 0) return EMPTY
88
+ const wallMs = minTs === Number.POSITIVE_INFINITY ? 0 : Math.max(0, maxTs - minTs)
89
+ return {
90
+ apiMs,
91
+ wallMs,
92
+ turns,
93
+ avgMs: apiMs / turns,
94
+ slowestMs,
95
+ // Summed per-turn inference vs a single wall span: overlapping/retried
96
+ // assistant records or clock skew can push the ratio past 100%, so clamp it
97
+ // (the gauge bar clamps too) to keep the percent honest.
98
+ apiPct: wallMs > 0 ? Math.min(100, Math.round((apiMs / wallMs) * 100)) : 0,
99
+ durations,
100
+ }
101
+ }
102
+
103
+ /** Compact, sidebar-friendly duration: "42s", "6m31s", "1h02m". */
104
+ export function fmtDuration(ms: number): string {
105
+ if (!Number.isFinite(ms) || ms < 0) ms = 0
106
+ const totalSec = Math.round(ms / 1000)
107
+ if (totalSec < 60) return `${totalSec}s`
108
+ const totalMin = Math.floor(totalSec / 60)
109
+ if (totalMin < 60) return `${totalMin}m${String(totalSec % 60).padStart(2, "0")}s`
110
+ const hours = Math.floor(totalMin / 60)
111
+ return `${hours}h${String(totalMin % 60).padStart(2, "0")}m`
112
+ }
113
+
114
+ export function bar(pct: number, width: number): string {
115
+ const p = Math.max(0, Math.min(100, Number.isFinite(pct) ? pct : 0))
116
+ const filled = Math.round((p / 100) * width)
117
+ return "█".repeat(filled) + "░".repeat(Math.max(0, width - filled))
118
+ }
119
+
120
+ export function sparkline(values: number[]): string {
121
+ if (values.length === 0) return ""
122
+ const points = values.slice(-SPARK_POINTS)
123
+ const max = Math.max(...points, 1)
124
+ return points
125
+ .map((v) => {
126
+ const idx = Math.round((v / max) * (SPARK_LEVELS.length - 1))
127
+ return SPARK_LEVELS[Math.min(SPARK_LEVELS.length - 1, Math.max(0, idx))]
128
+ })
129
+ .join("")
130
+ }
131
+
132
+ /** Join the enabled segments of a packed row with " · ", dropping the empties. */
133
+ function packRow(...segments: string[]): string {
134
+ return segments.filter(Boolean).join(" · ")
135
+ }
136
+
137
+ // Every row is a labeled string in the muted tone — no value relies on the
138
+ // reader inferring what an unlabeled number or glyph means. Each field toggles
139
+ // exactly one value; values that share a line drop out individually when their
140
+ // field is off. Fancy and simple render identically except that fancy draws the
141
+ // gauge bar on the ratio row and adds the per-turn sparkline.
142
+ export function buildRows(t: Timing, mode: Mode, fields: Fields): string[] {
143
+ const rows: string[] = []
144
+
145
+ if (fields.ratio) {
146
+ rows.push(mode === "fancy" ? `api/wall ${bar(t.apiPct, BAR_WIDTH)} ${t.apiPct}%` : `api/wall ${t.apiPct}%`)
147
+ }
148
+
149
+ const timesRow = packRow(
150
+ fields.api ? `api ${fmtDuration(t.apiMs)}` : "",
151
+ fields.wall ? `wall ${fmtDuration(t.wallMs)}` : "",
152
+ )
153
+ if (timesRow) rows.push(timesRow)
154
+
155
+ const turnsRow = packRow(
156
+ fields.turns ? `turns ${t.turns}` : "",
157
+ fields.avg ? `avg ${fmtDuration(t.avgMs)}` : "",
158
+ )
159
+ if (turnsRow) rows.push(turnsRow)
160
+
161
+ if (fields.slow) rows.push(`slowest ${fmtDuration(t.slowestMs)}`)
162
+
163
+ if (mode === "fancy" && fields.sparkline) {
164
+ const spark = sparkline(t.durations)
165
+ if (spark) rows.push(`per-turn ${spark}`)
166
+ }
167
+
168
+ return rows
169
+ }
170
+
171
+ export function parseConfig(options: Record<string, unknown> | undefined): { mode: Mode; fields: Fields } {
172
+ const opts = options ?? {}
173
+ const mode: Mode = opts.mode === "simple" ? "simple" : "fancy"
174
+ const fields: Fields = { ...DEFAULT_FIELDS }
175
+ const raw = opts.fields
176
+ if (raw && typeof raw === "object") {
177
+ for (const key of Object.keys(DEFAULT_FIELDS) as (keyof Fields)[]) {
178
+ const value = (raw as Record<string, unknown>)[key]
179
+ if (typeof value === "boolean") fields[key] = value
180
+ }
181
+ }
182
+ return { mode, fields }
183
+ }
package/src/tui.tsx CHANGED
@@ -10,41 +10,35 @@
10
10
  * Fancy layout — every value names itself, all rows in the muted tone:
11
11
  *
12
12
  * Timing
13
- * api/wall ███████░ 78% gauge: how much of wall-clock was model inference
13
+ * api/wall ██████░░ 78% gauge: how much of wall-clock was model inference
14
14
  * api 31s · wall 40s the two raw times behind that ratio
15
15
  * turns 4 · avg 8s completed assistant turns and their average
16
16
  * slowest 19s the single slowest turn
17
17
  * per-turn ▄█▂▂ sparkline of each recent turn's duration
18
18
  *
19
- * Metrics (per session):
20
- * - api total assistant inference time = sum of (time.completed - time.created)
21
- * over every completed assistant message.
22
- * - wall span from the first to the last message timestamp (includes the time
23
- * you spend reading/typing between turns), so api is a fraction of it.
24
- * - api/wall api's share of wall-clock, as a bar gauge and percent.
25
- * - turns number of completed assistant messages, plus the average duration.
26
- * - slowest the single slowest assistant message.
27
- * - per-turn sparkline of recent per-turn durations.
19
+ * The timing math, formatting, and config parsing live in `./timing.ts` (pure,
20
+ * JSX-free, unit-tested); this file is just the Solid component and the slot
21
+ * registration. See `timing.ts` for the per-session metric definitions.
28
22
  *
29
23
  * Config — pass options via the tuple form in `tui.json`:
30
24
  *
31
25
  * "plugin": [
32
26
  * ["@foae/opencode-timings@latest", {
33
27
  * "mode": "fancy", // "fancy" (default) | "simple"
34
- * "fields": { // every field defaults to true
35
- * "api": true, "wall": true, "turns": true,
28
+ * "fields": { // each toggles one value, all default true
29
+ * "ratio": true, "api": true, "wall": true, "turns": true,
36
30
  * "avg": true, "slow": true, "sparkline": true
37
31
  * }
38
32
  * }]
39
33
  * ]
40
34
  *
41
- * "fancy" draws a bar gauge for the api/wall ratio and a sparkline of recent
42
- * turn durations; "simple" is plain labeled rows. The panel is always shown
43
- * (values read zero before the first turn).
35
+ * "fancy" draws the gauge bar on the ratio row and adds a sparkline of recent
36
+ * turn durations; "simple" is the same rows without the bar or sparkline. The
37
+ * panel is always shown (values read zero before the first turn).
44
38
  */
45
39
  import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
46
- import type { Message } from "@opencode-ai/sdk/v2"
47
- import { createSignal, onCleanup } from "solid-js"
40
+ import { createEffect, createSignal, Index, onCleanup } from "solid-js"
41
+ import { buildRows, computeTiming, EMPTY, parseConfig, type Fields, type Mode, type Timing } from "./timing.ts"
48
42
 
49
43
  const id = "opencode-timings"
50
44
 
@@ -56,150 +50,47 @@ const SIDEBAR_ORDER = 155
56
50
  // drive updates; this interval is just a low-frequency backstop.
57
51
  const REFRESH_INTERVAL_MS = 15_000
58
52
 
59
- // Narrower than the simple-mode rows so the "api/wall" label + gauge + percent
60
- // fit one sidebar line without wrapping.
61
- const BAR_WIDTH = 8
62
- const SPARK_POINTS = 12
63
- const SPARK_LEVELS = "▁▂▃▄▅▆▇█"
64
-
65
- type Mode = "fancy" | "simple"
66
- type Fields = {
67
- api: boolean
68
- wall: boolean
69
- turns: boolean
70
- avg: boolean
71
- slow: boolean
72
- sparkline: boolean
73
- }
74
- const DEFAULT_FIELDS: Fields = { api: true, wall: true, turns: true, avg: true, slow: true, sparkline: true }
75
-
76
- type Timing = {
77
- apiMs: number
78
- wallMs: number
79
- turns: number
80
- avgMs: number
81
- slowestMs: number
82
- apiPct: number
83
- durations: number[]
84
- }
85
- const EMPTY: Timing = { apiMs: 0, wallMs: 0, turns: 0, avgMs: 0, slowestMs: 0, apiPct: 0, durations: [] }
86
-
87
- function computeTiming(messages: ReadonlyArray<Message>): Timing {
88
- let apiMs = 0
89
- let slowestMs = 0
90
- let minTs = Number.POSITIVE_INFINITY
91
- let maxTs = 0
92
- const durations: number[] = []
93
-
94
- for (const msg of messages) {
95
- const created = msg.time?.created
96
- if (typeof created === "number") {
97
- if (created < minTs) minTs = created
98
- if (created > maxTs) maxTs = created
99
- }
100
- if (msg.role === "assistant") {
101
- const completed = msg.time?.completed
102
- if (typeof completed === "number") {
103
- if (completed > maxTs) maxTs = completed
104
- const dur = completed - (typeof created === "number" ? created : completed)
105
- if (dur > 0) {
106
- apiMs += dur
107
- durations.push(dur)
108
- if (dur > slowestMs) slowestMs = dur
109
- }
110
- }
111
- }
112
- }
113
-
114
- const turns = durations.length
115
- if (turns === 0) return EMPTY
116
- const wallMs = minTs === Number.POSITIVE_INFINITY ? 0 : Math.max(0, maxTs - minTs)
117
- return {
118
- apiMs,
119
- wallMs,
120
- turns,
121
- avgMs: apiMs / turns,
122
- slowestMs,
123
- apiPct: wallMs > 0 ? Math.round((apiMs / wallMs) * 100) : 0,
124
- durations,
125
- }
126
- }
127
-
128
- /** Compact, sidebar-friendly duration: "42s", "6m31s", "1h02m". */
129
- function fmtDuration(ms: number): string {
130
- if (!Number.isFinite(ms) || ms < 0) ms = 0
131
- const totalSec = Math.round(ms / 1000)
132
- if (totalSec < 60) return `${totalSec}s`
133
- const totalMin = Math.floor(totalSec / 60)
134
- if (totalMin < 60) return `${totalMin}m${String(totalSec % 60).padStart(2, "0")}s`
135
- const hours = Math.floor(totalMin / 60)
136
- return `${hours}h${String(totalMin % 60).padStart(2, "0")}m`
137
- }
138
-
139
- function bar(pct: number, width: number): string {
140
- const p = Math.max(0, Math.min(100, Number.isFinite(pct) ? pct : 0))
141
- const filled = Math.round((p / 100) * width)
142
- return "█".repeat(filled) + "░".repeat(Math.max(0, width - filled))
143
- }
144
-
145
- function sparkline(values: number[]): string {
146
- if (values.length === 0) return ""
147
- const points = values.slice(-SPARK_POINTS)
148
- const max = Math.max(...points, 1)
149
- return points
150
- .map((v) => {
151
- const idx = Math.round((v / max) * (SPARK_LEVELS.length - 1))
152
- return SPARK_LEVELS[Math.min(SPARK_LEVELS.length - 1, Math.max(0, idx))]
153
- })
154
- .join("")
155
- }
156
-
157
- function turnsLine(t: Timing, fields: Fields): string {
158
- if (fields.turns && fields.avg) return `turns ${t.turns} · avg ${fmtDuration(t.avgMs)}`
159
- if (fields.turns) return `turns ${t.turns}`
160
- if (fields.avg) return `avg ${fmtDuration(t.avgMs)}`
161
- return ""
162
- }
163
-
164
- // Every row is a labeled string in the muted tone — no value relies on the
165
- // reader inferring what an unlabeled number or glyph means.
166
- function buildRows(t: Timing, mode: Mode, fields: Fields): string[] {
167
- const rows: string[] = []
168
- if (mode === "fancy") {
169
- if (fields.api) rows.push(`api/wall ${bar(t.apiPct, BAR_WIDTH)} ${t.apiPct}%`)
170
- if (fields.wall) rows.push(`api ${fmtDuration(t.apiMs)} · wall ${fmtDuration(t.wallMs)}`)
171
- const ta = turnsLine(t, fields)
172
- if (ta) rows.push(ta)
173
- if (fields.slow) rows.push(`slowest ${fmtDuration(t.slowestMs)}`)
174
- if (fields.sparkline) {
175
- const spark = sparkline(t.durations)
176
- if (spark) rows.push(`per-turn ${spark}`)
177
- }
178
- } else {
179
- if (fields.api) rows.push(`api ${fmtDuration(t.apiMs)} · ${t.apiPct}% of wall`)
180
- if (fields.wall) rows.push(`wall ${fmtDuration(t.wallMs)}`)
181
- const ta = turnsLine(t, fields)
182
- if (ta) rows.push(ta)
183
- if (fields.slow) rows.push(`slowest ${fmtDuration(t.slowestMs)}`)
184
- }
185
- return rows
186
- }
187
-
188
53
  function SidebarTimingView(props: {
189
54
  api: TuiPluginApi
190
55
  sessionID: string
191
56
  mode: Mode
192
57
  fields: Fields
193
58
  }) {
194
- const read = (): Timing => computeTiming(props.api.state.session.messages(props.sessionID))
59
+ // Read defensively: api.state can throw if the session is torn down mid-read
60
+ // (e.g. deleted from another pane), and this runs inside event/timer callbacks
61
+ // where an escaping exception would be unhandled — fall back to empty instead.
62
+ const read = (): Timing => {
63
+ try {
64
+ return computeTiming(props.api.state.session.messages(props.sessionID))
65
+ } catch {
66
+ return EMPTY
67
+ }
68
+ }
195
69
  const [timing, setTiming] = createSignal<Timing>(read())
196
70
  const refresh = () => setTiming(read())
197
71
 
198
- // TUI/session state can hydrate asynchronously after mount or a session
199
- // switch, so recompute a few times early to recover from empty first reads.
200
- const recovery = [setTimeout(refresh, 400), setTimeout(refresh, 1500), setTimeout(refresh, 4000)]
72
+ // Re-read whenever the active session changes. OpenCode may keep this slot
73
+ // component mounted and merely swap `session_id`, in which case a mount-time
74
+ // read alone would keep showing the previous session until the next event or
75
+ // the interval backstop. Tracking props.sessionID here also re-arms the
76
+ // async-hydration recovery reads (TUI/session state can land a beat after a
77
+ // switch), and onCleanup tears the pending timers down on the next switch.
78
+ createEffect(() => {
79
+ void props.sessionID // track session switches
80
+ refresh()
81
+ const recovery = [setTimeout(refresh, 400), setTimeout(refresh, 1500), setTimeout(refresh, 4000)]
82
+ onCleanup(() => {
83
+ for (const timer of recovery) clearTimeout(timer)
84
+ })
85
+ })
86
+
87
+ // Low-frequency backstop; events drive the timely updates.
201
88
  const interval = setInterval(refresh, REFRESH_INTERVAL_MS)
202
89
 
90
+ // Access paths differ by event on purpose: message.updated/session.updated
91
+ // carry a nested `info` (Message/Session, no top-level sessionID), while
92
+ // message.removed/session.idle carry sessionID directly — each matches its
93
+ // SDK event type.
203
94
  const unsubscribers = [
204
95
  props.api.event.on("message.updated", (event) => {
205
96
  if (event.properties?.info?.sessionID === props.sessionID) refresh()
@@ -216,7 +107,6 @@ function SidebarTimingView(props: {
216
107
  ]
217
108
 
218
109
  onCleanup(() => {
219
- for (const timer of recovery) clearTimeout(timer)
220
110
  clearInterval(interval)
221
111
  for (const unsubscribe of unsubscribers) unsubscribe()
222
112
  })
@@ -229,30 +119,18 @@ function SidebarTimingView(props: {
229
119
  <b>Timing</b>
230
120
  </text>
231
121
  <box gap={0}>
232
- {rows().map((row) => (
233
- <text fg={props.api.theme.current.textMuted} wrapMode="none">
234
- {row || " "}
235
- </text>
236
- ))}
122
+ <Index each={rows()}>
123
+ {(row) => (
124
+ <text fg={props.api.theme.current.textMuted} wrapMode="none">
125
+ {row() || " "}
126
+ </text>
127
+ )}
128
+ </Index>
237
129
  </box>
238
130
  </box>
239
131
  )
240
132
  }
241
133
 
242
- function parseConfig(options: Record<string, unknown> | undefined): { mode: Mode; fields: Fields } {
243
- const opts = options ?? {}
244
- const mode: Mode = opts.mode === "simple" ? "simple" : "fancy"
245
- const fields: Fields = { ...DEFAULT_FIELDS }
246
- const raw = opts.fields
247
- if (raw && typeof raw === "object") {
248
- for (const key of Object.keys(DEFAULT_FIELDS) as (keyof Fields)[]) {
249
- const value = (raw as Record<string, unknown>)[key]
250
- if (typeof value === "boolean") fields[key] = value
251
- }
252
- }
253
- return { mode, fields }
254
- }
255
-
256
134
  const tui: TuiPlugin = async (api, options) => {
257
135
  const { mode, fields } = parseConfig(options)
258
136
  api.slots.register({