@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.
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/package.json +59 -0
- 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
|