@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 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
+ }