@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.
Files changed (3) hide show
  1. package/README.md +39 -11
  2. package/package.json +1 -1
  3. 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
- API 6m31s 54%
15
- wall 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,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.0`.
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.0",
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
- * - 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.
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
- * Modelled on @slkiser/opencode-quota's sidebar plugin (the reference for the
19
- * sidebar_content slot mechanism on OpenCode 1.15.x).
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
- turns += 1
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 SidebarTimingView(props: { api: TuiPluginApi; sessionID: string }) {
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 lines = (): string[] => {
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
- {lines().map((line) => (
232
+ {rows().map((row) => (
148
233
  <text fg={props.api.theme.current.textMuted} wrapMode="none">
149
- {line || " "}
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
- const tui: TuiPlugin = async (api) => {
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
  })