@foae/opencode-timings 0.1.1 → 0.2.0

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 +34 -14
  2. package/package.json +17 -10
  3. package/src/timing.ts +183 -0
  4. package/src/tui.tsx +57 -168
package/README.md CHANGED
@@ -11,20 +11,25 @@ context-window pollution**.
11
11
 
12
12
  ```
13
13
  Timing
14
- API ███████░░░ 54%
15
- 6m31s / 12m04s
16
- turns 18 · avg 21s
17
- ▁▂▅█▃▂▄▂▁▃ slow 1m12s
14
+ api/wall ██████░░ 78%
15
+ api 31s · wall 40s
16
+ turns 4 · avg 8s
17
+ slowest 19s
18
+ per-turn ▄█▂▂
18
19
  ```
19
20
 
21
+ Every row names itself, so there are no unlabeled numbers or glyphs to decode.
22
+
20
23
  ## Metrics
21
24
 
22
- | Row | Meaning |
23
- |---------|---------|
24
- | `API` | Total assistant inference time — the sum of `time.completed time.created` over every completed assistant message and its share of wall-clock. |
25
- | `wall` | Span from the first to the last message timestamp. Includes the time you spend reading/typing between turns, so `API` is always a fraction of it. |
26
- | `turns` | Number of completed assistant messages, plus the average per-turn duration. |
27
- | `slow` | The single slowest assistant message. |
25
+ | Row | Meaning |
26
+ |------------|---------|
27
+ | `api/wall` | How much of wall-clock was actual model inference, as a bar gauge and percent. |
28
+ | `api` | Total assistant inference time the sum of `time.completed time.created` over every completed assistant message. |
29
+ | `wall` | Span from the first to the last message timestamp. Includes the time you spend reading/typing between turns, so `api` is always a fraction of it. |
30
+ | `turns` | Number of completed assistant messages, plus the average per-turn duration. |
31
+ | `slowest` | The single slowest assistant message. |
32
+ | `per-turn` | Sparkline of each recent turn's duration. |
28
33
 
29
34
  The panel is always shown; before the first turn its values read zero.
30
35
 
@@ -44,7 +49,7 @@ belongs in `tui.json`, not `opencode.json`:
44
49
  OpenCode installs the plugin and its dependencies with Bun at startup. Restart
45
50
  OpenCode and open the session sidebar to see the `Timing` panel.
46
51
 
47
- You can also pin a version, e.g. `@foae/opencode-timings@0.1.1`.
52
+ You can also pin a version, e.g. `@foae/opencode-timings@0.1.2`.
48
53
 
49
54
  ## Configuration
50
55
 
@@ -56,7 +61,7 @@ Pass options using the tuple form (`[spec, options]`) in `tui.json`:
56
61
  "plugin": [
57
62
  ["@foae/opencode-timings@latest", {
58
63
  "mode": "fancy",
59
- "fields": { "api": true, "wall": true, "turns": true, "avg": true, "slow": true, "sparkline": true }
64
+ "fields": { "ratio": true, "api": true, "wall": true, "turns": true, "avg": true, "slow": true, "sparkline": true }
60
65
  }]
61
66
  ]
62
67
  }
@@ -64,8 +69,8 @@ Pass options using the tuple form (`[spec, options]`) in `tui.json`:
64
69
 
65
70
  | Option | Values | Default | Meaning |
66
71
  |----------|--------|---------|---------|
67
- | `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. |
68
- | `fields` | object of booleans | all `true` | Toggle individual values: `api`, `wall`, `turns`, `avg`, `slow`, `sparkline` (`sparkline` is fancy-only). |
72
+ | `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. |
73
+ | `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. |
69
74
 
70
75
  With no options (a plain `"@foae/opencode-timings@latest"` string), it defaults to `fancy` mode with all fields shown.
71
76
 
@@ -73,6 +78,21 @@ With no options (a plain `"@foae/opencode-timings@latest"` string), it defaults
73
78
 
74
79
  - OpenCode `1.15.x` or newer (uses the TUI slot plugin API).
75
80
 
81
+ ## Development
82
+
83
+ Built and run with [Bun](https://bun.sh). The package ships raw source — there is no build step; OpenCode loads `src/tui.tsx` directly.
84
+
85
+ - `src/timing.ts` — pure timing math, formatting, and config parsing (no JSX), unit-tested.
86
+ - `src/tui.tsx` — the SolidJS sidebar component and the slot registration.
87
+
88
+ ```sh
89
+ bun install
90
+ bun run typecheck # tsc --noEmit
91
+ bun test # unit tests for the pure logic in src/timing.ts
92
+ ```
93
+
94
+ `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.
95
+
76
96
  ## License
77
97
 
78
98
  MIT — see [LICENSE](./LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foae/opencode-timings",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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
@@ -7,33 +7,38 @@
7
7
  * state. Nothing is injected into the message stream, so there is zero
8
8
  * context-window pollution.
9
9
  *
10
- * Metrics (per session):
11
- * - API total assistant inference time = sum of (time.completed - time.created)
12
- * over every completed assistant message, with its share of wall-clock.
13
- * - wall span from the first to the last message timestamp (includes the time
14
- * you spend reading/typing between turns).
15
- * - turns number of completed assistant messages, plus the average duration.
16
- * - slow the single slowest assistant message.
10
+ * Fancy layout — every value names itself, all rows in the muted tone:
11
+ *
12
+ * Timing
13
+ * api/wall ██████░░ 78% gauge: how much of wall-clock was model inference
14
+ * api 31s · wall 40s the two raw times behind that ratio
15
+ * turns 4 · avg 8s completed assistant turns and their average
16
+ * slowest 19s the single slowest turn
17
+ * per-turn ▄█▂▂ sparkline of each recent turn's duration
18
+ *
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.
17
22
  *
18
23
  * Config — pass options via the tuple form in `tui.json`:
19
24
  *
20
25
  * "plugin": [
21
26
  * ["@foae/opencode-timings@latest", {
22
27
  * "mode": "fancy", // "fancy" (default) | "simple"
23
- * "fields": { // every field defaults to true
24
- * "api": true, "wall": true, "turns": true,
28
+ * "fields": { // each toggles one value, all default true
29
+ * "ratio": true, "api": true, "wall": true, "turns": true,
25
30
  * "avg": true, "slow": true, "sparkline": true
26
31
  * }
27
32
  * }]
28
33
  * ]
29
34
  *
30
- * "fancy" draws a bar gauge for the API/wall ratio and a sparkline of recent
31
- * turn durations; "simple" is plain labeled rows. The panel is always shown
32
- * (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).
33
38
  */
34
39
  import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
35
- import type { Message } from "@opencode-ai/sdk/v2"
36
- 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"
37
42
 
38
43
  const id = "opencode-timings"
39
44
 
@@ -45,148 +50,47 @@ const SIDEBAR_ORDER = 155
45
50
  // drive updates; this interval is just a low-frequency backstop.
46
51
  const REFRESH_INTERVAL_MS = 15_000
47
52
 
48
- const BAR_WIDTH = 10
49
- const SPARK_POINTS = 12
50
- const SPARK_LEVELS = "▁▂▃▄▅▆▇█"
51
-
52
- type Mode = "fancy" | "simple"
53
- type Fields = {
54
- api: boolean
55
- wall: boolean
56
- turns: boolean
57
- avg: boolean
58
- slow: boolean
59
- sparkline: boolean
60
- }
61
- const DEFAULT_FIELDS: Fields = { api: true, wall: true, turns: true, avg: true, slow: true, sparkline: true }
62
-
63
- type Timing = {
64
- apiMs: number
65
- wallMs: number
66
- turns: number
67
- avgMs: number
68
- slowestMs: number
69
- apiPct: number
70
- durations: number[]
71
- }
72
- const EMPTY: Timing = { apiMs: 0, wallMs: 0, turns: 0, avgMs: 0, slowestMs: 0, apiPct: 0, durations: [] }
73
-
74
- function computeTiming(messages: ReadonlyArray<Message>): Timing {
75
- let apiMs = 0
76
- let slowestMs = 0
77
- let minTs = Number.POSITIVE_INFINITY
78
- let maxTs = 0
79
- const durations: number[] = []
80
-
81
- for (const msg of messages) {
82
- const created = msg.time?.created
83
- if (typeof created === "number") {
84
- if (created < minTs) minTs = created
85
- if (created > maxTs) maxTs = created
86
- }
87
- if (msg.role === "assistant") {
88
- const completed = msg.time?.completed
89
- if (typeof completed === "number") {
90
- if (completed > maxTs) maxTs = completed
91
- const dur = completed - (typeof created === "number" ? created : completed)
92
- if (dur > 0) {
93
- apiMs += dur
94
- durations.push(dur)
95
- if (dur > slowestMs) slowestMs = dur
96
- }
97
- }
98
- }
99
- }
100
-
101
- const turns = durations.length
102
- if (turns === 0) return EMPTY
103
- const wallMs = minTs === Number.POSITIVE_INFINITY ? 0 : Math.max(0, maxTs - minTs)
104
- return {
105
- apiMs,
106
- wallMs,
107
- turns,
108
- avgMs: apiMs / turns,
109
- slowestMs,
110
- apiPct: wallMs > 0 ? Math.round((apiMs / wallMs) * 100) : 0,
111
- durations,
112
- }
113
- }
114
-
115
- /** Compact, sidebar-friendly duration: "42s", "6m31s", "1h02m". */
116
- function fmtDuration(ms: number): string {
117
- if (!Number.isFinite(ms) || ms < 0) ms = 0
118
- const totalSec = Math.round(ms / 1000)
119
- if (totalSec < 60) return `${totalSec}s`
120
- const totalMin = Math.floor(totalSec / 60)
121
- if (totalMin < 60) return `${totalMin}m${String(totalSec % 60).padStart(2, "0")}s`
122
- const hours = Math.floor(totalMin / 60)
123
- return `${hours}h${String(totalMin % 60).padStart(2, "0")}m`
124
- }
125
-
126
- function bar(pct: number, width: number): string {
127
- const p = Math.max(0, Math.min(100, Number.isFinite(pct) ? pct : 0))
128
- const filled = Math.round((p / 100) * width)
129
- return "█".repeat(filled) + "░".repeat(Math.max(0, width - filled))
130
- }
131
-
132
- function sparkline(values: number[]): string {
133
- if (values.length === 0) return ""
134
- const points = values.slice(-SPARK_POINTS)
135
- const max = Math.max(...points, 1)
136
- return points
137
- .map((v) => {
138
- const idx = Math.round((v / max) * (SPARK_LEVELS.length - 1))
139
- return SPARK_LEVELS[Math.min(SPARK_LEVELS.length - 1, Math.max(0, idx))]
140
- })
141
- .join("")
142
- }
143
-
144
- type Tone = "muted" | "accent"
145
- type Row = { text: string; tone: Tone }
146
-
147
- function turnsLine(t: Timing, fields: Fields): string {
148
- if (fields.turns && fields.avg) return `turns ${t.turns} · avg ${fmtDuration(t.avgMs)}`
149
- if (fields.turns) return `turns ${t.turns}`
150
- if (fields.avg) return `avg ${fmtDuration(t.avgMs)}`
151
- return ""
152
- }
153
-
154
- function buildRows(t: Timing, mode: Mode, fields: Fields): Row[] {
155
- const rows: Row[] = []
156
- if (mode === "fancy") {
157
- if (fields.api) rows.push({ text: `API ${bar(t.apiPct, BAR_WIDTH)} ${t.apiPct}%`, tone: "accent" })
158
- if (fields.wall) rows.push({ text: `${fmtDuration(t.apiMs)} / ${fmtDuration(t.wallMs)}`, tone: "muted" })
159
- const ta = turnsLine(t, fields)
160
- if (ta) rows.push({ text: ta, tone: "muted" })
161
- const spark = fields.sparkline ? sparkline(t.durations) : ""
162
- const slow = fields.slow ? `slow ${fmtDuration(t.slowestMs)}` : ""
163
- const last = [spark, slow].filter(Boolean).join(" ")
164
- if (last) rows.push({ text: last, tone: "muted" })
165
- } else {
166
- if (fields.api) rows.push({ text: `API ${fmtDuration(t.apiMs)} ${t.apiPct}%`, tone: "muted" })
167
- if (fields.wall) rows.push({ text: `wall ${fmtDuration(t.wallMs)}`, tone: "muted" })
168
- const ta = turnsLine(t, fields)
169
- if (ta) rows.push({ text: ta, tone: "muted" })
170
- if (fields.slow) rows.push({ text: `slow ${fmtDuration(t.slowestMs)}`, tone: "muted" })
171
- }
172
- return rows
173
- }
174
-
175
53
  function SidebarTimingView(props: {
176
54
  api: TuiPluginApi
177
55
  sessionID: string
178
56
  mode: Mode
179
57
  fields: Fields
180
58
  }) {
181
- 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
+ }
182
69
  const [timing, setTiming] = createSignal<Timing>(read())
183
70
  const refresh = () => setTiming(read())
184
71
 
185
- // TUI/session state can hydrate asynchronously after mount or a session
186
- // switch, so recompute a few times early to recover from empty first reads.
187
- 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.
188
88
  const interval = setInterval(refresh, REFRESH_INTERVAL_MS)
189
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.
190
94
  const unsubscribers = [
191
95
  props.api.event.on("message.updated", (event) => {
192
96
  if (event.properties?.info?.sessionID === props.sessionID) refresh()
@@ -203,14 +107,11 @@ function SidebarTimingView(props: {
203
107
  ]
204
108
 
205
109
  onCleanup(() => {
206
- for (const timer of recovery) clearTimeout(timer)
207
110
  clearInterval(interval)
208
111
  for (const unsubscribe of unsubscribers) unsubscribe()
209
112
  })
210
113
 
211
- const rows = (): Row[] => buildRows(timing(), props.mode, props.fields)
212
- const toneColor = (tone: Tone) =>
213
- tone === "accent" ? props.api.theme.current.accent : props.api.theme.current.textMuted
114
+ const rows = (): string[] => buildRows(timing(), props.mode, props.fields)
214
115
 
215
116
  return (
216
117
  <box gap={0}>
@@ -218,30 +119,18 @@ function SidebarTimingView(props: {
218
119
  <b>Timing</b>
219
120
  </text>
220
121
  <box gap={0}>
221
- {rows().map((row) => (
222
- <text fg={toneColor(row.tone)} wrapMode="none">
223
- {row.text || " "}
224
- </text>
225
- ))}
122
+ <Index each={rows()}>
123
+ {(row) => (
124
+ <text fg={props.api.theme.current.textMuted} wrapMode="none">
125
+ {row() || " "}
126
+ </text>
127
+ )}
128
+ </Index>
226
129
  </box>
227
130
  </box>
228
131
  )
229
132
  }
230
133
 
231
- function parseConfig(options: Record<string, unknown> | undefined): { mode: Mode; fields: Fields } {
232
- const opts = options ?? {}
233
- const mode: Mode = opts.mode === "simple" ? "simple" : "fancy"
234
- const fields: Fields = { ...DEFAULT_FIELDS }
235
- const raw = opts.fields
236
- if (raw && typeof raw === "object") {
237
- for (const key of Object.keys(DEFAULT_FIELDS) as (keyof Fields)[]) {
238
- const value = (raw as Record<string, unknown>)[key]
239
- if (typeof value === "boolean") fields[key] = value
240
- }
241
- }
242
- return { mode, fields }
243
- }
244
-
245
134
  const tui: TuiPlugin = async (api, options) => {
246
135
  const { mode, fields } = parseConfig(options)
247
136
  api.slots.register({