opencode-task-timer 0.0.0-stage → 0.1.9
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 +65 -0
- package/LICENSE +21 -0
- package/README.md +94 -2
- package/dist/measurement.js +463 -0
- package/dist/tui.js +536 -0
- package/package.json +50 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
---
|
|
8
|
+
|
|
9
|
+
## [0.1.9] - 2026-10-28
|
|
10
|
+
|
|
11
|
+
Patch-Release: **Öffentlich und auf npm**.
|
|
12
|
+
|
|
13
|
+
### Geändert
|
|
14
|
+
|
|
15
|
+
- **Open Source:** Repository ist öffentlich unter `emelpe78/opencode-task-timer`; das Paket liegt auf npm als `opencode-task-timer`.
|
|
16
|
+
- **Auffindbarkeit:** Beschreibung und Keywords für GitHub und npm geschärft, damit das Plugin leichter gefunden wird.
|
|
17
|
+
|
|
18
|
+
## [0.1.8] - 2026-10-26
|
|
19
|
+
|
|
20
|
+
Patch-Release: **Titel bleiben stehen, der Block spricht Englisch**.
|
|
21
|
+
|
|
22
|
+
### Behoben
|
|
23
|
+
|
|
24
|
+
- **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.
|
|
25
|
+
- **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.
|
|
26
|
+
- **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.
|
|
27
|
+
- Ein abgebrochener Prompt (`session.inbox.cancelled`) wird entfernt und benennt keinen späteren Lauf.
|
|
28
|
+
|
|
29
|
+
### Geändert
|
|
30
|
+
|
|
31
|
+
- 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.
|
|
32
|
+
- 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.
|
|
33
|
+
|
|
34
|
+
## [0.1.7] - 2026-10-26
|
|
35
|
+
|
|
36
|
+
Patch-Release: **Die veröffentlichte Fassung lädt wieder**.
|
|
37
|
+
|
|
38
|
+
### Behoben
|
|
39
|
+
|
|
40
|
+
- Drei Ursachen nacheinander, die das Laden verhinderten:
|
|
41
|
+
- JSX in `node_modules` wird nicht transformiert, deshalb fehlte ein vorgebautes `dist/` (`Cannot find package 'react'`).
|
|
42
|
+
- `dist/tui.js` importierte `./laufzeit.ts`, die nicht im Paket lag (`Cannot find module './laufzeit.ts'`).
|
|
43
|
+
- 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`.
|
|
44
|
+
|
|
45
|
+
### Hinzugefügt
|
|
46
|
+
|
|
47
|
+
- `scripts/build.mjs` baut `src/tui.tsx` und `src/measurement.ts` nach `dist/`.
|
|
48
|
+
|
|
49
|
+
## [0.1.0] - 2026-10-26
|
|
50
|
+
|
|
51
|
+
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.
|
|
52
|
+
|
|
53
|
+
### Hinzugefügt
|
|
54
|
+
|
|
55
|
+
- Block `Laufzeiten` in der Seitenleiste, neueste Aufgabe oben, höchstens zehn Zeilen, mit Dauer, Titel und Status nur bei Abweichung.
|
|
56
|
+
- Laufender Eintrag mit sekündlichem Takt und Hinweis `wartet auf dich`.
|
|
57
|
+
- Wartezeit auf Freigaben und Rückfragen zählt zur Laufzeit und wird beim Abschluss als `· 20s warten` ausgewiesen.
|
|
58
|
+
- Summenzeile über alle abgeschlossenen Aufgaben der Sitzung.
|
|
59
|
+
- Zustände `Fehler`, `abgebrochen`, `verdrängt`, `beendet`, `inaktiv` und `Zeitsprung`.
|
|
60
|
+
- Messung je Agentenlauf statt je Prompt: `steer` und Fortsetzung nach einer Kompaktierung verlängern, Subagenten zählen nicht separat.
|
|
61
|
+
- Durable Ablage mit Aufbewahrung über 30 Tage und 200 Sitzungen.
|
|
62
|
+
- Liegengebliebene Läufe werden beim Start als `beendet` abgeschlossen.
|
|
63
|
+
- Befehle `/laufzeiten-reset` und `/laufzeiten-reset-alle`.
|
|
64
|
+
- Ausblenden bei sehr schmaler Seitenleiste.
|
|
65
|
+
- 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,95 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-task-timer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenCode-2-TUI-Plugin: Laufzeiten je Agentenaufgabe in der Seitenleiste.
|
|
4
|
+
|
|
5
|
+
Misst, wie lange der Agent für jede Aufgabe braucht, und zeigt die Dauern als Block in der TUI-Seitenleiste.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Durations
|
|
9
|
+
35s Zweite Aufgabe mit Fehler
|
|
10
|
+
1m 22s Seitenleisten-Probe · 20s waiting
|
|
11
|
+
2 tasks, 1m 57s, Ø 59s
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- Die Uhr startet, wenn der Agent mit einer Aufgabe beginnt.
|
|
15
|
+
- Die Uhr stoppt, wenn er fertig ist und du wieder gefragt wirst.
|
|
16
|
+
- Die Zeilen stehen in der Seitenleiste, neueste oben.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Über npm:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
opencode plugin add opencode-task-timer
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Oder direkt vom Repository:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
opencode plugin add github:emelpe78/opencode-task-timer#v0.1.9
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
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.
|
|
33
|
+
|
|
34
|
+
## Was gemessen wird
|
|
35
|
+
|
|
36
|
+
Der vollständige Messvertrag steht in [README-Messung](docs/measurement.md). Die Kurzfassung:
|
|
37
|
+
|
|
38
|
+
- Gemessen wird ein Agentenlauf, nicht ein Prompt.
|
|
39
|
+
- Ein `steer`-Prompt und die Fortsetzung nach einer Kompaktierung verlängern die laufende Aufgabe. Ein `queue`-Prompt gehört zur nächsten.
|
|
40
|
+
- Subagenten werden nicht separat gezählt, ihre Zeit steckt in der Hauptlaufzeit.
|
|
41
|
+
- Warten auf deine Freigabe zählt zur Laufzeit und wird beim Abschluss zusätzlich ausgewiesen.
|
|
42
|
+
- Titel ist die erste Zeile deines Prompts, auf 40 Zeichen gekürzt.
|
|
43
|
+
|
|
44
|
+
## Befehle
|
|
45
|
+
|
|
46
|
+
| Befehl | Wirkung |
|
|
47
|
+
| ---------------- | ------------------------------------------- |
|
|
48
|
+
| `/ott-reset` | Löscht die Laufzeiten der aktuellen Sitzung |
|
|
49
|
+
| `/ott-reset-all` | Löscht alle gespeicherten Laufzeiten |
|
|
50
|
+
|
|
51
|
+
Beide stehen auch in der Befehlspalette und fragen vor dem Löschen nach.
|
|
52
|
+
|
|
53
|
+
## Anzeige
|
|
54
|
+
|
|
55
|
+
- Höchstens zehn Zeilen, neueste oben.
|
|
56
|
+
- Dauer als `42s`, `1m 23s`, ab einer Stunde `1h 02m`.
|
|
57
|
+
- Status nur bei Abweichung: `error`, `aborted`, `superseded`, `ended`, `inactive`, `clock skew`.
|
|
58
|
+
- Der laufende Eintrag tickt sekündlich und zeigt `waiting for you`, wenn er auf dich wartet.
|
|
59
|
+
- Summenzeile über alle abgeschlossenen Aufgaben der Sitzung, englisch.
|
|
60
|
+
- Bei sehr schmaler Seitenleiste bleibt der Block aus, statt unleserlich zu werden.
|
|
61
|
+
- Rein lesend, kein Tastenkürzel.
|
|
62
|
+
|
|
63
|
+
## Speicherung
|
|
64
|
+
|
|
65
|
+
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.
|
|
66
|
+
|
|
67
|
+
## Entwicklung
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
npm install
|
|
71
|
+
npm run bauen # schreibt dist/tui.js und dist/measurement.js
|
|
72
|
+
npm test
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
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).
|
|
76
|
+
|
|
77
|
+
`dist/` ist eingecheckt, damit die Installation direkt aus dem Repository funktioniert.
|
|
78
|
+
|
|
79
|
+
## Fallstricke beim Veröffentlichen
|
|
80
|
+
|
|
81
|
+
- 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`.
|
|
82
|
+
- `dist/` muss die Messlogik mitliefern. Ein Import auf `./measurement.ts` schlägt fehl, weil `src/` nicht im Paket liegt.
|
|
83
|
+
- `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`.
|
|
84
|
+
- Die Imports auf `@opentui/solid` und `solid-js` werden auf die Laufzeitmodule des Hosts umgeschrieben (`opentui:runtime-module:…`), wie im offiziellen Plugin `oc-tps`.
|
|
85
|
+
- `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.
|
|
86
|
+
- 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.
|
|
87
|
+
|
|
88
|
+
## Voraussetzungen
|
|
89
|
+
|
|
90
|
+
- OpenCode 2.0 oder neuer (`@opencode/plugin` 2.x).
|
|
91
|
+
- Node 22.6 oder neuer, nur für die Tests.
|
|
92
|
+
|
|
93
|
+
## Lizenz
|
|
94
|
+
|
|
95
|
+
[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,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-task-timer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.9",
|
|
4
|
+
"description": "OpenCode 2 TUI plugin: per-task agent durations in the sidebar",
|
|
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
|
+
"opencode-plugins",
|
|
36
|
+
"tui",
|
|
37
|
+
"timer",
|
|
38
|
+
"duration",
|
|
39
|
+
"agent",
|
|
40
|
+
"sidebar",
|
|
41
|
+
"solid-js",
|
|
42
|
+
"typescript",
|
|
43
|
+
"cli",
|
|
44
|
+
"productivity"
|
|
45
|
+
],
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@babel/core": "^7.29.7",
|
|
48
|
+
"@babel/preset-typescript": "^7.29.7",
|
|
49
|
+
"babel-preset-solid": "^1.9.16",
|
|
50
|
+
"solid-js": "^1.9.17"
|
|
51
|
+
}
|
|
52
|
+
}
|