opencode-livetrace 0.0.0-stage → 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ben Lambert
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 CHANGED
@@ -1,3 +1,92 @@
1
- # Temporary Holding Version
1
+ # opencode-livetrace
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An OpenCode v2 TUI plugin that shows what the model is doing right now in the
4
+ prompt footer, and the current session's activity counts in the sidebar.
5
+
6
+ While a run is active, the footer shows the current phase and how long it has
7
+ lasted, just before OpenCode's context size:
8
+
9
+ <img src="docs/footer.png" width="771" alt="The footer through one run: waiting, thinking, running a read tool, writing, then idle, each with the context size">
10
+
11
+ *The footer through one run: waiting, thinking, running a tool, writing, and
12
+ idle.*
13
+
14
+ | Indicator | Phase |
15
+ |---------------|------------------------------------------------------|
16
+ | `1.2s ◌` | Waiting for the model (request sent, no output yet) |
17
+ | `4.2s ✦` | Thinking (streaming reasoning) |
18
+ | `0.8s ✎` | Writing (streaming reply text) |
19
+ | `read 2.1s ⚒` | Calling or running a tool |
20
+
21
+ It's hidden when the session is idle and during retry backoff.
22
+
23
+ When the sidebar is open, it gets an "Activity" section with the counts, below
24
+ OpenCode's "Context" section:
25
+
26
+ <img src="docs/sidebar.png" width="403" alt="Sidebar Context section followed by the Activity section listing turns, steps, tools, and thoughts">
27
+
28
+ | Symbol | Count | Meaning |
29
+ |--------|----------|-------------------------------------------------|
30
+ | `⏎` | Turns | Your messages |
31
+ | `↻` | Steps | Model calls |
32
+ | `⚒` | Tools | Tool calls made by the model |
33
+ | `✦` | Thoughts | Reasoning blocks |
34
+
35
+ A turn with two tool calls followed by a reply is three steps.
36
+
37
+ Screenshots are tmux captures rendered to PNG; glyph shapes depend on your
38
+ terminal font.
39
+
40
+ ## Install
41
+
42
+ opencode plugin add opencode-livetrace
43
+
44
+ Restart OpenCode to load it.
45
+
46
+ ## Development
47
+
48
+ Build the bundle into `dist/`, which the package's `exports` point at:
49
+
50
+ bun install
51
+ bun run build
52
+
53
+ Then add the repo's absolute path to the `plugins` array in
54
+ `~/.config/opencode/opencode.json`, and rebuild after each change:
55
+
56
+ ```json
57
+ {
58
+ "plugins": ["/path/to/opencode-livetrace"]
59
+ }
60
+ ```
61
+
62
+ Alternatively, OpenCode discovers directories (or symlinks to directories)
63
+ under `~/.config/opencode/plugins/` or `<project>/.opencode/plugins/` without
64
+ any config entry. Use one method, not both: the paths differ, so the plugin
65
+ would load twice.
66
+
67
+ To publish, bump `version` in `package.json` and run `npm publish`; the
68
+ `prepublishOnly` script rebuilds `dist/` first.
69
+
70
+ ## How It Works
71
+
72
+ - `src/tui.tsx` appends the phase indicator to the `prompt.footer.status` slot
73
+ (after the built-in status) and the counts to the `sidebar.content` slot.
74
+ - `src/server.ts` is a no-op server entry. Plugins listed in `opencode.json` are
75
+ loaded by the server first, and one without a server entry never reaches
76
+ the TUI.
77
+ - `src/phase.ts` tracks the live phase per session from step, text, reasoning,
78
+ tool, retry, and execution events, timed from each event's `created`
79
+ timestamp.
80
+ - Full counts come from the message API (`type: "user"` and
81
+ `type: "assistant"`, counting `tool` and `reasoning` content parts), paging
82
+ through the full history, since the TUI's message store holds only the most
83
+ recent messages.
84
+ - While a run streams, step, tool, and reasoning events bump the counts
85
+ directly; a full recount (debounced) runs when a run starts or ends, and
86
+ after reverts and compactions.
87
+ - OpenCode applies the Solid JSX transform only to plugin files outside
88
+ `node_modules`, so an installed package must ship compiled JS. `build.ts`
89
+ bundles `src/` with OpenTUI's Solid plugin, leaving `solid-js` and
90
+ `@opentui/*` external so they resolve to OpenCode's own runtime.
91
+
92
+ Tested with OpenCode 2.2.6.
package/dist/server.js ADDED
@@ -0,0 +1,6 @@
1
+ // @bun
2
+ // src/server.ts
3
+ var server_default = { id: "opencode-livetrace", setup() {} };
4
+ export {
5
+ server_default as default
6
+ };
package/dist/tui.js ADDED
@@ -0,0 +1,252 @@
1
+ // @bun
2
+ // src/tui.tsx
3
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
4
+ import { effect as _$effect } from "@opentui/solid";
5
+ import { insertNode as _$insertNode } from "@opentui/solid";
6
+ import { insert as _$insert } from "@opentui/solid";
7
+ import { setProp as _$setProp } from "@opentui/solid";
8
+ import { createElement as _$createElement } from "@opentui/solid";
9
+ import { createComponent as _$createComponent } from "@opentui/solid";
10
+ import { createEffect, createSignal as createSignal2, on, Show } from "solid-js";
11
+
12
+ // src/phase.ts
13
+ import { createSignal } from "solid-js";
14
+ var GLYPH = { wait: "\u25CC", think: "\u2726", write: "\u270E", tool: "\u2692" };
15
+ var WAIT = ["session.execution.started", "session.step.started", "session.compaction.started"];
16
+ var DONE = [
17
+ "session.step.failed",
18
+ "session.execution.succeeded",
19
+ "session.execution.failed",
20
+ "session.execution.interrupted",
21
+ "session.idle"
22
+ ];
23
+ function trackPhases(on) {
24
+ const [live, setLive] = createSignal({});
25
+ const [now, setNow] = createSignal(Date.now());
26
+ let tick;
27
+ const set = (id, p) => {
28
+ setNow(Date.now());
29
+ setLive(({ [id]: _, ...rest }) => p ? { ...rest, [id]: p } : rest);
30
+ const active = Object.keys(live()).length > 0;
31
+ if (active && !tick)
32
+ tick = setInterval(() => setNow(Date.now()), 100);
33
+ if (!active && tick) {
34
+ clearInterval(tick);
35
+ tick = undefined;
36
+ }
37
+ };
38
+ const enter = (kind) => (e) => {
39
+ const id = e.data?.sessionID;
40
+ if (!id || kind !== "tool" && live()[id]?.kind === kind)
41
+ return;
42
+ const since = Math.min(e.created ?? Infinity, Date.now());
43
+ set(id, { kind, since, tool: kind === "tool" ? e.data?.name : undefined });
44
+ };
45
+ const wait = enter("wait");
46
+ const offs = [
47
+ ...WAIT.map((t) => on(t, wait)),
48
+ on("session.step.ended", (e) => live()[e.data?.sessionID ?? ""]?.kind !== "tool" && wait(e)),
49
+ on("session.tool.success", wait),
50
+ on("session.tool.failed", wait),
51
+ on("session.reasoning.started", enter("think")),
52
+ on("session.text.started", enter("write")),
53
+ on("session.tool.input.started", enter("tool")),
54
+ on("session.tool.called", enter("tool")),
55
+ on("session.retry.scheduled", (e) => {
56
+ const id = e.data?.sessionID;
57
+ if (id)
58
+ set(id, { kind: "wait", since: e.data?.at ?? e.created ?? Date.now() });
59
+ }),
60
+ ...DONE.map((t) => on(t, (e) => e.data?.sessionID && set(e.data.sessionID)))
61
+ ];
62
+ return {
63
+ get: (id) => {
64
+ const p = id ? live()[id] : undefined;
65
+ return p && now() >= p.since ? { ...p, elapsed: now() - p.since } : undefined;
66
+ },
67
+ dispose: () => {
68
+ offs.forEach((off) => off());
69
+ clearInterval(tick);
70
+ }
71
+ };
72
+ }
73
+ function fmt(ms) {
74
+ const s = ms / 1000;
75
+ if (s < 60)
76
+ return `${s.toFixed(1)}s`;
77
+ return `${Math.floor(s / 60)}m${String(Math.floor(s % 60)).padStart(2, "0")}s`;
78
+ }
79
+
80
+ // src/tui.tsx
81
+ var BUMPS = {
82
+ "session.step.started": "steps",
83
+ "session.tool.input.started": "tools",
84
+ "session.reasoning.started": "thoughts"
85
+ };
86
+ var RESYNC = ["session.execution.started", "session.execution.succeeded", "session.execution.failed", "session.execution.interrupted", "session.revert.committed", "session.compaction.ended"];
87
+ var ROWS = [["turns", "\u23CE", "Turns"], ["steps", "\u21BB", "Steps"], ["tools", "\u2692", "Tools"], ["thoughts", "\u2726", "Thoughts"]];
88
+ var PAGE = 200;
89
+ var DEBOUNCE_MS = 250;
90
+ var tui_default = {
91
+ id: "opencode-livetrace",
92
+ setup(ctx) {
93
+ const [counts, setCounts] = createSignal2({});
94
+ const timers = new Map;
95
+ const refresh = async (id) => {
96
+ const [users, steps] = await Promise.all([fetchAll(ctx, id, "user"), fetchAll(ctx, id, "assistant")]);
97
+ const parts = steps.flatMap((m) => m.content ?? []);
98
+ const n = (type) => parts.filter((p) => p.type === type).length;
99
+ setCounts((c) => ({
100
+ ...c,
101
+ [id]: {
102
+ turns: users.length,
103
+ steps: steps.length,
104
+ tools: n("tool"),
105
+ thoughts: n("reasoning")
106
+ }
107
+ }));
108
+ };
109
+ const schedule = (id) => {
110
+ clearTimeout(timers.get(id));
111
+ timers.set(id, setTimeout(() => {
112
+ timers.delete(id);
113
+ refresh(id);
114
+ }, DEBOUNCE_MS));
115
+ };
116
+ const bump = (id, key) => setCounts((c) => ({
117
+ ...c,
118
+ [id]: {
119
+ ...c[id],
120
+ [key]: c[id][key] + 1
121
+ }
122
+ }));
123
+ const tracked = (fn) => (e) => {
124
+ const id = e.data?.sessionID;
125
+ if (id && id in counts())
126
+ fn(id);
127
+ };
128
+ const phases = trackPhases(ctx.data.on);
129
+ const offs = [...Object.entries(BUMPS).map(([type, key]) => ctx.data.on(type, tracked((id) => bump(id, key)))), ...RESYNC.map((type) => ctx.data.on(type, tracked(schedule)))];
130
+ const watch = (input) => {
131
+ createEffect(on(() => input.sessionID, (id) => {
132
+ if (!id || id in counts())
133
+ return;
134
+ setCounts((c) => ({
135
+ ...c,
136
+ [id]: {
137
+ turns: 0,
138
+ steps: 0,
139
+ tools: 0,
140
+ thoughts: 0
141
+ }
142
+ }));
143
+ refresh(id);
144
+ }));
145
+ return () => input.sessionID ? counts()[input.sessionID] : undefined;
146
+ };
147
+ const unslots = [ctx.ui.slot({
148
+ append: "prompt.footer.status",
149
+ render: (input) => {
150
+ const phase = () => phases.get(input.sessionID);
151
+ const {
152
+ feedback
153
+ } = ctx.theme.text;
154
+ return _$createComponent(Show, {
155
+ get when() {
156
+ return phase();
157
+ },
158
+ children: (p) => (() => {
159
+ var _el$ = _$createElement("text"), _el$2 = _$createElement("span"), _el$3 = _$createElement("span");
160
+ _$insertNode(_el$, _el$2);
161
+ _$insertNode(_el$, _el$3);
162
+ _$setProp(_el$, "wrapMode", "none");
163
+ _$setProp(_el$, "flexShrink", 0);
164
+ _$insert(_el$2, () => `${p().tool ? `${p().tool} ` : ""}${fmt(p().elapsed)} `);
165
+ _$insert(_el$3, () => GLYPH[p().kind]);
166
+ _$effect((_p$) => {
167
+ var _v$ = {
168
+ fg: ctx.theme.text.muted
169
+ }, _v$2 = {
170
+ fg: p().kind === "wait" ? feedback.warning.base : feedback.success.base
171
+ };
172
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$2, "style", _v$, _p$.e));
173
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$3, "style", _v$2, _p$.t));
174
+ return _p$;
175
+ }, {
176
+ e: undefined,
177
+ t: undefined
178
+ });
179
+ return _el$;
180
+ })()
181
+ });
182
+ }
183
+ }), ctx.ui.slot({
184
+ append: "sidebar.content",
185
+ render: (input) => {
186
+ const stats = watch(input);
187
+ return _$createComponent(Show, {
188
+ get when() {
189
+ return stats();
190
+ },
191
+ children: (s) => (() => {
192
+ var _el$4 = _$createElement("box"), _el$5 = _$createElement("text"), _el$6 = _$createElement("b");
193
+ _$insertNode(_el$4, _el$5);
194
+ _$insertNode(_el$5, _el$6);
195
+ _$insertNode(_el$6, _$createTextNode(`Activity`));
196
+ _$insert(_el$4, () => ROWS.map(([key, sym, name]) => (() => {
197
+ var _el$8 = _$createElement("box"), _el$9 = _$createElement("text"), _el$0 = _$createElement("text");
198
+ _$insertNode(_el$8, _el$9);
199
+ _$insertNode(_el$8, _el$0);
200
+ _$setProp(_el$8, "flexDirection", "row");
201
+ _$setProp(_el$8, "justifyContent", "space-between");
202
+ _$insert(_el$9, name);
203
+ _$insert(_el$0, () => `${s()[key]} ${sym}`);
204
+ _$effect((_p$) => {
205
+ var _v$3 = ctx.theme.text.muted, _v$4 = ctx.theme.text.muted;
206
+ _v$3 !== _p$.e && (_p$.e = _$setProp(_el$9, "fg", _v$3, _p$.e));
207
+ _v$4 !== _p$.t && (_p$.t = _$setProp(_el$0, "fg", _v$4, _p$.t));
208
+ return _p$;
209
+ }, {
210
+ e: undefined,
211
+ t: undefined
212
+ });
213
+ return _el$8;
214
+ })()), null);
215
+ _$effect((_$p) => _$setProp(_el$5, "fg", ctx.theme.text.base, _$p));
216
+ return _el$4;
217
+ })()
218
+ });
219
+ }
220
+ })];
221
+ return () => {
222
+ phases.dispose();
223
+ unslots.forEach((off) => off());
224
+ offs.forEach((off) => off());
225
+ timers.forEach(clearTimeout);
226
+ };
227
+ }
228
+ };
229
+ async function fetchAll(ctx, sessionID, type) {
230
+ const rows = [];
231
+ let cursor;
232
+ while (true) {
233
+ const page = await ctx.client.message.list({
234
+ sessionID,
235
+ type,
236
+ limit: PAGE,
237
+ ...cursor ? {
238
+ cursor
239
+ } : {
240
+ order: "asc"
241
+ }
242
+ });
243
+ const data = page.data ?? [];
244
+ rows.push(...data);
245
+ cursor = page.cursor?.next ?? undefined;
246
+ if (!cursor || data.length < PAGE)
247
+ return rows;
248
+ }
249
+ }
250
+ export {
251
+ tui_default as default
252
+ };
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "opencode-livetrace",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "OpenCode v2 TUI plugin that shows session activity counts and a live phase indicator in the footer and sidebar.",
5
+ "type": "module",
6
+ "exports": {
7
+ "./server": "./dist/server.js",
8
+ "./tui": "./dist/tui.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "bun build.ts",
15
+ "prepublishOnly": "bun run build"
16
+ },
17
+ "keywords": [
18
+ "opencode",
19
+ "opencode-plugin",
20
+ "tui"
21
+ ],
22
+ "author": "Ben Lambert",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/belambert/opencode-livetrace.git"
27
+ },
28
+ "homepage": "https://github.com/belambert/opencode-livetrace#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/belambert/opencode-livetrace/issues"
31
+ },
32
+ "devDependencies": {
33
+ "@opentui/core": "^0.8.3",
34
+ "@opentui/solid": "^0.8.3",
35
+ "solid-js": "^1.9.18"
36
+ }
37
+ }