@foae/opencode-timings 0.1.0 → 0.1.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 +28 -5
- package/package.json +1 -1
- package/src/tui.tsx +114 -25
package/README.md
CHANGED
|
@@ -11,10 +11,10 @@ context-window pollution**.
|
|
|
11
11
|
|
|
12
12
|
```
|
|
13
13
|
Timing
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
slow
|
|
14
|
+
API ███████░░░ 54%
|
|
15
|
+
6m31s / 12m04s
|
|
16
|
+
turns 18 · avg 21s
|
|
17
|
+
▁▂▅█▃▂▄▂▁▃ slow 1m12s
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
## Metrics
|
|
@@ -44,7 +44,30 @@ belongs in `tui.json`, not `opencode.json`:
|
|
|
44
44
|
OpenCode installs the plugin and its dependencies with Bun at startup. Restart
|
|
45
45
|
OpenCode and open the session sidebar to see the `Timing` panel.
|
|
46
46
|
|
|
47
|
-
You can also pin a version, e.g. `@foae/opencode-timings@0.1.
|
|
47
|
+
You can also pin a version, e.g. `@foae/opencode-timings@0.1.1`.
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
Pass options using the tuple form (`[spec, options]`) in `tui.json`:
|
|
52
|
+
|
|
53
|
+
```jsonc
|
|
54
|
+
{
|
|
55
|
+
"$schema": "https://opencode.ai/tui.json",
|
|
56
|
+
"plugin": [
|
|
57
|
+
["@foae/opencode-timings@latest", {
|
|
58
|
+
"mode": "fancy",
|
|
59
|
+
"fields": { "api": true, "wall": true, "turns": true, "avg": true, "slow": true, "sparkline": true }
|
|
60
|
+
}]
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| Option | Values | Default | Meaning |
|
|
66
|
+
|----------|--------|---------|---------|
|
|
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). |
|
|
69
|
+
|
|
70
|
+
With no options (a plain `"@foae/opencode-timings@latest"` string), it defaults to `fancy` mode with all fields shown.
|
|
48
71
|
|
|
49
72
|
## Requirements
|
|
50
73
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foae/opencode-timings",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.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",
|
package/src/tui.tsx
CHANGED
|
@@ -15,8 +15,21 @@
|
|
|
15
15
|
* - turns number of completed assistant messages, plus the average duration.
|
|
16
16
|
* - slow the single slowest assistant message.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* Config — pass options via the tuple form in `tui.json`:
|
|
19
|
+
*
|
|
20
|
+
* "plugin": [
|
|
21
|
+
* ["@foae/opencode-timings@latest", {
|
|
22
|
+
* "mode": "fancy", // "fancy" (default) | "simple"
|
|
23
|
+
* "fields": { // every field defaults to true
|
|
24
|
+
* "api": true, "wall": true, "turns": true,
|
|
25
|
+
* "avg": true, "slow": true, "sparkline": true
|
|
26
|
+
* }
|
|
27
|
+
* }]
|
|
28
|
+
* ]
|
|
29
|
+
*
|
|
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).
|
|
20
33
|
*/
|
|
21
34
|
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
22
35
|
import type { Message } from "@opencode-ai/sdk/v2"
|
|
@@ -32,24 +45,38 @@ const SIDEBAR_ORDER = 155
|
|
|
32
45
|
// drive updates; this interval is just a low-frequency backstop.
|
|
33
46
|
const REFRESH_INTERVAL_MS = 15_000
|
|
34
47
|
|
|
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
|
+
|
|
35
63
|
type Timing = {
|
|
36
|
-
ok: boolean
|
|
37
64
|
apiMs: number
|
|
38
65
|
wallMs: number
|
|
39
66
|
turns: number
|
|
40
67
|
avgMs: number
|
|
41
68
|
slowestMs: number
|
|
42
69
|
apiPct: number
|
|
70
|
+
durations: number[]
|
|
43
71
|
}
|
|
44
|
-
|
|
45
|
-
const EMPTY: Timing = { ok: false, apiMs: 0, wallMs: 0, turns: 0, avgMs: 0, slowestMs: 0, apiPct: 0 }
|
|
72
|
+
const EMPTY: Timing = { apiMs: 0, wallMs: 0, turns: 0, avgMs: 0, slowestMs: 0, apiPct: 0, durations: [] }
|
|
46
73
|
|
|
47
74
|
function computeTiming(messages: ReadonlyArray<Message>): Timing {
|
|
48
75
|
let apiMs = 0
|
|
49
|
-
let turns = 0
|
|
50
76
|
let slowestMs = 0
|
|
51
77
|
let minTs = Number.POSITIVE_INFINITY
|
|
52
78
|
let maxTs = 0
|
|
79
|
+
const durations: number[] = []
|
|
53
80
|
|
|
54
81
|
for (const msg of messages) {
|
|
55
82
|
const created = msg.time?.created
|
|
@@ -64,23 +91,24 @@ function computeTiming(messages: ReadonlyArray<Message>): Timing {
|
|
|
64
91
|
const dur = completed - (typeof created === "number" ? created : completed)
|
|
65
92
|
if (dur > 0) {
|
|
66
93
|
apiMs += dur
|
|
67
|
-
|
|
94
|
+
durations.push(dur)
|
|
68
95
|
if (dur > slowestMs) slowestMs = dur
|
|
69
96
|
}
|
|
70
97
|
}
|
|
71
98
|
}
|
|
72
99
|
}
|
|
73
100
|
|
|
101
|
+
const turns = durations.length
|
|
74
102
|
if (turns === 0) return EMPTY
|
|
75
103
|
const wallMs = minTs === Number.POSITIVE_INFINITY ? 0 : Math.max(0, maxTs - minTs)
|
|
76
104
|
return {
|
|
77
|
-
ok: true,
|
|
78
105
|
apiMs,
|
|
79
106
|
wallMs,
|
|
80
107
|
turns,
|
|
81
108
|
avgMs: apiMs / turns,
|
|
82
109
|
slowestMs,
|
|
83
110
|
apiPct: wallMs > 0 ? Math.round((apiMs / wallMs) * 100) : 0,
|
|
111
|
+
durations,
|
|
84
112
|
}
|
|
85
113
|
}
|
|
86
114
|
|
|
@@ -95,7 +123,61 @@ function fmtDuration(ms: number): string {
|
|
|
95
123
|
return `${hours}h${String(totalMin % 60).padStart(2, "0")}m`
|
|
96
124
|
}
|
|
97
125
|
|
|
98
|
-
function
|
|
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
|
+
function SidebarTimingView(props: {
|
|
176
|
+
api: TuiPluginApi
|
|
177
|
+
sessionID: string
|
|
178
|
+
mode: Mode
|
|
179
|
+
fields: Fields
|
|
180
|
+
}) {
|
|
99
181
|
const read = (): Timing => computeTiming(props.api.state.session.messages(props.sessionID))
|
|
100
182
|
const [timing, setTiming] = createSignal<Timing>(read())
|
|
101
183
|
const refresh = () => setTiming(read())
|
|
@@ -126,27 +208,19 @@ function SidebarTimingView(props: { api: TuiPluginApi; sessionID: string }) {
|
|
|
126
208
|
for (const unsubscribe of unsubscribers) unsubscribe()
|
|
127
209
|
})
|
|
128
210
|
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
}
|
|
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
|
|
138
214
|
|
|
139
|
-
// Always rendered (even before the first turn, showing zeros) so the panel
|
|
140
|
-
// is a permanent fixture of the sidebar.
|
|
141
215
|
return (
|
|
142
216
|
<box gap={0}>
|
|
143
217
|
<text fg={props.api.theme.current.text}>
|
|
144
218
|
<b>Timing</b>
|
|
145
219
|
</text>
|
|
146
220
|
<box gap={0}>
|
|
147
|
-
{
|
|
148
|
-
<text fg={
|
|
149
|
-
{
|
|
221
|
+
{rows().map((row) => (
|
|
222
|
+
<text fg={toneColor(row.tone)} wrapMode="none">
|
|
223
|
+
{row.text || " "}
|
|
150
224
|
</text>
|
|
151
225
|
))}
|
|
152
226
|
</box>
|
|
@@ -154,12 +228,27 @@ function SidebarTimingView(props: { api: TuiPluginApi; sessionID: string }) {
|
|
|
154
228
|
)
|
|
155
229
|
}
|
|
156
230
|
|
|
157
|
-
|
|
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
|
+
const tui: TuiPlugin = async (api, options) => {
|
|
246
|
+
const { mode, fields } = parseConfig(options)
|
|
158
247
|
api.slots.register({
|
|
159
248
|
order: SIDEBAR_ORDER,
|
|
160
249
|
slots: {
|
|
161
250
|
sidebar_content(_ctx, props: { session_id: string }) {
|
|
162
|
-
return <SidebarTimingView api={api} sessionID={props.session_id} />
|
|
251
|
+
return <SidebarTimingView api={api} sessionID={props.session_id} mode={mode} fields={fields} />
|
|
163
252
|
},
|
|
164
253
|
},
|
|
165
254
|
})
|