@glaicer/supercode-turn-timer 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 +30 -0
- package/dist/turn-timer-model.js +160 -0
- package/dist/turn-timer.js +77 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dmitrii Aronov
|
|
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,30 @@
|
|
|
1
|
+
# turn-timer
|
|
2
|
+
|
|
3
|
+
An OpenCode TUI plugin that shows how long the current turn has been running, inline in the prompt footer — right after the built-in `▣ Build · <model>` line:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
▣ Build · Opus 5 (max) ◷ 0:12
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Install with the OpenCode CLI — it detects the TUI target and registers the plugin in `tui.json` for you:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
opencode plugin @glaicer/supercode-turn-timer --global
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- `--global` installs into the global config (`~/.config/opencode`); default is local (`.opencode` in the current project).
|
|
18
|
+
- `--force` replaces an already-installed version.
|
|
19
|
+
- Restart OpenCode after installing.
|
|
20
|
+
|
|
21
|
+
Manual install also works: add the package to the `plugin` array in `tui.json` (global `~/.config/opencode/tui.json` or local `<project>/.opencode/tui.json`):
|
|
22
|
+
|
|
23
|
+
```jsonc
|
|
24
|
+
{
|
|
25
|
+
"plugin": ["@glaicer/supercode-turn-timer"]
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
> [!IMPORTANT]
|
|
30
|
+
> **The first OpenCode load after installing this plugin may be slow.** That's OpenCode downloading the plugin's packages into its cache. It happens once. Every subsequent start is fast.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn Timer Model — every state transition and string of the turn timer.
|
|
3
|
+
*
|
|
4
|
+
* The timer measures one agent turn: it starts on the first `session.status`
|
|
5
|
+
* `busy` for a session, freezes on `idle` (interrupt included), and resets on
|
|
6
|
+
* the next `busy`. `retry` is mid-turn backoff and does not touch the clock.
|
|
7
|
+
* Records are kept per sessionID so switching sessions in the TUI shows each
|
|
8
|
+
* session's own timer; events for background sessions never disturb the
|
|
9
|
+
* visible one.
|
|
10
|
+
*
|
|
11
|
+
* Phases: `hidden` (no record for the open session — the TUI process missed
|
|
12
|
+
* the turn start, so no number is invented), `running` (counting up),
|
|
13
|
+
* `done` (frozen final elapsed time until the next turn starts).
|
|
14
|
+
*
|
|
15
|
+
* All reads of wall time and all scheduling go through the injected clock, so
|
|
16
|
+
* the whole model is testable without real timers.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Glyph is from Geometric Shapes (same block as the host's ▣), never emoji-presented. */
|
|
20
|
+
export const TURN_TIMER_GLYPH = "◷";
|
|
21
|
+
|
|
22
|
+
/** Slot order for `session_prompt_right`: below the built-in footer content. */
|
|
23
|
+
export const TURN_TIMER_ORDER = 50;
|
|
24
|
+
|
|
25
|
+
/** Wall-clock reads and the 1s repaint schedule, injectable for tests. */
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reactive primitives owned by the TUI entrypoint (same pattern as
|
|
29
|
+
* context-progress-bar): the entry imports these from "solid-js" where the
|
|
30
|
+
* host rewrites them to its own runtime. This file must not value-import
|
|
31
|
+
* "solid-js" itself, or an npm-installed copy can miss the host prescan and
|
|
32
|
+
* freeze the timer on its first paint.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
function pad2(value) {
|
|
36
|
+
return String(value).padStart(2, "0");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `0:12` under an hour, `1:02:03` from an hour up; never negative. */
|
|
40
|
+
export function formatElapsed(elapsedMs) {
|
|
41
|
+
const total = Math.max(0, Math.floor(elapsedMs / 1000));
|
|
42
|
+
const seconds = total % 60;
|
|
43
|
+
const minutes = Math.floor(total / 60) % 60;
|
|
44
|
+
const hours = Math.floor(total / 3600);
|
|
45
|
+
return hours > 0 ? `${hours}:${pad2(minutes)}:${pad2(seconds)}` : `${minutes}:${pad2(seconds)}`;
|
|
46
|
+
}
|
|
47
|
+
export function timerText(elapsedMs) {
|
|
48
|
+
return `${TURN_TIMER_GLYPH} ${formatElapsed(elapsedMs)}`;
|
|
49
|
+
}
|
|
50
|
+
function statusType(status) {
|
|
51
|
+
return status?.type;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Turn Timer Model over the live event bus. `session.status` drives the
|
|
56
|
+
* record map; `session.deleted` drops the record; `server.connected`
|
|
57
|
+
* reconciles against `api.state.session.status` so a reconnect never leaves a
|
|
58
|
+
* frozen wrong number (an open record whose session is idle/reply-unknown is
|
|
59
|
+
* closed or dropped, never silently kept stale).
|
|
60
|
+
*/
|
|
61
|
+
export function createTurnTimerModel(api, sessionId, solid, clock) {
|
|
62
|
+
const records = new Map();
|
|
63
|
+
const [version, setVersion] = solid.createSignal(0);
|
|
64
|
+
const bump = () => setVersion(v => v + 1);
|
|
65
|
+
const offStatus = api.event.on("session.status", event => {
|
|
66
|
+
const {
|
|
67
|
+
sessionID,
|
|
68
|
+
status
|
|
69
|
+
} = event.properties;
|
|
70
|
+
const type = statusType(status);
|
|
71
|
+
const record = records.get(sessionID);
|
|
72
|
+
if (type === "busy") {
|
|
73
|
+
// A new turn starts only from a stopped state; re-broadcast busy keeps
|
|
74
|
+
// the running clock.
|
|
75
|
+
if (!record || record.endedAt !== undefined) {
|
|
76
|
+
records.set(sessionID, {
|
|
77
|
+
startedAt: clock.now()
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
bump();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (type === "idle") {
|
|
84
|
+
if (record && record.endedAt === undefined) {
|
|
85
|
+
// Replace, never mutate: the memo compares by reference, so an
|
|
86
|
+
// in-place endedAt would leave the phase stuck on "running".
|
|
87
|
+
records.set(sessionID, {
|
|
88
|
+
startedAt: record.startedAt,
|
|
89
|
+
endedAt: clock.now()
|
|
90
|
+
});
|
|
91
|
+
bump();
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// "retry" and anything unknown: the turn is still in flight.
|
|
96
|
+
});
|
|
97
|
+
const offDeleted = api.event.on("session.deleted", event => {
|
|
98
|
+
const properties = event.properties;
|
|
99
|
+
const deletedID = properties.sessionID ?? properties.info?.id;
|
|
100
|
+
if (deletedID !== undefined && records.delete(deletedID)) bump();
|
|
101
|
+
});
|
|
102
|
+
const offConnected = api.event.on("server.connected", () => {
|
|
103
|
+
solid.untrack(() => {
|
|
104
|
+
for (const [sessionID, record] of [...records]) {
|
|
105
|
+
if (record.endedAt !== undefined) continue;
|
|
106
|
+
let status;
|
|
107
|
+
try {
|
|
108
|
+
status = api.state.session.status(sessionID);
|
|
109
|
+
} catch {
|
|
110
|
+
status = undefined;
|
|
111
|
+
}
|
|
112
|
+
const type = statusType(status);
|
|
113
|
+
if (type === "idle") {
|
|
114
|
+
records.set(sessionID, {
|
|
115
|
+
startedAt: record.startedAt,
|
|
116
|
+
endedAt: clock.now()
|
|
117
|
+
});
|
|
118
|
+
} else if (type !== "busy" && type !== "retry") {
|
|
119
|
+
records.delete(sessionID);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
bump();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
solid.onCleanup(() => {
|
|
126
|
+
offStatus();
|
|
127
|
+
offDeleted();
|
|
128
|
+
offConnected();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// The memo holds only the record reference; wall time is read live in
|
|
132
|
+
// display() so a Solid recompute during a signal write can never freeze a
|
|
133
|
+
// stale elapsed value between repaint ticks.
|
|
134
|
+
const current = solid.createMemo(() => {
|
|
135
|
+
version();
|
|
136
|
+
return records.get(sessionId());
|
|
137
|
+
});
|
|
138
|
+
const phase = solid.createMemo(() => {
|
|
139
|
+
const record = current();
|
|
140
|
+
if (!record) return "hidden";
|
|
141
|
+
return record.endedAt === undefined ? "running" : "done";
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Repaint once per second while the visible session is running; the effect
|
|
145
|
+
// keys on phase transitions only, so the interval is created once per turn.
|
|
146
|
+
solid.createEffect(() => {
|
|
147
|
+
if (phase() !== "running") return;
|
|
148
|
+
const handle = clock.setInterval(bump, 1000);
|
|
149
|
+
solid.onCleanup(() => clock.clearInterval(handle));
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
phase,
|
|
153
|
+
display: () => {
|
|
154
|
+
const record = current();
|
|
155
|
+
if (!record) return "";
|
|
156
|
+
const elapsedMs = (record.endedAt ?? clock.now()) - record.startedAt;
|
|
157
|
+
return timerText(elapsedMs);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
2
|
+
import { setProp as _$setProp } from "@opentui/solid";
|
|
3
|
+
import { effect as _$effect } from "@opentui/solid";
|
|
4
|
+
import { insert as _$insert } from "@opentui/solid";
|
|
5
|
+
import { createElement as _$createElement } from "@opentui/solid";
|
|
6
|
+
/**
|
|
7
|
+
* supercode.turn-timer — elapsed time of the current agent turn, rendered in
|
|
8
|
+
* the `session_prompt_right` slot: the host places it right-aligned on the
|
|
9
|
+
* same line as the built-in "▣ Build · <model>" footer (verified in the
|
|
10
|
+
* 1.18.30 binary: the slot is passed as the Prompt's `right` prop, and the
|
|
11
|
+
* Prompt renders `right` in a row box next to the agent/model texts).
|
|
12
|
+
*
|
|
13
|
+
* This file is only the View plus slot registration: it starts no clock,
|
|
14
|
+
* formats nothing and keeps no state — all logic lives in
|
|
15
|
+
* ./turn-timer-model.ts (the tested seam).
|
|
16
|
+
*
|
|
17
|
+
* Phases: running counts up in accent; on idle (turn done or interrupted)
|
|
18
|
+
* the final elapsed time freezes in muted; before the first observed turn
|
|
19
|
+
* start of the open session nothing renders — the plugin never invents a
|
|
20
|
+
* number it did not see.
|
|
21
|
+
*/
|
|
22
|
+
import { createEffect, createMemo, createSignal, onCleanup, Show, untrack } from "solid-js";
|
|
23
|
+
import { TURN_TIMER_ORDER, createTurnTimerModel } from "./turn-timer-model.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The host rewrites this file's "solid-js" import to its own runtime. The
|
|
27
|
+
* model builds all of its signals on these exact primitives (see
|
|
28
|
+
* SolidRuntime), so the timer stays in the host's reactive graph even when
|
|
29
|
+
* installed as an npm package under node_modules.
|
|
30
|
+
*/
|
|
31
|
+
const solid = {
|
|
32
|
+
createSignal,
|
|
33
|
+
createMemo,
|
|
34
|
+
createEffect,
|
|
35
|
+
onCleanup,
|
|
36
|
+
untrack
|
|
37
|
+
};
|
|
38
|
+
const clock = {
|
|
39
|
+
now: () => Date.now(),
|
|
40
|
+
setInterval: (fn, ms) => setInterval(fn, ms),
|
|
41
|
+
clearInterval: handle => clearInterval(handle)
|
|
42
|
+
};
|
|
43
|
+
function Section(props) {
|
|
44
|
+
const theme = () => props.api.theme.current;
|
|
45
|
+
const model = createTurnTimerModel(props.api, () => props.session_id, solid, clock);
|
|
46
|
+
return _$createComponent(Show, {
|
|
47
|
+
get when() {
|
|
48
|
+
return model.phase() !== "hidden";
|
|
49
|
+
},
|
|
50
|
+
get children() {
|
|
51
|
+
var _el$ = _$createElement("text");
|
|
52
|
+
_$insert(_el$, () => model.display());
|
|
53
|
+
_$effect(_$p => _$setProp(_el$, "fg", model.phase() === "running" ? theme().accent : theme().textMuted, _$p));
|
|
54
|
+
return _el$;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const tui = async api => {
|
|
59
|
+
api.slots.register({
|
|
60
|
+
order: TURN_TIMER_ORDER,
|
|
61
|
+
slots: {
|
|
62
|
+
session_prompt_right(_ctx, props) {
|
|
63
|
+
return _$createComponent(Section, {
|
|
64
|
+
api: api,
|
|
65
|
+
get session_id() {
|
|
66
|
+
return props.session_id;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
const plugin = {
|
|
74
|
+
id: "supercode.turn-timer",
|
|
75
|
+
tui
|
|
76
|
+
};
|
|
77
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
3
|
+
"name": "@glaicer/supercode-turn-timer",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "OpenCode TUI plugin: elapsed-time timer for the current turn, inline in the prompt footer.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Dmitrii Aronov <aronov.mml@gmail.com>",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Glaicer/supercode-turn-timer.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/Glaicer/supercode-turn-timer#readme",
|
|
14
|
+
"bugs": "https://github.com/Glaicer/supercode-turn-timer/issues",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"opencode",
|
|
17
|
+
"opencode-plugin",
|
|
18
|
+
"tui-plugin",
|
|
19
|
+
"timer",
|
|
20
|
+
"turn-timer"
|
|
21
|
+
],
|
|
22
|
+
"exports": {
|
|
23
|
+
"./tui": "./dist/turn-timer.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "node scripts/build.mjs",
|
|
33
|
+
"check:package": "node scripts/check-package.mjs",
|
|
34
|
+
"prepack": "npm run build && npm run check:package",
|
|
35
|
+
"test": "node --test --conditions browser 'src/**/*.test.ts'",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@opencode-ai/plugin": ">=1.18.21",
|
|
40
|
+
"@opentui/core": ">=0.4.5",
|
|
41
|
+
"@opentui/solid": ">=0.4.5",
|
|
42
|
+
"solid-js": "1.9.12"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@babel/core": "7.28.0",
|
|
46
|
+
"@babel/preset-typescript": "7.27.1",
|
|
47
|
+
"@opencode-ai/sdk": "1.18.21",
|
|
48
|
+
"@types/node": "24.13.3",
|
|
49
|
+
"babel-preset-solid": "1.9.12",
|
|
50
|
+
"typescript": "5.8.2"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=24",
|
|
54
|
+
"opencode": ">=1.18.21"
|
|
55
|
+
}
|
|
56
|
+
}
|