@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.
- package/README.md +20 -3
- package/package.json +17 -10
- package/src/timing.ts +183 -0
- 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
|
+

|
|
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
|
|
73
|
-
| `fields` | object of booleans | all `true` |
|
|
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
|
|
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
|
-
"
|
|
43
|
-
"
|
|
42
|
+
"engines": {
|
|
43
|
+
"bun": ">=1.0.0"
|
|
44
44
|
},
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
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.
|
|
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.
|
|
55
|
-
"@opencode-ai/sdk": "^1.
|
|
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
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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": { //
|
|
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
|
|
42
|
-
* turn durations; "simple" is
|
|
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
|
|
47
|
-
import {
|
|
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
|
-
|
|
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
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
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()
|
|
233
|
-
|
|
234
|
-
{
|
|
235
|
-
|
|
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({
|