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 +21 -0
- package/README.md +91 -2
- package/dist/server.js +6 -0
- package/dist/tui.js +252 -0
- package/package.json +35 -4
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
|
-
#
|
|
1
|
+
# opencode-livetrace
|
|
2
2
|
|
|
3
|
-
|
|
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
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|