@foae/opencode-timings 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -0
  3. package/package.json +59 -0
  4. package/src/tui.tsx +170 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Foae
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # opencode-timings
2
+
3
+ A tiny [OpenCode](https://opencode.ai) **TUI sidebar** plugin that shows
4
+ per-session timing — how much wall-clock the session has taken and how much of
5
+ that was actually spent waiting on the model.
6
+
7
+ It renders into the same right-hand sidebar as the Quota / MCP / LSP / Todo /
8
+ Files panels, reading the session's messages directly from the TUI's reactive
9
+ state. Nothing is ever injected into the message stream, so there is **zero
10
+ context-window pollution**.
11
+
12
+ ```
13
+ Timing
14
+ API 6m31s 54%
15
+ wall 12m04s
16
+ turns 18 · avg 21s
17
+ slow 1m12s
18
+ ```
19
+
20
+ ## Metrics
21
+
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. |
28
+
29
+ The panel is always shown; before the first turn its values read zero.
30
+
31
+ ## Install
32
+
33
+ Add it to the `plugin` array of the **TUI** config that OpenCode loads
34
+ (`~/.config/opencode/tui.json` or `tui.jsonc`) — this is a TUI plugin, so it
35
+ belongs in `tui.json`, not `opencode.json`:
36
+
37
+ ```jsonc
38
+ {
39
+ "$schema": "https://opencode.ai/tui.json",
40
+ "plugin": ["@foae/opencode-timings@latest"]
41
+ }
42
+ ```
43
+
44
+ OpenCode installs the plugin and its dependencies with Bun at startup. Restart
45
+ OpenCode and open the session sidebar to see the `Timing` panel.
46
+
47
+ You can also pin a version, e.g. `@foae/opencode-timings@0.1.0`.
48
+
49
+ ## Requirements
50
+
51
+ - OpenCode `1.15.x` or newer (uses the TUI slot plugin API).
52
+
53
+ ## License
54
+
55
+ MIT — see [LICENSE](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@foae/opencode-timings",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "author": "foae",
7
+ "type": "module",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/foae/opencode-timings.git"
14
+ },
15
+ "homepage": "https://github.com/foae/opencode-timings#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/foae/opencode-timings/issues"
18
+ },
19
+ "keywords": [
20
+ "opencode",
21
+ "opencode-plugin",
22
+ "tui",
23
+ "sidebar",
24
+ "timing",
25
+ "duration",
26
+ "metrics",
27
+ "session"
28
+ ],
29
+ "oc-plugin": [
30
+ "tui"
31
+ ],
32
+ "exports": {
33
+ "./tui": {
34
+ "default": "./src/tui.tsx"
35
+ }
36
+ },
37
+ "files": [
38
+ "src",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "scripts": {
43
+ "typecheck": "tsc --noEmit"
44
+ },
45
+ "dependencies": {
46
+ "@opentui/core": "^0.1.107",
47
+ "@opentui/solid": "^0.1.107",
48
+ "solid-js": "^1.9.12"
49
+ },
50
+ "peerDependencies": {
51
+ "@opencode-ai/plugin": "^1.14.29"
52
+ },
53
+ "devDependencies": {
54
+ "@opencode-ai/plugin": "^1.14.29",
55
+ "@opencode-ai/sdk": "^1.14.29",
56
+ "@types/node": "^22.0.0",
57
+ "typescript": "^5.8.0"
58
+ }
59
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,170 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ /**
3
+ * opencode-timings — a TUI sidebar panel showing per-session timing.
4
+ *
5
+ * Renders into OpenCode's `sidebar_content` slot (alongside Quota / MCP / LSP /
6
+ * Todo / Files), reading the session's messages straight from the reactive TUI
7
+ * state. Nothing is injected into the message stream, so there is zero
8
+ * context-window pollution.
9
+ *
10
+ * 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.
17
+ *
18
+ * Modelled on @slkiser/opencode-quota's sidebar plugin (the reference for the
19
+ * sidebar_content slot mechanism on OpenCode 1.15.x).
20
+ */
21
+ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
22
+ import type { Message } from "@opencode-ai/sdk/v2"
23
+ import { createSignal, onCleanup } from "solid-js"
24
+
25
+ const id = "opencode-timings"
26
+
27
+ // Render just below Quota (order 150) and above the variable-height built-in
28
+ // sections (MCP/LSP/Todo/Files) so the panel stays near the top of the fold.
29
+ const SIDEBAR_ORDER = 155
30
+
31
+ // Wall-clock and API time are derived purely from message timestamps, so events
32
+ // drive updates; this interval is just a low-frequency backstop.
33
+ const REFRESH_INTERVAL_MS = 15_000
34
+
35
+ type Timing = {
36
+ ok: boolean
37
+ apiMs: number
38
+ wallMs: number
39
+ turns: number
40
+ avgMs: number
41
+ slowestMs: number
42
+ apiPct: number
43
+ }
44
+
45
+ const EMPTY: Timing = { ok: false, apiMs: 0, wallMs: 0, turns: 0, avgMs: 0, slowestMs: 0, apiPct: 0 }
46
+
47
+ function computeTiming(messages: ReadonlyArray<Message>): Timing {
48
+ let apiMs = 0
49
+ let turns = 0
50
+ let slowestMs = 0
51
+ let minTs = Number.POSITIVE_INFINITY
52
+ let maxTs = 0
53
+
54
+ for (const msg of messages) {
55
+ const created = msg.time?.created
56
+ if (typeof created === "number") {
57
+ if (created < minTs) minTs = created
58
+ if (created > maxTs) maxTs = created
59
+ }
60
+ if (msg.role === "assistant") {
61
+ const completed = msg.time?.completed
62
+ if (typeof completed === "number") {
63
+ if (completed > maxTs) maxTs = completed
64
+ const dur = completed - (typeof created === "number" ? created : completed)
65
+ if (dur > 0) {
66
+ apiMs += dur
67
+ turns += 1
68
+ if (dur > slowestMs) slowestMs = dur
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ if (turns === 0) return EMPTY
75
+ const wallMs = minTs === Number.POSITIVE_INFINITY ? 0 : Math.max(0, maxTs - minTs)
76
+ return {
77
+ ok: true,
78
+ apiMs,
79
+ wallMs,
80
+ turns,
81
+ avgMs: apiMs / turns,
82
+ slowestMs,
83
+ apiPct: wallMs > 0 ? Math.round((apiMs / wallMs) * 100) : 0,
84
+ }
85
+ }
86
+
87
+ /** Compact, sidebar-friendly duration: "42s", "6m31s", "1h02m". */
88
+ function fmtDuration(ms: number): string {
89
+ if (!Number.isFinite(ms) || ms < 0) ms = 0
90
+ const totalSec = Math.round(ms / 1000)
91
+ if (totalSec < 60) return `${totalSec}s`
92
+ const totalMin = Math.floor(totalSec / 60)
93
+ if (totalMin < 60) return `${totalMin}m${String(totalSec % 60).padStart(2, "0")}s`
94
+ const hours = Math.floor(totalMin / 60)
95
+ return `${hours}h${String(totalMin % 60).padStart(2, "0")}m`
96
+ }
97
+
98
+ function SidebarTimingView(props: { api: TuiPluginApi; sessionID: string }) {
99
+ const read = (): Timing => computeTiming(props.api.state.session.messages(props.sessionID))
100
+ const [timing, setTiming] = createSignal<Timing>(read())
101
+ const refresh = () => setTiming(read())
102
+
103
+ // TUI/session state can hydrate asynchronously after mount or a session
104
+ // switch, so recompute a few times early to recover from empty first reads.
105
+ const recovery = [setTimeout(refresh, 400), setTimeout(refresh, 1500), setTimeout(refresh, 4000)]
106
+ const interval = setInterval(refresh, REFRESH_INTERVAL_MS)
107
+
108
+ const unsubscribers = [
109
+ props.api.event.on("message.updated", (event) => {
110
+ if (event.properties?.info?.sessionID === props.sessionID) refresh()
111
+ }),
112
+ props.api.event.on("message.removed", (event) => {
113
+ if (event.properties?.sessionID === props.sessionID) refresh()
114
+ }),
115
+ props.api.event.on("session.updated", (event) => {
116
+ if (event.properties?.info?.id === props.sessionID) refresh()
117
+ }),
118
+ props.api.event.on("session.idle", (event) => {
119
+ if (event.properties?.sessionID === props.sessionID) refresh()
120
+ }),
121
+ ]
122
+
123
+ onCleanup(() => {
124
+ for (const timer of recovery) clearTimeout(timer)
125
+ clearInterval(interval)
126
+ for (const unsubscribe of unsubscribers) unsubscribe()
127
+ })
128
+
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
+ }
138
+
139
+ // Always rendered (even before the first turn, showing zeros) so the panel
140
+ // is a permanent fixture of the sidebar.
141
+ return (
142
+ <box gap={0}>
143
+ <text fg={props.api.theme.current.text}>
144
+ <b>Timing</b>
145
+ </text>
146
+ <box gap={0}>
147
+ {lines().map((line) => (
148
+ <text fg={props.api.theme.current.textMuted} wrapMode="none">
149
+ {line || " "}
150
+ </text>
151
+ ))}
152
+ </box>
153
+ </box>
154
+ )
155
+ }
156
+
157
+ const tui: TuiPlugin = async (api) => {
158
+ api.slots.register({
159
+ order: SIDEBAR_ORDER,
160
+ slots: {
161
+ sidebar_content(_ctx, props: { session_id: string }) {
162
+ return <SidebarTimingView api={api} sessionID={props.session_id} />
163
+ },
164
+ },
165
+ })
166
+ }
167
+
168
+ const pluginModule: TuiPluginModule & { id: string } = { id, tui }
169
+
170
+ export default pluginModule