opencode-task-timer 0.0.0-stage → 0.1.8

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/CHANGELOG.md ADDED
@@ -0,0 +1,60 @@
1
+ # Changelog
2
+
3
+ Alle nennenswerten Änderungen an diesem Plugin. Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionen nach [Semantic Versioning](https://semver.org/lang/de/). Aufbau und Pflege sind in [docs/changelog.md](docs/changelog.md) beschrieben.
4
+
5
+ ## [Unreleased]
6
+
7
+ ### Geändert
8
+
9
+ - Repository ist öffentlich unter `emelpe78/opencode-task-timer`; das Paket liegt auf npm als `opencode-task-timer`.
10
+
11
+ ---
12
+
13
+ ## [0.1.8] - 2026-10-26
14
+
15
+ Patch-Release: **Titel bleiben stehen, der Block spricht Englisch**.
16
+
17
+ ### Behoben
18
+
19
+ - **Titel fehlte:** `session.inbox.enqueued` kommt vor `session.execution.started`. Der Prompt-Text wird jetzt schon beim Vormerken je Sitzung abgelegt und benennt den startenden Lauf, statt beim Start ins Leere zu greifen.
20
+ - **Zustand überschrieb sich:** Der Store schreibt asynchron und lieferte kurz danach noch den alten Stand. Ereignisse rechnen deshalb auf einem eigenen Stand mit Revision; der Store gilt nur, wenn er nicht älter ist. Zwei Prompts kurz hintereinander verlieren so keinen Titel mehr.
21
+ - **Befehle waren unauffindbar:** Die Tastenebene registrierte kein `mode` und lag damit im Standardmodus `base`, der nur bei leerem Eingabefeld greift. Befehlspalette und Slash-Vervollständigung laufen dagegen im globalen Modus und sahen weder `/laufzeiten-reset` noch `/ott-reset`. Die Ebene ist jetzt `mode: "global"`, beide Befehle erscheinen wieder in Palette und Vervollständigung.
22
+ - Ein abgebrochener Prompt (`session.inbox.cancelled`) wird entfernt und benennt keinen späteren Lauf.
23
+
24
+ ### Geändert
25
+
26
+ - Der Block in der Seitenleiste ist englisch: Überschrift `Durations`, Status `error`, `aborted`, `superseded`, `ended`, `inactive`, `clock skew`, Hinweis `waiting for you`, Zusatz `20s waiting`, Summenzeile `2 tasks, 1m 57s, Ø 59s` und `untitled`. Befehle, Palettentitel, Dialoge und Toasts bleiben deutsch.
27
+ - Die beiden Befehle heißen jetzt `/ott-reset` und `/ott-reset-all` statt `/laufzeiten-reset` und `/laufzeiten-reset-alle`. Die alten Namen entfallen ersatzlos; Titel, Gruppe, Rückfrage und Toast bleiben deutsch.
28
+
29
+ ## [0.1.7] - 2026-10-26
30
+
31
+ Patch-Release: **Die veröffentlichte Fassung lädt wieder**.
32
+
33
+ ### Behoben
34
+
35
+ - Drei Ursachen nacheinander, die das Laden verhinderten:
36
+ - JSX in `node_modules` wird nicht transformiert, deshalb fehlte ein vorgebautes `dist/` (`Cannot find package 'react'`).
37
+ - `dist/tui.js` importierte `./laufzeit.ts`, die nicht im Paket lag (`Cannot find module './laufzeit.ts'`).
38
+ - Ein `build`-Skript löste npms Vorbereitung für Git-Abhängigkeiten aus und ließ die Installation abbrechen (`git dep preparation failed`). Der Build heißt jetzt `bauen`.
39
+
40
+ ### Hinzugefügt
41
+
42
+ - `scripts/build.mjs` baut `src/tui.tsx` und `src/measurement.ts` nach `dist/`.
43
+
44
+ ## [0.1.0] - 2026-10-26
45
+
46
+ Minor-Release: **Erste Fassung**. Die Linie bringt den Laufzeiten-Block in der Seitenleiste, die Messung je Agentenlauf samt Wartezeit und die durable Ablage. Die Patches dieser Linie nach `0.1.0` beheben ausschließlich das Laden der veröffentlichten Fassung.
47
+
48
+ ### Hinzugefügt
49
+
50
+ - Block `Laufzeiten` in der Seitenleiste, neueste Aufgabe oben, höchstens zehn Zeilen, mit Dauer, Titel und Status nur bei Abweichung.
51
+ - Laufender Eintrag mit sekündlichem Takt und Hinweis `wartet auf dich`.
52
+ - Wartezeit auf Freigaben und Rückfragen zählt zur Laufzeit und wird beim Abschluss als `· 20s warten` ausgewiesen.
53
+ - Summenzeile über alle abgeschlossenen Aufgaben der Sitzung.
54
+ - Zustände `Fehler`, `abgebrochen`, `verdrängt`, `beendet`, `inaktiv` und `Zeitsprung`.
55
+ - Messung je Agentenlauf statt je Prompt: `steer` und Fortsetzung nach einer Kompaktierung verlängern, Subagenten zählen nicht separat.
56
+ - Durable Ablage mit Aufbewahrung über 30 Tage und 200 Sitzungen.
57
+ - Liegengebliebene Läufe werden beim Start als `beendet` abgeschlossen.
58
+ - Befehle `/laufzeiten-reset` und `/laufzeiten-reset-alle`.
59
+ - Ausblenden bei sehr schmaler Seitenleiste.
60
+ - Tests für die Messlogik mit `node --test`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marc Lettau-Poelchen
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,93 @@
1
- # Temporary Holding Version
1
+ # opencode-task-timer
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
+ Ein Plugin für OpenCode 2, das in der Seitenleiste anzeigt, wie lange der Agent für jede Aufgabe gebraucht hat.
4
+
5
+ ```
6
+ Durations
7
+ 35s Zweite Aufgabe mit Fehler
8
+ 1m 22s Seitenleisten-Probe · 20s waiting
9
+ 2 tasks, 1m 57s, Ø 59s
10
+ ```
11
+
12
+ - Die Uhr startet, wenn der Agent mit einer Aufgabe beginnt.
13
+ - Die Uhr stoppt, wenn er fertig ist und du wieder gefragt wirst.
14
+ - Die Zeilen stehen in der Seitenleiste, neueste oben.
15
+
16
+ ## Installation
17
+
18
+ Über npm:
19
+
20
+ ```
21
+ opencode plugin add opencode-task-timer
22
+ ```
23
+
24
+ Oder direkt vom Repository:
25
+
26
+ ```
27
+ opencode plugin add github:emelpe78/opencode-task-timer#v0.1.8
28
+ ```
29
+
30
+ Danach OpenCode neu starten. Der Block erscheint in der Seitenleiste, sobald in der Sitzung die erste Aufgabe gelaufen ist. Eine Sitzung ohne Aufgaben zeigt nichts.
31
+
32
+ ## Was gemessen wird
33
+
34
+ Der vollständige Messvertrag steht in [README-Messung](docs/measurement.md). Die Kurzfassung:
35
+
36
+ - Gemessen wird ein Agentenlauf, nicht ein Prompt.
37
+ - Ein `steer`-Prompt und die Fortsetzung nach einer Kompaktierung verlängern die laufende Aufgabe. Ein `queue`-Prompt gehört zur nächsten.
38
+ - Subagenten werden nicht separat gezählt, ihre Zeit steckt in der Hauptlaufzeit.
39
+ - Warten auf deine Freigabe zählt zur Laufzeit und wird beim Abschluss zusätzlich ausgewiesen.
40
+ - Titel ist die erste Zeile deines Prompts, auf 40 Zeichen gekürzt.
41
+
42
+ ## Befehle
43
+
44
+ | Befehl | Wirkung |
45
+ | ---------------- | ------------------------------------------- |
46
+ | `/ott-reset` | Löscht die Laufzeiten der aktuellen Sitzung |
47
+ | `/ott-reset-all` | Löscht alle gespeicherten Laufzeiten |
48
+
49
+ Beide stehen auch in der Befehlspalette und fragen vor dem Löschen nach.
50
+
51
+ ## Anzeige
52
+
53
+ - Höchstens zehn Zeilen, neueste oben.
54
+ - Dauer als `42s`, `1m 23s`, ab einer Stunde `1h 02m`.
55
+ - Status nur bei Abweichung: `error`, `aborted`, `superseded`, `ended`, `inactive`, `clock skew`.
56
+ - Der laufende Eintrag tickt sekündlich und zeigt `waiting for you`, wenn er auf dich wartet.
57
+ - Summenzeile über alle abgeschlossenen Aufgaben der Sitzung, englisch.
58
+ - Bei sehr schmaler Seitenleiste bleibt der Block aus, statt unleserlich zu werden.
59
+ - Rein lesend, kein Tastenkürzel.
60
+
61
+ ## Speicherung
62
+
63
+ Der Zustand liegt im durabelen Store des Hosts und überlebt Neustarts. Er enthält je Aufgabe nur den gekürzten Titel und Zeitpunkte, niemals den vollen Prompt. Aufbewahrung: 30 Tage und höchstens 200 Sitzungen. Gelöschte Sitzungen verschwinden sofort.
64
+
65
+ ## Entwicklung
66
+
67
+ ```
68
+ npm install
69
+ npm run bauen # schreibt dist/tui.js und dist/measurement.js
70
+ npm test
71
+ ```
72
+
73
+ Die Messlogik liegt in `src/measurement.ts` und ist frei von OpenCode-Abhängigkeiten, damit sie mit `node --test` prüfbar bleibt. `src/tui.tsx` verdrahtet nur die Ereignisse und rendert den Block. Was gemessen wird, steht in [README-Messung](docs/measurement.md); wie `CHANGELOG.md` aufgebaut und gepflegt wird, in [Changelog-Regeln](docs/changelog.md).
74
+
75
+ `dist/` ist eingecheckt, damit die Installation direkt aus dem Repository funktioniert.
76
+
77
+ ## Fallstricke beim Veröffentlichen
78
+
79
+ - OpenCodes Solid-JSX-Transform überspringt Pfade mit `/node_modules/`. Ein veröffentlichtes Plugin liegt genau dort, deshalb braucht es ein vorgebautes `dist/`. Ohne das wird sein JSX als React-JSX übersetzt und der Ladevorgang scheitert an einem fehlenden `react`.
80
+ - `dist/` muss die Messlogik mitliefern. Ein Import auf `./measurement.ts` schlägt fehl, weil `src/` nicht im Paket liegt.
81
+ - `package.json` darf keines der Skripte `build`, `prepare`, `prepack`, `install`, `preinstall` oder `postinstall` nennen. npms pacote löst sonst bei einer Git-Abhängigkeit eine Vorbereitung aus, die hier scheitert. Der Build heißt deshalb `bauen`.
82
+ - Die Imports auf `@opentui/solid` und `solid-js` werden auf die Laufzeitmodule des Hosts umgeschrieben (`opentui:runtime-module:…`), wie im offiziellen Plugin `oc-tps`.
83
+ - `context.keymap.layer` braucht `mode: "global"`, sonst landet die Ebene im Standardmodus `base`. Der greift nur bei leerem Eingabefeld, während Befehlspalette und Slash-Vervollständigung im globalen Modus laufen. Ohne `mode` sind die Befehle dort unsichtbar, obwohl `palette: true` und `slash` gesetzt sind.
84
+ - Ein Plugin in `~/.config/opencode/plugins/` ist ein Server-Plugin und bekommt keinen TUI-Kontext. `context.keymap` fehlt dort, der Ladevorgang scheitert mit `undefined is not an object`. TUI-Plugins gehören über `cli.json` oder `opencode plugin add` eingebunden.
85
+
86
+ ## Voraussetzungen
87
+
88
+ - OpenCode 2.0 oder neuer (`@opencode/plugin` 2.x).
89
+ - Node 22.6 oder neuer, nur für die Tests.
90
+
91
+ ## Lizenz
92
+
93
+ [MIT](LICENSE)
@@ -0,0 +1,463 @@
1
+ // Reine Messlogik für opencode-task-timer.
2
+ //
3
+ // Diese Datei kennt weder OpenCode noch Solid noch JSX. Sie ist damit mit
4
+ // `node --test` prüfbar und enthält die eigentliche Wahrheit darüber, was als
5
+ // Laufzeit gezählt wird. Alles Sichtbare und alles Verdrahtete liegt in
6
+ // `tui.tsx`.
7
+ //
8
+ // Messvertrag (kurz):
9
+ // - Gemessen wird ein Agentenlauf, nicht ein Prompt.
10
+ // - Start ist `run-started`, Ende ist `run-ended` oder `run-interrupted`.
11
+ // - Zeitstempel kommen vom Server, nicht von der lokalen Uhr.
12
+ // - Ein `steer`-Prompt und eine automatische Fortsetzung nach Kompaktierung
13
+ // verlängern den laufenden Eintrag. Ein `queue`-Prompt führt über ein
14
+ // `superseded`-Ende zu einer neuen Zeile.
15
+ // - Kind-Sitzungen (Subagenten) werden vollständig ignoriert, damit ihre Zeit
16
+ // nicht doppelt in der Wurzel landet.
17
+ // - Warten auf Freigabe oder Rückfrage zählt zur Laufzeit und wird beim
18
+ // Abschluss zusätzlich ausgewiesen.
19
+
20
+ export const MAX_ENTRIES = 10;
21
+ export const MAX_TITLE = 40;
22
+ export const RETENTION_DAYS = 30;
23
+ export const MAX_SESSIONS = 200;
24
+ export const UNTITLED = "untitled";
25
+ export const WAITING_HINT = "waiting for you";
26
+ export const RUNNING_HINT = "running";
27
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
28
+ export function emptyState() {
29
+ return {
30
+ rev: 0,
31
+ entries: [],
32
+ texts: {},
33
+ pending: {},
34
+ lastPrompt: {},
35
+ sessions: [],
36
+ heartbeats: {}
37
+ };
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Formatierung
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** Dauer als `42s`, `1m 23s` oder `1h 02m`. Sekundengenau, ohne Zehntel. */
45
+ export function formatDuration(ms) {
46
+ const total = Math.max(0, Math.round(ms / 1000));
47
+ const hours = Math.floor(total / 3600);
48
+ const minutes = Math.floor(total % 3600 / 60);
49
+ const seconds = total % 60;
50
+ if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`;
51
+ if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
52
+ return `${seconds}s`;
53
+ }
54
+
55
+ /**
56
+ * Titel aus dem Prompt: erste nicht leere Zeile, auf `MAX_TITLE` Zeichen
57
+ * gekürzt. Ohne verwertbaren Text bleibt `untitled`.
58
+ */
59
+ export function shortenTitle(text) {
60
+ if (!text) return UNTITLED;
61
+ const line = text.split(/\r?\n/).map(part => part.trim()).find(part => part.length > 0);
62
+ if (!line) return UNTITLED;
63
+ const compact = line.replace(/\s+/g, " ");
64
+ if (compact.length <= MAX_TITLE) return compact;
65
+ return `${compact.slice(0, MAX_TITLE - 1).trimEnd()}…`;
66
+ }
67
+
68
+ /** Anzeigetext eines Status. Erfolgreiche Läufe bleiben schlicht. */
69
+ export function statusLabel(status) {
70
+ switch (status) {
71
+ case "running":
72
+ return RUNNING_HINT;
73
+ case "error":
74
+ return "error";
75
+ case "aborted":
76
+ return "aborted";
77
+ case "superseded":
78
+ return "superseded";
79
+ case "ended":
80
+ return "ended";
81
+ case "inactive":
82
+ return "inactive";
83
+ case "clockSkew":
84
+ return "clock skew";
85
+ case "done":
86
+ return undefined;
87
+ }
88
+ }
89
+ export function statusFromReason(reason) {
90
+ switch (reason) {
91
+ case "user":
92
+ return "aborted";
93
+ case "shutdown":
94
+ return "ended";
95
+ case "superseded":
96
+ return "superseded";
97
+ case "inactivity":
98
+ return "inactive";
99
+ }
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Ableitungen
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /** Dauer eines Eintrags. Negatives aus einer zurückgestellten Uhr wird zu null. */
107
+ export function durationMs(entry, now) {
108
+ const end = entry.end ?? now;
109
+ return Math.max(0, end - entry.start);
110
+ }
111
+
112
+ /** Wartezeit einschließlich einer noch laufenden Wartezeit. */
113
+ export function waitingMs(entry, now) {
114
+ const open = entry.waitingSince === undefined ? 0 : Math.max(0, now - entry.waitingSince);
115
+ return entry.waitingMs + open;
116
+ }
117
+ /**
118
+ * Kennzahlen über alle abgeschlossenen Einträge, auch die nicht erfolgreichen
119
+ * und auch die aus der Anzeige gefallenen. Der laufende Eintrag bleibt außen
120
+ * vor, sonst wäre die Summe bei jedem Takt eine andere.
121
+ */
122
+ export function stats(entries, now) {
123
+ const finished = entries.filter(entry => entry.status !== "running");
124
+ const totalMs = finished.reduce((sum, entry) => sum + durationMs(entry, now), 0);
125
+ const count = finished.length;
126
+ return {
127
+ count,
128
+ totalMs,
129
+ averageMs: count > 0 ? Math.round(totalMs / count) : 0
130
+ };
131
+ }
132
+ export function summaryLine(summary) {
133
+ if (summary.count === 0) return undefined;
134
+ const tasks = summary.count === 1 ? "1 task" : `${summary.count} tasks`;
135
+ return `${tasks}, ${formatDuration(summary.totalMs)}, Ø ${formatDuration(summary.averageMs)}`;
136
+ }
137
+
138
+ /** Zusatz zur Laufzeit, wenn beim Abschluss Wartezeit angefallen ist. */
139
+ export function waitingNote(entry, now) {
140
+ if (entry.status === "running") return undefined;
141
+ const waiting = waitingMs(entry, now);
142
+ if (waiting < 1000) return undefined;
143
+ return `${formatDuration(waiting)} waiting`;
144
+ }
145
+
146
+ /** Einträge einer Sitzung, neueste zuerst, auf `MAX_ENTRIES` begrenzt. */
147
+ export function visibleEntries(state, session, max = MAX_ENTRIES) {
148
+ return state.entries.filter(entry => entry.session === session).sort((left, right) => right.start - left.start).slice(0, max);
149
+ }
150
+ export function runningEntry(state, session) {
151
+ return state.entries.find(entry => entry.session === session && entry.status === "running");
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Zustandsübergänge
156
+ // ---------------------------------------------------------------------------
157
+
158
+ export function withEntries(state, entries) {
159
+ return {
160
+ ...state,
161
+ entries
162
+ };
163
+ }
164
+ function replace(state, id, patch) {
165
+ return withEntries(state, state.entries.map(entry => entry.id === id ? {
166
+ ...entry,
167
+ ...patch
168
+ } : entry));
169
+ }
170
+ function touchSession(state, session, time) {
171
+ const rest = state.sessions.filter(entry => entry !== session);
172
+ return {
173
+ ...state,
174
+ sessions: [...rest, session],
175
+ heartbeats: {
176
+ ...state.heartbeats,
177
+ [session]: time
178
+ }
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Ein `steer`-Prompt verlängert die laufende Aufgabe, deshalb wird der Titel
184
+ * ergänzt statt ersetzt. Passt die Ergänzung nicht mehr in `MAX_TITLE`, bleibt
185
+ * der bisherige Titel stehen, damit die Zeile lesbar bleibt.
186
+ */
187
+ function appendTitle(previous, next) {
188
+ if (previous === UNTITLED) return next;
189
+ if (next === UNTITLED) return previous;
190
+ const combined = `${previous} + ${next}`;
191
+ return combined.length <= MAX_TITLE ? combined : previous;
192
+ }
193
+ function finish(state, session, time, status) {
194
+ const running = runningEntry(state, session);
195
+ if (!running) return state;
196
+ const openWaiting = running.waitingSince === undefined ? 0 : Math.max(0, time - running.waitingSince);
197
+ // Eine zurückgestellte Systemuhr darf keine negative Laufzeit erzeugen.
198
+ const clockSkew = time < running.start;
199
+ return replace(state, running.id, {
200
+ end: clockSkew ? running.start : time,
201
+ status: clockSkew ? "clockSkew" : status,
202
+ waitingMs: running.waitingMs + openWaiting,
203
+ waitingSince: undefined
204
+ });
205
+ }
206
+
207
+ /** Wendet ein Ereignis auf den Zustand an. Ohne Seiteneffekte. */
208
+ export function apply(state, event) {
209
+ const next = reduce(state, event);
210
+ // Jede angenommene Änderung zählt eine Revision weiter.
211
+ return next === state ? state : {
212
+ ...next,
213
+ rev: state.rev + 1
214
+ };
215
+ }
216
+ function reduce(state, event) {
217
+ switch (event.kind) {
218
+ case "run-started":
219
+ {
220
+ if (event.isChild) return state;
221
+ // Doppelte Zustellung, etwa nach Hot Reload oder in einer zweiten TUI.
222
+ if (state.entries.some(entry => entry.id === event.id)) return state;
223
+ const touched = touchSession(state, event.session, event.time);
224
+ const running = runningEntry(touched, event.session);
225
+ // Fortsetzung, etwa nach einer Kompaktierung: derselbe Auftrag läuft
226
+ // weiter, es entsteht keine neue Zeile.
227
+ if (running) return touched;
228
+ // Der Host meldet den Prompt vor dem Start, die Zustellung aber erst
229
+ // danach. Deshalb ist der vorgemerkte Prompt die richtige Quelle und der
230
+ // zuletzt zugestellte nur der Rückfall. `titleFrom` verhindert später,
231
+ // dass dieselbe Zustellung den Titel ein zweites Mal ergänzt.
232
+ const queued = touched.pending[event.session]?.[0];
233
+ const fallback = queued?.text ?? touched.lastPrompt[event.session];
234
+ const entry = {
235
+ id: event.id,
236
+ session: event.session,
237
+ title: shortenTitle(fallback),
238
+ ...(queued === undefined ? {} : {
239
+ titleFrom: queued.id
240
+ }),
241
+ start: event.time,
242
+ status: "running",
243
+ waitingMs: 0
244
+ };
245
+ return withEntries(touched, [...touched.entries, entry]);
246
+ }
247
+ case "run-ended":
248
+ {
249
+ if (event.isChild) return state;
250
+ return finish(touchSession(state, event.session, event.time), event.session, event.time, event.result === "done" ? "done" : "error");
251
+ }
252
+ case "run-interrupted":
253
+ {
254
+ if (event.isChild) return state;
255
+ return finish(touchSession(state, event.session, event.time), event.session, event.time, statusFromReason(event.reason));
256
+ }
257
+ case "prompt-queued":
258
+ {
259
+ const queue = state.pending[event.session] ?? [];
260
+ const known = queue.some(item => item.id === event.deliveryID);
261
+ return {
262
+ ...state,
263
+ texts: {
264
+ ...state.texts,
265
+ [event.deliveryID]: event.text
266
+ },
267
+ pending: {
268
+ ...state.pending,
269
+ [event.session]: known ? queue : [...queue, {
270
+ id: event.deliveryID,
271
+ text: event.text,
272
+ delivery: event.delivery
273
+ }]
274
+ }
275
+ };
276
+ }
277
+ case "prompt-delivered":
278
+ {
279
+ if (event.isChild) return state;
280
+ const text = state.texts[event.deliveryID];
281
+ const title = shortenTitle(text);
282
+ // Der Prompt ist zugestellt: Text und Vormerkung sind verbraucht.
283
+ const texts = {
284
+ ...state.texts
285
+ };
286
+ delete texts[event.deliveryID];
287
+ const queue = (state.pending[event.session] ?? []).filter(item => item.id !== event.deliveryID);
288
+ const pending = {
289
+ ...state.pending,
290
+ [event.session]: queue
291
+ };
292
+ if (queue.length === 0) delete pending[event.session];
293
+ const touched = {
294
+ ...state,
295
+ texts,
296
+ pending,
297
+ lastPrompt: {
298
+ ...state.lastPrompt,
299
+ [event.session]: text ?? title
300
+ }
301
+ };
302
+ const running = runningEntry(touched, event.session);
303
+ if (!running) return touched;
304
+ // Hat dieser Prompt den Lauf schon beim Start benannt, steht der Titel
305
+ // bereits und wird nicht ein zweites Mal ergänzt.
306
+ if (running.titleFrom === event.deliveryID) return touched;
307
+ // Ein `steer`-Prompt schiebt sich in die laufende Arbeit und ergänzt
308
+ // deren Titel.
309
+ if (event.delivery === "steer") {
310
+ return replace(touched, running.id, {
311
+ title: appendTitle(running.title, title)
312
+ });
313
+ }
314
+ // Ein `queue`-Prompt gehört zum naechsten Lauf. Ist noch einer offen, wird
315
+ // er nicht umbenannt, sonst stuende ein fremder Titel an einer fremden
316
+ // Laufzeit. Ist der offene Lauf noch ohne Titel, ist das die eigene
317
+ // Zustellung, die der Host erst nach dem Start meldet.
318
+ if (running.title === UNTITLED) {
319
+ return replace(touched, running.id, {
320
+ title
321
+ });
322
+ }
323
+ return touched;
324
+ }
325
+ case "prompt-cancelled":
326
+ {
327
+ // Ein verworfener Prompt darf weder den nächsten Lauf benennen noch als
328
+ // Rückfall liegen bleiben.
329
+ const texts = {
330
+ ...state.texts
331
+ };
332
+ delete texts[event.deliveryID];
333
+ const queue = (state.pending[event.session] ?? []).filter(item => item.id !== event.deliveryID);
334
+ const pending = {
335
+ ...state.pending,
336
+ [event.session]: queue
337
+ };
338
+ if (queue.length === 0) delete pending[event.session];
339
+ return {
340
+ ...state,
341
+ texts,
342
+ pending
343
+ };
344
+ }
345
+ case "waiting-started":
346
+ {
347
+ const running = runningEntry(state, event.session);
348
+ if (!running || running.waitingSince !== undefined) return state;
349
+ return replace(state, running.id, {
350
+ waitingSince: event.time
351
+ });
352
+ }
353
+ case "waiting-ended":
354
+ {
355
+ const running = runningEntry(state, event.session);
356
+ if (!running || running.waitingSince === undefined) return state;
357
+ const added = Math.max(0, event.time - running.waitingSince);
358
+ return replace(state, running.id, {
359
+ waitingMs: running.waitingMs + added,
360
+ waitingSince: undefined
361
+ });
362
+ }
363
+ case "session-deleted":
364
+ {
365
+ const texts = {
366
+ ...state.texts
367
+ };
368
+ const pending = {
369
+ ...state.pending
370
+ };
371
+ const lastPrompt = {
372
+ ...state.lastPrompt
373
+ };
374
+ const heartbeats = {
375
+ ...state.heartbeats
376
+ };
377
+ delete pending[event.session];
378
+ delete lastPrompt[event.session];
379
+ delete heartbeats[event.session];
380
+ return {
381
+ entries: state.entries.filter(entry => entry.session !== event.session),
382
+ texts,
383
+ pending,
384
+ lastPrompt,
385
+ sessions: state.sessions.filter(session => session !== event.session),
386
+ heartbeats
387
+ };
388
+ }
389
+ case "heartbeat":
390
+ {
391
+ if (event.isChild) return state;
392
+ return touchSession(state, event.session, event.time);
393
+ }
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Schließt beim Start liegengebliebene Läufe ab. Die Dauer endet beim letzten
399
+ * Lebenszeichen, nicht bei der aktuellen Uhrzeit, sonst würde eine über Nacht
400
+ * geschlossene Sitzung eine absurde Laufzeit behaupten.
401
+ */
402
+ export function closeStaleRuns(state) {
403
+ const entries = state.entries.map(entry => {
404
+ if (entry.status !== "running") return entry;
405
+ const last = state.heartbeats[entry.session] ?? entry.start;
406
+ const end = Math.max(entry.start, last);
407
+ return {
408
+ ...entry,
409
+ status: "ended",
410
+ end,
411
+ waitingSince: undefined
412
+ };
413
+ });
414
+ return withEntries(state, entries);
415
+ }
416
+
417
+ /**
418
+ * Räumt nach Alter und Anzahl auf: Einträge älter als `RETENTION_DAYS` verschwinden,
419
+ * und es bleiben höchstens `MAX_SESSIONS` Sitzungen erhalten.
420
+ */
421
+ export function prune(state, now) {
422
+ const cutoff = now - RETENTION_DAYS * MS_PER_DAY;
423
+ const kept = new Set(state.sessions.slice(Math.max(0, state.sessions.length - MAX_SESSIONS)));
424
+ const entries = state.entries.filter(entry => entry.start >= cutoff && kept.has(entry.session));
425
+ const liveSessions = new Set(entries.map(entry => entry.session));
426
+ const pending = {};
427
+ const lastPrompt = {};
428
+ const heartbeats = {};
429
+ for (const session of liveSessions) {
430
+ const queue = state.pending[session];
431
+ if (queue !== undefined && queue.length > 0) pending[session] = queue;
432
+ const prompt = state.lastPrompt[session];
433
+ if (prompt !== undefined) lastPrompt[session] = prompt;
434
+ const beat = state.heartbeats[session];
435
+ if (beat !== undefined) heartbeats[session] = beat;
436
+ }
437
+ // Vorgemerkte Texte ohne noch offene Vormerkung sind nach einem Aufräumen
438
+ // wertlos: ihre Zustellung kommt nicht mehr.
439
+ const texts = {};
440
+ for (const queue of Object.values(pending)) {
441
+ for (const item of queue) {
442
+ const text = state.texts[item.id];
443
+ if (text !== undefined) texts[item.id] = text;
444
+ }
445
+ }
446
+ return {
447
+ rev: state.rev + 1,
448
+ entries,
449
+ texts,
450
+ pending,
451
+ lastPrompt,
452
+ sessions: state.sessions.filter(session => liveSessions.has(session)),
453
+ heartbeats
454
+ };
455
+ }
456
+
457
+ /** Leert die Einträge einer Sitzung, ohne andere Sitzungen anzutasten. */
458
+ export function clearSession(state, session) {
459
+ return {
460
+ ...withEntries(state, state.entries.filter(entry => entry.session !== session)),
461
+ rev: state.rev + 1
462
+ };
463
+ }
package/dist/tui.js ADDED
@@ -0,0 +1,536 @@
1
+ import { use as _$use } from "opentui:runtime-module:%40opentui%2Fsolid";
2
+ import { createComponent as _$createComponent } from "opentui:runtime-module:%40opentui%2Fsolid";
3
+ import { effect as _$effect } from "opentui:runtime-module:%40opentui%2Fsolid";
4
+ import { memo as _$memo } from "opentui:runtime-module:%40opentui%2Fsolid";
5
+ import { createTextNode as _$createTextNode } from "opentui:runtime-module:%40opentui%2Fsolid";
6
+ import { insertNode as _$insertNode } from "opentui:runtime-module:%40opentui%2Fsolid";
7
+ import { insert as _$insert } from "opentui:runtime-module:%40opentui%2Fsolid";
8
+ import { setProp as _$setProp } from "opentui:runtime-module:%40opentui%2Fsolid";
9
+ import { createElement as _$createElement } from "opentui:runtime-module:%40opentui%2Fsolid";
10
+ // OpenCode-2-TUI-Plugin: Laufzeiten je Aufgabe in der Seitenleiste.
11
+ //
12
+ // Diese Datei verdrahtet die OpenCode-Ereignisse mit der reinen Messlogik aus
13
+ // `measurement.ts` und rendert den Block. Die Wahrheit über die Messung liegt in
14
+ // `measurement.ts` und ist dort durch Tests belegt; hier steht nur Übersetzung und
15
+ // Darstellung.
16
+ //
17
+ // Messvertrag siehe README, Abschnitt "Was gemessen wird".
18
+
19
+ import { Plugin } from "@opencode/plugin/tui";
20
+ import { For, Show, createMemo, createSignal, onCleanup } from "opentui:runtime-module:solid-js";
21
+ import { apply, clearSession, closeStaleRuns, durationMs, emptyState, formatDuration, prune, runningEntry, stats, statusLabel, summaryLine, visibleEntries, waitingNote, WAITING_HINT } from "./measurement.js";
22
+
23
+ /** Speicherschlüssel im durabelen Host-Store. */
24
+ const STORAGE = "laufzeiten";
25
+ /** Unter dieser Breite wird der Block ausgeblendet statt unleserlich zu werden. */
26
+ const MIN_WIDTH = 22;
27
+ /** Takt der Live-Anzeige. */
28
+ const TICK_MS = 1_000;
29
+ /** Ab hier wird der Titel gekürzt, damit die Zeile in die Spalte passt. */
30
+ const TITLE_SPACE = 12;
31
+ /** Liest einen Textpfad defensiv, weil die Payload-Form zwischen Versionen wechselt. */
32
+ function textAt(value, path) {
33
+ let current = value;
34
+ for (const part of path) {
35
+ if (current === null || typeof current !== "object") return undefined;
36
+ current = current[part];
37
+ }
38
+ return typeof current === "string" ? current : undefined;
39
+ }
40
+
41
+ /**
42
+ * Prompt-Text eines Zustellungs-Ereignisses. Die Form ist je nach Version
43
+ * `{ item: { type, payload: { text } } }` oder `{ item: { text } }`, deshalb
44
+ * werden beide Wege versucht.
45
+ */
46
+ function promptText(event) {
47
+ for (const path of [["data", "item", "payload", "text"], ["data", "item", "text"], ["data", "text"], ["item", "payload", "text"], ["item", "text"]]) {
48
+ const text = textAt(event, path);
49
+ if (text !== undefined) return text;
50
+ }
51
+ return "";
52
+ }
53
+ function deliveryID(event) {
54
+ for (const path of [["data", "inboxID"], ["data", "item", "id"], ["data", "item", "payload", "id"], ["inboxID"]]) {
55
+ const id = textAt(event, path);
56
+ if (id !== undefined) return id;
57
+ }
58
+ return undefined;
59
+ }
60
+ export default Plugin.define({
61
+ id: "task-timer",
62
+ async setup(context) {
63
+ // Beim Start liegengebliebene Läufe abschließen und aufräumen, damit eine
64
+ // über Nacht geschlossene Sitzung keine absurde Laufzeit behauptet.
65
+ const [stored, write] = context.storage.store(STORAGE, {
66
+ initial: {
67
+ state: emptyState()
68
+ }
69
+ });
70
+ const cleaned = prune(closeStaleRuns(stored.state), Date.now());
71
+ if (cleaned !== stored.state) {
72
+ await write(draft => {
73
+ draft.state = cleaned;
74
+ });
75
+ }
76
+ // Ein Taktzähler stößt die sekündliche Neuberechnung an. Der Zustand selbst
77
+ // ist der reaktive Store, deshalb genügt ein Zähler ohne Wert.
78
+ const [tick, setTick] = createSignal(0);
79
+ const nudge = () => setTick(value => value + 1);
80
+
81
+ // Der Store schreibt asynchron und zieht seinen Stand verzögert nach. Würde
82
+ // jedes Ereignis blind aus dem Store rechnen, überschriebe der alte Stand
83
+ // das frisch Zugestellte. Genau daran ging der Titel verloren. Deshalb trägt
84
+ // jeder Zustand eine Revision: der Store gilt nur, wenn er nicht älter ist
85
+ // als der eigene Stand. So gehen Änderungen einer zweiten TUI nicht
86
+ // verloren, und der eigene Fortschritt wird nicht zurückgedreht.
87
+ const [latest, setLatest] = createSignal(cleaned);
88
+ const advance = next => {
89
+ setLatest(() => next);
90
+ return write(draft => {
91
+ draft.state = next;
92
+ }).then(() => undefined, () => undefined);
93
+ };
94
+
95
+ /** Der Zustand, auf den das nächste Ereignis trifft. */
96
+ const currentState = () => stored.state.rev > latest().rev ? stored.state : latest();
97
+ const accept = event => {
98
+ const before = currentState();
99
+ const after = apply(before, event);
100
+ if (after === before) return;
101
+ void advance(after);
102
+ nudge();
103
+ };
104
+
105
+ /** Übersetzt ein OpenCode-Ereignis in unsere Sprache und wendet es an. */
106
+ const translate = (type, event) => {
107
+ const data = event.data ?? {};
108
+ const session = typeof data.sessionID === "string" ? data.sessionID : undefined;
109
+ const stamp = event.created;
110
+ const time = typeof stamp === "number" ? stamp : Date.now();
111
+ const isChild = session !== undefined && session !== context.data.session.root(session);
112
+ switch (type) {
113
+ case "session.execution.started":
114
+ {
115
+ if (!session) return;
116
+ const identifier = event.id;
117
+ const id = typeof identifier === "string" ? identifier : `${session}:${time}`;
118
+ accept({
119
+ kind: "run-started",
120
+ id,
121
+ session,
122
+ time,
123
+ isChild
124
+ });
125
+ return;
126
+ }
127
+ case "session.execution.succeeded":
128
+ {
129
+ if (!session) return;
130
+ accept({
131
+ kind: "run-ended",
132
+ session,
133
+ time,
134
+ result: "done",
135
+ isChild
136
+ });
137
+ return;
138
+ }
139
+ case "session.execution.failed":
140
+ {
141
+ if (!session) return;
142
+ accept({
143
+ kind: "run-ended",
144
+ session,
145
+ time,
146
+ result: "error",
147
+ isChild
148
+ });
149
+ return;
150
+ }
151
+ case "session.execution.interrupted":
152
+ {
153
+ if (!session) return;
154
+ const raw = data.reason;
155
+ const known = ["user", "shutdown", "superseded", "inactivity"];
156
+ const reason = known.find(value => value === raw) ?? "user";
157
+ accept({
158
+ kind: "run-interrupted",
159
+ session,
160
+ time,
161
+ reason,
162
+ isChild
163
+ });
164
+ return;
165
+ }
166
+ case "session.inbox.enqueued":
167
+ {
168
+ // Der Host meldet den Prompt vor dem Lauf. Text und Zustellart werden
169
+ // vorgemerkt, damit der Eintrag beim Start schon seinen Titel kennt.
170
+ const id = deliveryID(event);
171
+ if (!id || !session) return;
172
+ const item = data.item ?? {};
173
+ const delivery = item.delivery === "steer" ? "steer" : "queue";
174
+ accept({
175
+ kind: "prompt-queued",
176
+ deliveryID: id,
177
+ session,
178
+ text: promptText(event),
179
+ delivery
180
+ });
181
+ return;
182
+ }
183
+ case "session.inbox.delivered":
184
+ {
185
+ const id = deliveryID(event);
186
+ if (!session || !id) return;
187
+ // Die Zustellart vom Vormerken bleibt maßgeblich; das Ereignis selbst
188
+ // trägt sie nicht.
189
+ const queued = currentState().pending[session] ?? [];
190
+ const delivery = queued.find(item => item.id === id)?.delivery ?? "queue";
191
+ accept({
192
+ kind: "prompt-delivered",
193
+ deliveryID: id,
194
+ session,
195
+ time,
196
+ delivery,
197
+ isChild
198
+ });
199
+ return;
200
+ }
201
+ case "session.inbox.delivery.changed":
202
+ {
203
+ // Nur eine Umstellung auf `steer` verlängert die laufende Aufgabe.
204
+ const id = deliveryID(event);
205
+ if (!session || !id || data.delivery !== "steer") return;
206
+ accept({
207
+ kind: "prompt-delivered",
208
+ deliveryID: id,
209
+ session,
210
+ time,
211
+ delivery: "steer",
212
+ isChild
213
+ });
214
+ return;
215
+ }
216
+ case "session.inbox.cancelled":
217
+ {
218
+ // Ein verworfener Prompt benennt keinen Lauf.
219
+ const id = deliveryID(event);
220
+ if (!session || !id) return;
221
+ accept({
222
+ kind: "prompt-cancelled",
223
+ deliveryID: id,
224
+ session
225
+ });
226
+ return;
227
+ }
228
+ case "session.permissions":
229
+ {
230
+ if (!session) return;
231
+ const open = Array.isArray(data.permissions) && data.permissions.length > 0;
232
+ accept({
233
+ kind: open ? "waiting-started" : "waiting-ended",
234
+ session,
235
+ time
236
+ });
237
+ return;
238
+ }
239
+ case "session.deleted":
240
+ {
241
+ if (!session) return;
242
+ accept({
243
+ kind: "session-deleted",
244
+ session
245
+ });
246
+ return;
247
+ }
248
+ case "session.step.started":
249
+ case "session.step.ended":
250
+ case "session.tool.called":
251
+ case "session.tool.success":
252
+ case "session.tool.failed":
253
+ case "session.status":
254
+ case "session.retry.scheduled":
255
+ {
256
+ // Lebenszeichen halten die Dauer eines liegengebliebenen Laufs ehrlich.
257
+ if (!session) return;
258
+ accept({
259
+ kind: "heartbeat",
260
+ session,
261
+ time,
262
+ isChild
263
+ });
264
+ return;
265
+ }
266
+ default:
267
+ return;
268
+ }
269
+ };
270
+ const subscription = context.data.listen(event => {
271
+ const type = event.details?.type;
272
+ if (!type) return;
273
+ translate(type, event.details);
274
+ });
275
+ const ticker = setInterval(nudge, TICK_MS);
276
+ onCleanup(() => {
277
+ clearInterval(ticker);
278
+ subscription();
279
+ });
280
+
281
+ // ---------------------------------------------------------------------
282
+ // Darstellung
283
+ // ---------------------------------------------------------------------
284
+
285
+ function Row(props) {
286
+ const duration = createMemo(() => formatDuration(durationMs(props.entry, props.now)));
287
+ const running = createMemo(() => props.entry.status === "running");
288
+ const waiting = createMemo(() => props.entry.waitingSince !== undefined);
289
+ const status = createMemo(() => statusLabel(props.entry.status));
290
+ const note = createMemo(() => waitingNote(props.entry, props.now));
291
+ const title = createMemo(() => {
292
+ const space = Math.max(6, props.width - TITLE_SPACE);
293
+ const text = props.entry.title;
294
+ return text.length <= space ? text : `${text.slice(0, space - 1).trimEnd()}…`;
295
+ });
296
+ const color = createMemo(() => {
297
+ switch (props.entry.status) {
298
+ case "error":
299
+ return context.theme.hue.red[500];
300
+ case "aborted":
301
+ case "superseded":
302
+ case "ended":
303
+ case "inactive":
304
+ case "clockSkew":
305
+ return context.theme.hue.orange[500];
306
+ default:
307
+ return context.theme.text.muted;
308
+ }
309
+ });
310
+ return (() => {
311
+ var _el$ = _$createElement("box"),
312
+ _el$2 = _$createElement("box"),
313
+ _el$3 = _$createElement("text"),
314
+ _el$4 = _$createElement("text"),
315
+ _el$5 = _$createTextNode(` `);
316
+ _$insertNode(_el$, _el$2);
317
+ _$setProp(_el$, "flexDirection", "column");
318
+ _$insertNode(_el$2, _el$3);
319
+ _$insertNode(_el$2, _el$4);
320
+ _$setProp(_el$2, "flexDirection", "row");
321
+ _$insert(_el$3, () => duration().padStart(7));
322
+ _$insertNode(_el$4, _el$5);
323
+ _$insert(_el$4, title, null);
324
+ _$insert(_el$, _$createComponent(Show, {
325
+ get when() {
326
+ return waiting() || status() !== undefined || note() !== undefined;
327
+ },
328
+ get children() {
329
+ var _el$6 = _$createElement("box");
330
+ _$setProp(_el$6, "flexDirection", "row");
331
+ _$setProp(_el$6, "paddingLeft", 8);
332
+ _$insert(_el$6, _$createComponent(Show, {
333
+ get when() {
334
+ return waiting();
335
+ },
336
+ get children() {
337
+ var _el$7 = _$createElement("text");
338
+ _$insert(_el$7, WAITING_HINT);
339
+ _$effect(_$p => _$setProp(_el$7, "fg", context.theme.hue.orange[500], _$p));
340
+ return _el$7;
341
+ }
342
+ }), null);
343
+ _$insert(_el$6, _$createComponent(Show, {
344
+ get when() {
345
+ return _$memo(() => !!!waiting())() && status() !== undefined;
346
+ },
347
+ get children() {
348
+ var _el$8 = _$createElement("text");
349
+ _$insert(_el$8, status);
350
+ _$effect(_$p => _$setProp(_el$8, "fg", color(), _$p));
351
+ return _el$8;
352
+ }
353
+ }), null);
354
+ _$insert(_el$6, _$createComponent(Show, {
355
+ get when() {
356
+ return note() !== undefined;
357
+ },
358
+ get children() {
359
+ var _el$9 = _$createElement("text"),
360
+ _el$0 = _$createTextNode(` · `);
361
+ _$insertNode(_el$9, _el$0);
362
+ _$insert(_el$9, note, null);
363
+ _$effect(_$p => _$setProp(_el$9, "fg", context.theme.text.muted, _$p));
364
+ return _el$9;
365
+ }
366
+ }), null);
367
+ return _el$6;
368
+ }
369
+ }), null);
370
+ _$effect(_p$ => {
371
+ var _v$ = running() ? context.theme.hue.accent[500] : context.theme.text.base,
372
+ _v$2 = context.theme.text.base;
373
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$3, "fg", _v$, _p$.e));
374
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$4, "fg", _v$2, _p$.t));
375
+ return _p$;
376
+ }, {
377
+ e: undefined,
378
+ t: undefined
379
+ });
380
+ return _el$;
381
+ })();
382
+ }
383
+ function Block(props) {
384
+ const [width, setWidth] = createSignal(0);
385
+ const now = createMemo(() => {
386
+ tick();
387
+ return Date.now();
388
+ });
389
+ const list = createMemo(() => visibleEntries(currentState(), props.sessionID));
390
+ const running = createMemo(() => runningEntry(currentState(), props.sessionID));
391
+ const numbers = createMemo(() => stats(currentState().entries, now()));
392
+ const summary = createMemo(() => summaryLine(numbers()));
393
+
394
+ // Eine Kind-Sitzung zeigt nichts: die Liste gehört zur Wurzel-Aufgabe.
395
+ const visible = createMemo(() => {
396
+ if (props.sessionID !== context.data.session.root(props.sessionID)) return false;
397
+ return list().length > 0 && width() >= MIN_WIDTH;
398
+ });
399
+ return (() => {
400
+ var _el$1 = _$createElement("box");
401
+ _$use(element => {
402
+ const box = element;
403
+ const measure = () => setWidth(box.width ?? 0);
404
+ box.onSizeChange = measure;
405
+ measure();
406
+ }, _el$1);
407
+ _$setProp(_el$1, "flexDirection", "column");
408
+ _$setProp(_el$1, "paddingTop", 1);
409
+ _$setProp(_el$1, "flexShrink", 0);
410
+ _$insert(_el$1, _$createComponent(Show, {
411
+ get when() {
412
+ return visible();
413
+ },
414
+ get children() {
415
+ return [(() => {
416
+ var _el$10 = _$createElement("text");
417
+ _$insertNode(_el$10, _$createTextNode(`Durations`));
418
+ _$effect(_$p => _$setProp(_el$10, "fg", context.theme.text.muted, _$p));
419
+ return _el$10;
420
+ })(), _$createComponent(Show, {
421
+ get when() {
422
+ return running() !== undefined;
423
+ },
424
+ get children() {
425
+ return _$createComponent(Row, {
426
+ get entry() {
427
+ return running();
428
+ },
429
+ get now() {
430
+ return now();
431
+ },
432
+ get width() {
433
+ return width();
434
+ }
435
+ });
436
+ }
437
+ }), _$createComponent(For, {
438
+ get each() {
439
+ return list().filter(entry => entry.status !== "running");
440
+ },
441
+ children: entry => _$createComponent(Row, {
442
+ entry: entry,
443
+ get now() {
444
+ return now();
445
+ },
446
+ get width() {
447
+ return width();
448
+ }
449
+ })
450
+ }), _$createComponent(Show, {
451
+ get when() {
452
+ return summary() !== undefined;
453
+ },
454
+ get children() {
455
+ var _el$12 = _$createElement("text");
456
+ _$insert(_el$12, summary);
457
+ _$effect(_$p => _$setProp(_el$12, "fg", context.theme.text.muted, _$p));
458
+ return _el$12;
459
+ }
460
+ })];
461
+ }
462
+ }));
463
+ return _el$1;
464
+ })();
465
+ }
466
+ const slot = context.ui.slot({
467
+ append: "sidebar.content",
468
+ render: input => _$createComponent(Block, {
469
+ get sessionID() {
470
+ return input.sessionID;
471
+ }
472
+ })
473
+ });
474
+ onCleanup(slot);
475
+
476
+ // ---------------------------------------------------------------------
477
+ // Befehle
478
+ // ---------------------------------------------------------------------
479
+
480
+ const reset = async (all, session) => {
481
+ if (!all && !session) {
482
+ context.ui.toast.show({
483
+ message: "Keine Sitzung geöffnet",
484
+ variant: "warning"
485
+ });
486
+ return;
487
+ }
488
+ const confirmed = await context.ui.dialog.confirm({
489
+ title: "Laufzeiten zurücksetzen",
490
+ message: all ? "Alle gespeicherten Laufzeiten verwerfen?" : "Laufzeiten dieser Sitzung verwerfen?"
491
+ });
492
+ if (!confirmed) return;
493
+ // Zurücksetzen behält die Revision bei, damit die Anzeige sofort folgt.
494
+ await advance(all ? emptyState() : clearSession(currentState(), session));
495
+ nudge();
496
+ context.ui.toast.show({
497
+ message: "Laufzeiten zurückgesetzt",
498
+ variant: "info"
499
+ });
500
+ };
501
+ const currentSession = () => {
502
+ const route = context.ui.router.current();
503
+ return route.type === "session" ? route.sessionID : undefined;
504
+ };
505
+ context.keymap.layer(() => ({
506
+ // Ohne `mode` landet die Ebene im Standardmodus `base`, der nur bei
507
+ // leerem Eingabefeld greift. Palettensuche und Slash-Vervollständigung
508
+ // laufen dagegen im globalen Modus, deshalb blieben beide Befehle dort
509
+ // unsichtbar.
510
+ mode: "global",
511
+ commands: [{
512
+ id: "task-timer.reset",
513
+ title: "Laufzeiten zurücksetzen",
514
+ group: "Laufzeiten",
515
+ palette: true,
516
+ slash: {
517
+ name: "ott-reset"
518
+ },
519
+ run: () => {
520
+ void reset(false, currentSession());
521
+ }
522
+ }, {
523
+ id: "task-timer.reset-all",
524
+ title: "Laufzeiten aller Sitzungen zurücksetzen",
525
+ group: "Laufzeiten",
526
+ palette: true,
527
+ slash: {
528
+ name: "ott-reset-all"
529
+ },
530
+ run: () => {
531
+ void reset(true, currentSession());
532
+ }
533
+ }]
534
+ }));
535
+ }
536
+ });
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "opencode-task-timer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.8",
4
+ "description": "OpenCode-2-TUI-Plugin: Laufzeiten je Aufgabe in der Seitenleiste",
5
+ "license": "MIT",
6
+ "author": "Marc Lettau-Poelchen <lettau.poelchen@emelpe.de>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/emelpe78/opencode-task-timer.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/emelpe78/opencode-task-timer/issues"
13
+ },
14
+ "homepage": "https://github.com/emelpe78/opencode-task-timer#readme",
15
+ "type": "module",
16
+ "exports": {
17
+ "./tui": "./dist/tui.js"
18
+ },
19
+ "files": [
20
+ "dist/",
21
+ "README.md",
22
+ "CHANGELOG.md",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "test": "node --test 'test/*.test.ts'",
27
+ "bauen": "node scripts/build.mjs"
28
+ },
29
+ "engines": {
30
+ "node": ">=22.6"
31
+ },
32
+ "keywords": [
33
+ "opencode",
34
+ "opencode-plugin",
35
+ "tui"
36
+ ],
37
+ "devDependencies": {
38
+ "@babel/core": "^7.29.7",
39
+ "@babel/preset-typescript": "^7.29.7",
40
+ "babel-preset-solid": "^1.9.16",
41
+ "solid-js": "^1.9.17"
42
+ }
43
+ }