@foae/opencode-timings 0.1.0 → 0.1.2
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 +39 -11
- package/package.json +1 -1
- package/src/tui.tsx +130 -30
package/README.md
CHANGED
|
@@ -11,20 +11,25 @@ context-window pollution**.
|
|
|
11
11
|
|
|
12
12
|
```
|
|
13
13
|
Timing
|
|
14
|
-
|
|
15
|
-
wall
|
|
16
|
-
|
|
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,30 @@ 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`.
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
Pass options using the tuple form (`[spec, options]`) in `tui.json`:
|
|
57
|
+
|
|
58
|
+
```jsonc
|
|
59
|
+
{
|
|
60
|
+
"$schema": "https://opencode.ai/tui.json",
|
|
61
|
+
"plugin": [
|
|
62
|
+
["@foae/opencode-timings@latest", {
|
|
63
|
+
"mode": "fancy",
|
|
64
|
+
"fields": { "api": true, "wall": true, "turns": true, "avg": true, "slow": true, "sparkline": true }
|
|
65
|
+
}]
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Option | Values | Default | Meaning |
|
|
71
|
+
|----------|--------|---------|---------|
|
|
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
|
+
|
|
75
|
+
With no options (a plain `"@foae/opencode-timings@latest"` string), it defaults to `fancy` mode with all fields shown.
|
|
48
76
|
|
|
49
77
|
## Requirements
|
|
50
78
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foae/opencode-timings",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
package/src/tui.tsx
CHANGED
|
@@ -7,16 +7,40 @@
|
|
|
7
7
|
* state. Nothing is injected into the message stream, so there is zero
|
|
8
8
|
* context-window pollution.
|
|
9
9
|
*
|
|
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
|
+
*
|
|
10
19
|
* Metrics (per session):
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
* - wall
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
16
|
-
* -
|
|
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.
|
|
28
|
+
*
|
|
29
|
+
* Config — pass options via the tuple form in `tui.json`:
|
|
17
30
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
31
|
+
* "plugin": [
|
|
32
|
+
* ["@foae/opencode-timings@latest", {
|
|
33
|
+
* "mode": "fancy", // "fancy" (default) | "simple"
|
|
34
|
+
* "fields": { // every field defaults to true
|
|
35
|
+
* "api": true, "wall": true, "turns": true,
|
|
36
|
+
* "avg": true, "slow": true, "sparkline": true
|
|
37
|
+
* }
|
|
38
|
+
* }]
|
|
39
|
+
* ]
|
|
40
|
+
*
|
|
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).
|
|
20
44
|
*/
|
|
21
45
|
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
22
46
|
import type { Message } from "@opencode-ai/sdk/v2"
|
|
@@ -32,24 +56,40 @@ const SIDEBAR_ORDER = 155
|
|
|
32
56
|
// drive updates; this interval is just a low-frequency backstop.
|
|
33
57
|
const REFRESH_INTERVAL_MS = 15_000
|
|
34
58
|
|
|
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
|
+
|
|
35
76
|
type Timing = {
|
|
36
|
-
ok: boolean
|
|
37
77
|
apiMs: number
|
|
38
78
|
wallMs: number
|
|
39
79
|
turns: number
|
|
40
80
|
avgMs: number
|
|
41
81
|
slowestMs: number
|
|
42
82
|
apiPct: number
|
|
83
|
+
durations: number[]
|
|
43
84
|
}
|
|
44
|
-
|
|
45
|
-
const EMPTY: Timing = { ok: false, apiMs: 0, wallMs: 0, turns: 0, avgMs: 0, slowestMs: 0, apiPct: 0 }
|
|
85
|
+
const EMPTY: Timing = { apiMs: 0, wallMs: 0, turns: 0, avgMs: 0, slowestMs: 0, apiPct: 0, durations: [] }
|
|
46
86
|
|
|
47
87
|
function computeTiming(messages: ReadonlyArray<Message>): Timing {
|
|
48
88
|
let apiMs = 0
|
|
49
|
-
let turns = 0
|
|
50
89
|
let slowestMs = 0
|
|
51
90
|
let minTs = Number.POSITIVE_INFINITY
|
|
52
91
|
let maxTs = 0
|
|
92
|
+
const durations: number[] = []
|
|
53
93
|
|
|
54
94
|
for (const msg of messages) {
|
|
55
95
|
const created = msg.time?.created
|
|
@@ -64,23 +104,24 @@ function computeTiming(messages: ReadonlyArray<Message>): Timing {
|
|
|
64
104
|
const dur = completed - (typeof created === "number" ? created : completed)
|
|
65
105
|
if (dur > 0) {
|
|
66
106
|
apiMs += dur
|
|
67
|
-
|
|
107
|
+
durations.push(dur)
|
|
68
108
|
if (dur > slowestMs) slowestMs = dur
|
|
69
109
|
}
|
|
70
110
|
}
|
|
71
111
|
}
|
|
72
112
|
}
|
|
73
113
|
|
|
114
|
+
const turns = durations.length
|
|
74
115
|
if (turns === 0) return EMPTY
|
|
75
116
|
const wallMs = minTs === Number.POSITIVE_INFINITY ? 0 : Math.max(0, maxTs - minTs)
|
|
76
117
|
return {
|
|
77
|
-
ok: true,
|
|
78
118
|
apiMs,
|
|
79
119
|
wallMs,
|
|
80
120
|
turns,
|
|
81
121
|
avgMs: apiMs / turns,
|
|
82
122
|
slowestMs,
|
|
83
123
|
apiPct: wallMs > 0 ? Math.round((apiMs / wallMs) * 100) : 0,
|
|
124
|
+
durations,
|
|
84
125
|
}
|
|
85
126
|
}
|
|
86
127
|
|
|
@@ -95,7 +136,61 @@ function fmtDuration(ms: number): string {
|
|
|
95
136
|
return `${hours}h${String(totalMin % 60).padStart(2, "0")}m`
|
|
96
137
|
}
|
|
97
138
|
|
|
98
|
-
function
|
|
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
|
+
function SidebarTimingView(props: {
|
|
189
|
+
api: TuiPluginApi
|
|
190
|
+
sessionID: string
|
|
191
|
+
mode: Mode
|
|
192
|
+
fields: Fields
|
|
193
|
+
}) {
|
|
99
194
|
const read = (): Timing => computeTiming(props.api.state.session.messages(props.sessionID))
|
|
100
195
|
const [timing, setTiming] = createSignal<Timing>(read())
|
|
101
196
|
const refresh = () => setTiming(read())
|
|
@@ -126,27 +221,17 @@ function SidebarTimingView(props: { api: TuiPluginApi; sessionID: string }) {
|
|
|
126
221
|
for (const unsubscribe of unsubscribers) unsubscribe()
|
|
127
222
|
})
|
|
128
223
|
|
|
129
|
-
const
|
|
130
|
-
const t = timing()
|
|
131
|
-
return [
|
|
132
|
-
`API ${fmtDuration(t.apiMs)} ${t.apiPct}%`,
|
|
133
|
-
`wall ${fmtDuration(t.wallMs)}`,
|
|
134
|
-
`turns ${t.turns} · avg ${fmtDuration(t.avgMs)}`,
|
|
135
|
-
`slow ${fmtDuration(t.slowestMs)}`,
|
|
136
|
-
]
|
|
137
|
-
}
|
|
224
|
+
const rows = (): string[] => buildRows(timing(), props.mode, props.fields)
|
|
138
225
|
|
|
139
|
-
// Always rendered (even before the first turn, showing zeros) so the panel
|
|
140
|
-
// is a permanent fixture of the sidebar.
|
|
141
226
|
return (
|
|
142
227
|
<box gap={0}>
|
|
143
228
|
<text fg={props.api.theme.current.text}>
|
|
144
229
|
<b>Timing</b>
|
|
145
230
|
</text>
|
|
146
231
|
<box gap={0}>
|
|
147
|
-
{
|
|
232
|
+
{rows().map((row) => (
|
|
148
233
|
<text fg={props.api.theme.current.textMuted} wrapMode="none">
|
|
149
|
-
{
|
|
234
|
+
{row || " "}
|
|
150
235
|
</text>
|
|
151
236
|
))}
|
|
152
237
|
</box>
|
|
@@ -154,12 +239,27 @@ function SidebarTimingView(props: { api: TuiPluginApi; sessionID: string }) {
|
|
|
154
239
|
)
|
|
155
240
|
}
|
|
156
241
|
|
|
157
|
-
|
|
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
|
+
const tui: TuiPlugin = async (api, options) => {
|
|
257
|
+
const { mode, fields } = parseConfig(options)
|
|
158
258
|
api.slots.register({
|
|
159
259
|
order: SIDEBAR_ORDER,
|
|
160
260
|
slots: {
|
|
161
261
|
sidebar_content(_ctx, props: { session_id: string }) {
|
|
162
|
-
return <SidebarTimingView api={api} sessionID={props.session_id} />
|
|
262
|
+
return <SidebarTimingView api={api} sessionID={props.session_id} mode={mode} fields={fields} />
|
|
163
263
|
},
|
|
164
264
|
},
|
|
165
265
|
})
|