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