@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.
- package/README.md +34 -14
- package/package.json +17 -10
- package/src/timing.ts +183 -0
- 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
|
-
|
|
15
|
-
|
|
16
|
-
turns
|
|
17
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
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.
|
|
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
|
|
68
|
-
| `fields` | object of booleans | all `true` |
|
|
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.
|
|
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
|
-
"
|
|
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
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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": { //
|
|
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
|
|
31
|
-
* turn durations; "simple" is
|
|
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
|
|
36
|
-
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"
|
|
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
|
-
|
|
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
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
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 = ():
|
|
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()
|
|
222
|
-
|
|
223
|
-
{
|
|
224
|
-
|
|
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({
|