pi-thread-engine 0.4.3 → 0.4.5
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/PLAN.md +28 -23
- package/extensions/index.ts +28 -2
- package/package.json +1 -1
- package/src/dashboard.ts +421 -393
package/PLAN.md
CHANGED
|
@@ -1,28 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-thread-engine
|
|
2
2
|
|
|
3
|
-
##
|
|
4
|
-
The pi-thread-engine extension was not properly loading in pi. Commands like `/threads`, `/pthread`, etc. were not recognized. After investigation and fixes, the extension now loads correctly in TUI mode.
|
|
3
|
+
## Current State (v0.4.4)
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
3
|
|
10
|
-
|
|
5
|
+
### Working
|
|
6
|
+
- All 7 thread types: Base (via /pthread), P (parallel), C (chained), B (branch/meta), F (fusion), Z (zero-touch), L (long)
|
|
7
|
+
- Stories: multi-phase orchestration with planned phase execution
|
|
8
|
+
- Dashboard v3: 3-column grouping (Needs Input | Working | Done), progress bars, output snippets, search, inline reply, `/agents` alias
|
|
9
|
+
- `/threads export [id|--all]` — export to Markdown
|
|
10
|
+
- `E` key in dashboard — quick export
|
|
11
|
+
- Keyboard shortcut: `ctrl+shift+t` opens dashboard
|
|
12
|
+
- Session persistence: survives compaction and `/fork`
|
|
13
|
+
- 3 LLM tools: `thread_spawn`, `thread_status`, `thread_kill`
|
|
14
|
+
- IndyDevDan framework branding: README + THREADS.md
|
|
11
15
|
|
|
12
|
-
##
|
|
13
|
-
- `extensions/index.ts` — replaced `StringEnum` import with local function
|
|
14
|
-
- `package.json` — updated peerDeps, cleaned manifest
|
|
15
|
-
- `src/core/executor.ts` — namespace update (reverted to `@mariozechner`)
|
|
16
|
-
- `src/core/registry.ts` — namespace update (reverted to `@mariozechner`)
|
|
16
|
+
## Next Steps (ranked by impact/effort)
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
1.
|
|
20
|
-
2.
|
|
21
|
-
3. Type `/threads status` — should show current thread status via notification
|
|
18
|
+
### P0 — Required for completeness
|
|
19
|
+
1. **Live progress with % + ETA** — Wire event emitter from executor to dashboard for real-time progress
|
|
20
|
+
2. **Token/cost counters** — Hook into pi's usage tracking per thread (use `pi.exec` output parsing)
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
- Add pi.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
### P1 — High value, low risk
|
|
23
|
+
3. **pi.dev gallery listing** — Already has `pi-package` keyword. Add `pi.video` or `pi.image` to package.json for preview. Ensure repository URL is correct.
|
|
24
|
+
4. **@IndyDevDan outreach** — Draft DM/message saying pi-thread-engine implements his framework
|
|
25
|
+
|
|
26
|
+
### P2 — Nice to have
|
|
27
|
+
5. **Pinning** — `P` key to pin threads to top
|
|
28
|
+
6. **Git worktree support** — `--worktree` flag on spawn for isolated branches
|
|
29
|
+
7. **pi-memory integration** — Auto-recall from `~/.pi-memory/` before spawning
|
|
30
|
+
|
|
31
|
+
### P3 — Long term
|
|
32
|
+
8. **ACP support** — Agent Client Protocol interoperability
|
|
33
|
+
9. **Session sharing** — Export/import threads via URL
|
package/extensions/index.ts
CHANGED
|
@@ -18,6 +18,30 @@ import { ThreadRegistry, formatElapsed } from "../src/core/registry.js";
|
|
|
18
18
|
import { ThreadExecutor } from "../src/core/executor.js";
|
|
19
19
|
import { createDashboard } from "../src/dashboard.js";
|
|
20
20
|
import type { Thread, ThreadType, Story, StoryPhase } from "../src/core/types.js";
|
|
21
|
+
import { writeFileSync } from "fs";
|
|
22
|
+
import { join } from "path";
|
|
23
|
+
|
|
24
|
+
// ── Export helper ───────────────────────────────────────────
|
|
25
|
+
function exportThread(id: string, r: ThreadRegistry, cwd: string): string | null {
|
|
26
|
+
const t = r.get(id);
|
|
27
|
+
const s = r.getStory(id);
|
|
28
|
+
if (!t && !s) return null;
|
|
29
|
+
const md = ["# pi-thread-engine Export"];
|
|
30
|
+
const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
|
|
31
|
+
const fn = "thread-" + id + "-" + ts + ".md";
|
|
32
|
+
const outPath = join(cwd, fn);
|
|
33
|
+
if (s) { md.push("## Story: " + s.id); md.push("Goal: " + s.goal); }
|
|
34
|
+
if (t) {
|
|
35
|
+
const sum = r.summarize(t);
|
|
36
|
+
md.push("## " + sum.id + ": " + sum.type + " - " + sum.label);
|
|
37
|
+
for (const tk of t.tasks) {
|
|
38
|
+
const snip = tk.result ? tk.result.slice(0, 200).replace(/\n/g, " ") : tk.error ? "ERROR: " + tk.error.slice(0, 200) : "(no result)";
|
|
39
|
+
md.push("- " + tk.id + " [" + tk.state + "]: " + snip);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
writeFileSync(outPath, md.join("\n"), "utf8");
|
|
43
|
+
return outPath;
|
|
44
|
+
}
|
|
21
45
|
|
|
22
46
|
export default function (pi: ExtensionAPI) {
|
|
23
47
|
const registry = new ThreadRegistry();
|
|
@@ -235,7 +259,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
235
259
|
() => done(),
|
|
236
260
|
(id) => { registry.kill(id); ctx.ui.notify(`Killed ${id}`, "warning"); tui.requestRender(); },
|
|
237
261
|
(id) => { const t = registry.get(id); if (t) { ctx.ui.notify(`Thread ${id}: ${t.tasks.map((tk) => `${tk.id}: ${tk.result?.slice(0,100) ?? tk.error ?? "(pending)"}`).join("\n")}`, "info"); } },
|
|
238
|
-
(id, message) => { executor.injectReply(id, message); ctx.ui.notify(`Replied to ${id}: ${message.slice(0, 50)}...`, "info"); tui.requestRender(); }
|
|
262
|
+
(id, message) => { executor.injectReply(id, message); ctx.ui.notify(`Replied to ${id}: ${message.slice(0, 50)}...`, "info"); tui.requestRender(); },
|
|
263
|
+
(id) => { const p = exportThread(id, registry, ctx.cwd); if (p) ctx.ui.notify(`Exported to ${p}`, "info"); }
|
|
239
264
|
);
|
|
240
265
|
return { render: (w: number) => dashboard.render(w), invalidate: () => dashboard.invalidate(), handleInput: (data: string) => { dashboard.handleInput(data); tui.requestRender(); } };
|
|
241
266
|
});
|
|
@@ -265,7 +290,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
265
290
|
executor.injectReply(id, message);
|
|
266
291
|
ctx.ui.notify(`Replied to ${id}: ${message.slice(0, 50)}...`, "info");
|
|
267
292
|
tui.requestRender();
|
|
268
|
-
}
|
|
293
|
+
},
|
|
294
|
+
(id) => { const p = exportThread(id, registry, ctx.cwd); if (p) ctx.ui.notify(`Exported to ${p}`, "info"); }
|
|
269
295
|
);
|
|
270
296
|
|
|
271
297
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-thread-engine",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "Thread-Based Engineering for pi — all 7 thread types + stories + fusion + zero-touch + TUI dashboard. Based on @IndyDevDan framework from agenticengineer.com.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/dashboard.ts
CHANGED
|
@@ -1,394 +1,422 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Thread Dashboard v2 — Agent View-style grouping + inline reply + search
|
|
3
|
-
* Groups: Needs Input | Working | Done
|
|
4
|
-
* Keys: ↑↓ navigate, Enter expand, i reply, / search, k kill, p prune, q close
|
|
5
|
-
*/
|
|
6
|
-
import { matchesKey, Key, truncateToWidth } from "@mariozechner/pi-tui";
|
|
7
|
-
import type { ThreadRegistry } from "./core/registry.js";
|
|
8
|
-
|
|
9
|
-
export interface DashboardTheme {
|
|
10
|
-
fg: (color: string, text: string) => string;
|
|
11
|
-
bold: (text: string) => string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
interface Row {
|
|
15
|
-
id: string;
|
|
16
|
-
kind: "thread" | "story";
|
|
17
|
-
label: string;
|
|
18
|
-
state: string;
|
|
19
|
-
progress: string;
|
|
20
|
-
elapsed: string;
|
|
21
|
-
result: string;
|
|
22
|
-
error: string;
|
|
23
|
-
type: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface Group {
|
|
27
|
-
name: string;
|
|
28
|
-
icon: string;
|
|
29
|
-
color: string;
|
|
30
|
-
rows: Row[];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function createDashboard(
|
|
34
|
-
registry: ThreadRegistry,
|
|
35
|
-
theme: DashboardTheme,
|
|
36
|
-
onClose: () => void,
|
|
37
|
-
onKill?: (id: string) => void,
|
|
38
|
-
onReview?: (id: string) => void,
|
|
39
|
-
onReply?: (id: string, message: string) => void
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
let
|
|
43
|
-
let
|
|
44
|
-
let
|
|
45
|
-
let
|
|
46
|
-
let
|
|
47
|
-
let
|
|
48
|
-
let
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
case "
|
|
58
|
-
case "
|
|
59
|
-
case "
|
|
60
|
-
case "
|
|
61
|
-
case "
|
|
62
|
-
case "
|
|
63
|
-
case "
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
case "
|
|
73
|
-
case "
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
case "
|
|
83
|
-
case "
|
|
84
|
-
case "
|
|
85
|
-
case "
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (matchesKey(data, Key.
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Thread Dashboard v2 — Agent View-style grouping + inline reply + search
|
|
3
|
+
* Groups: Needs Input | Working | Done
|
|
4
|
+
* Keys: ↑↓ navigate, Enter expand, i reply, / search, k kill, p prune, q close
|
|
5
|
+
*/
|
|
6
|
+
import { matchesKey, Key, truncateToWidth } from "@mariozechner/pi-tui";
|
|
7
|
+
import type { ThreadRegistry } from "./core/registry.js";
|
|
8
|
+
|
|
9
|
+
export interface DashboardTheme {
|
|
10
|
+
fg: (color: string, text: string) => string;
|
|
11
|
+
bold: (text: string) => string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Row {
|
|
15
|
+
id: string;
|
|
16
|
+
kind: "thread" | "story";
|
|
17
|
+
label: string;
|
|
18
|
+
state: string;
|
|
19
|
+
progress: string;
|
|
20
|
+
elapsed: string;
|
|
21
|
+
result: string;
|
|
22
|
+
error: string;
|
|
23
|
+
type: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Group {
|
|
27
|
+
name: string;
|
|
28
|
+
icon: string;
|
|
29
|
+
color: string;
|
|
30
|
+
rows: Row[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createDashboard(
|
|
34
|
+
registry: ThreadRegistry,
|
|
35
|
+
theme: DashboardTheme,
|
|
36
|
+
onClose: () => void,
|
|
37
|
+
onKill?: (id: string) => void,
|
|
38
|
+
onReview?: (id: string) => void,
|
|
39
|
+
onReply?: (id: string, message: string) => void,
|
|
40
|
+
onExport?: (id: string) => void
|
|
41
|
+
) {
|
|
42
|
+
let selected = 0;
|
|
43
|
+
let expanded: string | null = null;
|
|
44
|
+
let searchQuery = "";
|
|
45
|
+
let showSearch = false;
|
|
46
|
+
let replyTarget: string | null = null;
|
|
47
|
+
let replyBuffer = "";
|
|
48
|
+
let groups: Group[] = [];
|
|
49
|
+
let pinned = new Set<string>();
|
|
50
|
+
let cachedWidth: number | undefined;
|
|
51
|
+
|
|
52
|
+
// Safety: cap at 100 rows to prevent terminal overflow
|
|
53
|
+
const MAX_ROWS = 100;
|
|
54
|
+
|
|
55
|
+
function stateIcon(state: string): string {
|
|
56
|
+
switch (state) {
|
|
57
|
+
case "running": return "⟳";
|
|
58
|
+
case "completed": return "✓";
|
|
59
|
+
case "failed": case "killed": return "✗";
|
|
60
|
+
case "pending": return "·";
|
|
61
|
+
case "planning": return "📋";
|
|
62
|
+
case "executing": return "⚡";
|
|
63
|
+
case "verifying": return "🔍";
|
|
64
|
+
case "done": return "✅";
|
|
65
|
+
case "needs_input": return "⚠";
|
|
66
|
+
default: return "?";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function stateColor(state: string): string {
|
|
71
|
+
switch (state) {
|
|
72
|
+
case "running": case "executing": return "warning";
|
|
73
|
+
case "completed": case "done": return "success";
|
|
74
|
+
case "failed": case "killed": return "error";
|
|
75
|
+
case "needs_input": return "warning";
|
|
76
|
+
default: return "muted";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function typeIcon(type: string): string {
|
|
81
|
+
switch (type) {
|
|
82
|
+
case "parallel": return "⫘";
|
|
83
|
+
case "chained": return "⟶";
|
|
84
|
+
case "fusion": return "⊕";
|
|
85
|
+
case "meta": return "◎";
|
|
86
|
+
case "long": return "∞";
|
|
87
|
+
case "zero": return "⊘";
|
|
88
|
+
default: return "·";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildGroups(): Group[] {
|
|
93
|
+
const allThreads = registry.all();
|
|
94
|
+
const allStories = registry.allStories();
|
|
95
|
+
|
|
96
|
+
const needsInput: Row[] = [];
|
|
97
|
+
const working: Row[] = [];
|
|
98
|
+
const done: Row[] = [];
|
|
99
|
+
|
|
100
|
+
for (const t of allThreads) {
|
|
101
|
+
const sum = registry.summarize(t);
|
|
102
|
+
const row: Row = {
|
|
103
|
+
id: sum.id,
|
|
104
|
+
kind: "thread",
|
|
105
|
+
label: sum.label,
|
|
106
|
+
state: sum.state,
|
|
107
|
+
progress: sum.progress,
|
|
108
|
+
elapsed: sum.elapsed,
|
|
109
|
+
result: t.tasks.find(x => x.result)?.result?.slice(0, 80) ?? "",
|
|
110
|
+
error: t.tasks.find(x => x.error)?.error?.slice(0, 80) ?? "",
|
|
111
|
+
type: t.type ?? "",
|
|
112
|
+
};
|
|
113
|
+
if (sum.state === "needs_input") needsInput.push(row);
|
|
114
|
+
else if (["running", "pending", "executing", "verifying", "planning"].includes(sum.state as string)) working.push(row);
|
|
115
|
+
else done.push(row);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const s of allStories) {
|
|
119
|
+
const row: Row = {
|
|
120
|
+
id: s.id,
|
|
121
|
+
kind: "story",
|
|
122
|
+
label: s.goal,
|
|
123
|
+
state: s.state,
|
|
124
|
+
progress: "",
|
|
125
|
+
elapsed: "",
|
|
126
|
+
result: "",
|
|
127
|
+
error: "",
|
|
128
|
+
type: "story",
|
|
129
|
+
};
|
|
130
|
+
if ((s.state as string) === "done" || (s.state as string) === "failed" || (s.state as string) === "completed") done.push(row);
|
|
131
|
+
else working.push(row);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Separate pinned rows
|
|
135
|
+
const allPinned: Row[] = [];
|
|
136
|
+
const needsInputNormal: Row[] = [];
|
|
137
|
+
const workingNormal: Row[] = [];
|
|
138
|
+
const doneNormal: Row[] = [];
|
|
139
|
+
for (const r of needsInput) { if (pinned.has(r.id)) allPinned.push(r); else needsInputNormal.push(r); }
|
|
140
|
+
for (const r of working) { if (pinned.has(r.id)) allPinned.push(r); else workingNormal.push(r); }
|
|
141
|
+
for (const r of done) { if (pinned.has(r.id)) allPinned.push(r); else doneNormal.push(r); }
|
|
142
|
+
|
|
143
|
+
const result: Group[] = [];
|
|
144
|
+
if (allPinned.length > 0) result.push({ name: "Pinned", icon: "📌", color: "accent", rows: allPinned });
|
|
145
|
+
if (needsInputNormal.length > 0) result.push({ name: "Needs Input", icon: "⚠", color: "warning", rows: needsInputNormal });
|
|
146
|
+
if (workingNormal.length > 0) result.push({ name: "Working", icon: "⟳", color: "warning", rows: workingNormal });
|
|
147
|
+
if (doneNormal.length > 0) result.push({ name: "Done", icon: "✓", color: "success", rows: doneNormal });
|
|
148
|
+
|
|
149
|
+
if (searchQuery) {
|
|
150
|
+
const q = searchQuery.toLowerCase();
|
|
151
|
+
for (const g of result) {
|
|
152
|
+
g.rows = g.rows.filter(r => r.label.toLowerCase().includes(q) || r.id.toLowerCase().includes(q));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const g of result) {
|
|
157
|
+
if (g.rows.length > MAX_ROWS) g.rows.length = MAX_ROWS;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return result.filter(g => g.rows.length > 0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function totalRows(): number {
|
|
164
|
+
let n = 0;
|
|
165
|
+
for (const g of groups) n += g.rows.length;
|
|
166
|
+
return n;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getSelected(): { group: number; row: number } | null {
|
|
170
|
+
let idx = 0;
|
|
171
|
+
for (let gi = 0; gi < groups.length; gi++) {
|
|
172
|
+
for (let ri = 0; ri < groups[gi].rows.length; ri++) {
|
|
173
|
+
if (idx === selected) return { group: gi, row: ri };
|
|
174
|
+
idx++;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function ensureGroups() {
|
|
181
|
+
if (groups.length === 0) groups = buildGroups();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function renderExpanded(id: string, width: number): string[] {
|
|
185
|
+
const lines: string[] = [];
|
|
186
|
+
const indent = " ";
|
|
187
|
+
const maxW = width - 6;
|
|
188
|
+
|
|
189
|
+
const t = registry.get(id);
|
|
190
|
+
if (t) {
|
|
191
|
+
lines.push(theme.fg("accent", theme.bold(` Thread ${t.id} (${t.type}) — ${t.state}`)));
|
|
192
|
+
lines.push("");
|
|
193
|
+
for (const task of t.tasks) {
|
|
194
|
+
const icon = stateIcon(task.state);
|
|
195
|
+
const color = stateColor(task.state);
|
|
196
|
+
lines.push(theme.fg(color, `${indent}${icon} ${task.id}: ${truncateToWidth(task.label, maxW)}`));
|
|
197
|
+
if (task.model) lines.push(theme.fg("dim", `${indent} model: ${task.model}`));
|
|
198
|
+
if (task.result) {
|
|
199
|
+
const preview = task.result.replace(/\n/g, " ").slice(0, 200);
|
|
200
|
+
lines.push(theme.fg("muted", `${indent} → ${truncateToWidth(preview, maxW)}`));
|
|
201
|
+
}
|
|
202
|
+
if (task.error) {
|
|
203
|
+
lines.push(theme.fg("error", `${indent} ✗ ${truncateToWidth(task.error, maxW)}`));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return lines;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const s = registry.getStory(id);
|
|
210
|
+
if (s) {
|
|
211
|
+
lines.push(theme.fg("accent", theme.bold(` Story ${s.id} — ${s.state}`)));
|
|
212
|
+
lines.push(theme.fg("muted", ` ${s.goal}`));
|
|
213
|
+
lines.push("");
|
|
214
|
+
for (const phase of s.phases) {
|
|
215
|
+
const icon = stateIcon(phase.state);
|
|
216
|
+
const color = stateColor(phase.state);
|
|
217
|
+
const tid = phase.threadId ? theme.fg("dim", ` [${phase.threadId}]`) : "";
|
|
218
|
+
lines.push(theme.fg(color, `${indent}${icon} ${phase.name} (${phase.threadType})${tid}`));
|
|
219
|
+
lines.push(theme.fg("dim", `${indent} ${truncateToWidth(phase.description, maxW)}`));
|
|
220
|
+
}
|
|
221
|
+
return lines;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return [theme.fg("error", ` ${id} not found`)];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function tw(s: string, w: number): string {
|
|
228
|
+
return truncateToWidth(s, Math.max(1, w));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const component = {
|
|
232
|
+
handleInput(data: string) {
|
|
233
|
+
if (replyTarget !== null) {
|
|
234
|
+
if (matchesKey(data, Key.enter)) {
|
|
235
|
+
onReply?.(replyTarget, replyBuffer);
|
|
236
|
+
replyTarget = null;
|
|
237
|
+
replyBuffer = "";
|
|
238
|
+
cachedWidth = undefined;
|
|
239
|
+
} else if (matchesKey(data, Key.escape)) {
|
|
240
|
+
replyTarget = null;
|
|
241
|
+
replyBuffer = "";
|
|
242
|
+
cachedWidth = undefined;
|
|
243
|
+
} else if (data.length === 1 && !data.startsWith("\x1b") && data !== "[" && data !== "o") {
|
|
244
|
+
replyBuffer += data;
|
|
245
|
+
cachedWidth = undefined;
|
|
246
|
+
} else if (data === "Backspace" || data === "\x7f") {
|
|
247
|
+
replyBuffer = replyBuffer.slice(0, -1);
|
|
248
|
+
cachedWidth = undefined;
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
254
|
+
onClose();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (data === "/") {
|
|
258
|
+
showSearch = !showSearch;
|
|
259
|
+
if (!showSearch) { searchQuery = ""; }
|
|
260
|
+
cachedWidth = undefined;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (showSearch && data.length === 1) {
|
|
264
|
+
searchQuery += data;
|
|
265
|
+
cachedWidth = undefined;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (showSearch && (data === "Backspace" || data === "\x7f")) {
|
|
269
|
+
searchQuery = searchQuery.slice(0, -1);
|
|
270
|
+
cachedWidth = undefined;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const total = totalRows();
|
|
275
|
+
if (matchesKey(data, Key.up) && selected > 0) { selected--; ensureGroups(); }
|
|
276
|
+
if (matchesKey(data, Key.down) && selected < total - 1) { selected++; ensureGroups(); }
|
|
277
|
+
|
|
278
|
+
if (data === "i") {
|
|
279
|
+
const sel = getSelected();
|
|
280
|
+
if (sel) {
|
|
281
|
+
replyTarget = groups[sel.group].rows[sel.row].id;
|
|
282
|
+
replyBuffer = "";
|
|
283
|
+
cachedWidth = undefined;
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (matchesKey(data, Key.enter)) {
|
|
288
|
+
const sel = getSelected();
|
|
289
|
+
if (sel) {
|
|
290
|
+
const id = groups[sel.group].rows[sel.row].id;
|
|
291
|
+
expanded = expanded === id ? null : id;
|
|
292
|
+
cachedWidth = undefined;
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (data === "e") {
|
|
297
|
+
const sel = getSelected();
|
|
298
|
+
if (sel) { onExport?.(groups[sel.group].rows[sel.row].id); cachedWidth = undefined; }
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (data === "k") {
|
|
303
|
+
const sel = getSelected();
|
|
304
|
+
if (sel) { onKill?.(groups[sel.group].rows[sel.row].id); cachedWidth = undefined; }
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (data === "r") {
|
|
308
|
+
const sel = getSelected();
|
|
309
|
+
if (sel) { onReview?.(groups[sel.group].rows[sel.row].id); cachedWidth = undefined; }
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (data === "p") { registry.prune(); ensureGroups(); cachedWidth = undefined; }
|
|
313
|
+
if (data === "P") {
|
|
314
|
+
const sel = getSelected();
|
|
315
|
+
if (sel) {
|
|
316
|
+
const id = groups[sel.group].rows[sel.row].id;
|
|
317
|
+
if (pinned.has(id)) pinned.delete(id); else pinned.add(id);
|
|
318
|
+
cachedWidth = undefined;
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
render(width: number): string[] {
|
|
325
|
+
groups = buildGroups();
|
|
326
|
+
const total = totalRows();
|
|
327
|
+
if (selected >= total && total > 0) selected = total - 1;
|
|
328
|
+
|
|
329
|
+
const lines: string[] = [];
|
|
330
|
+
const border = "─".repeat(Math.min(width - 4, 80));
|
|
331
|
+
|
|
332
|
+
lines.push("");
|
|
333
|
+
lines.push(theme.fg("accent", theme.bold(" 🧵 Thread Dashboard")));
|
|
334
|
+
lines.push(theme.fg("dim", ` ${tw(border, width)}`));
|
|
335
|
+
|
|
336
|
+
if (groups.length === 0 && total === 0) {
|
|
337
|
+
lines.push("");
|
|
338
|
+
lines.push(theme.fg("muted", " No threads or stories."));
|
|
339
|
+
lines.push(theme.fg("dim", " Use /pthread /fthread /zthread /story to start."));
|
|
340
|
+
} else {
|
|
341
|
+
let flatIdx = 0;
|
|
342
|
+
for (const g of groups) {
|
|
343
|
+
// Group header
|
|
344
|
+
const gColor = g.color === "success" ? "success" : "accent";
|
|
345
|
+
lines.push("");
|
|
346
|
+
lines.push(theme.fg(gColor, ` ${g.icon} ${g.name} (${g.rows.length})`));
|
|
347
|
+
|
|
348
|
+
for (const row of g.rows) {
|
|
349
|
+
const isSelected = flatIdx === selected;
|
|
350
|
+
const prefix = isSelected ? theme.fg("accent", " ▸ ") : " ";
|
|
351
|
+
|
|
352
|
+
let display: string;
|
|
353
|
+
if (row.kind === "story") {
|
|
354
|
+
const s = registry.getStory(row.id);
|
|
355
|
+
if (s) {
|
|
356
|
+
const phases = s.phases.map(p => `${stateIcon(p.state)}${p.name}`).join("→");
|
|
357
|
+
display = `📖 ${theme.fg("accent", row.id)} [${theme.fg(stateColor(row.state), row.state)}] ${tw(row.label, 22)} ${theme.fg("dim", tw(phases, 16))}`;
|
|
358
|
+
} else {
|
|
359
|
+
display = `📖 ${theme.fg("accent", row.id)} ${tw(row.label, 50)}`;
|
|
360
|
+
}
|
|
361
|
+
} else {
|
|
362
|
+
// Thread with progress bar + last output snippet
|
|
363
|
+
let progressBar = "░░░░░░░░░░";
|
|
364
|
+
if (row.result) {
|
|
365
|
+
progressBar = "██████████"; // done
|
|
366
|
+
} else if (row.state === "running" || row.state === "executing") {
|
|
367
|
+
progressBar = "████░░░░░░"; // in progress
|
|
368
|
+
} else if (row.state === "failed") {
|
|
369
|
+
progressBar = "✗✗✗✗✗✗✗✗✗✗"; // failed
|
|
370
|
+
}
|
|
371
|
+
const pinMark = pinned.has(row.id) ? "📌" : " ";
|
|
372
|
+
const snippet = row.result
|
|
373
|
+
? `→${row.result.slice(0, 40)}`
|
|
374
|
+
: row.error
|
|
375
|
+
? `✗${row.error.slice(0, 40)}`
|
|
376
|
+
: row.elapsed
|
|
377
|
+
? `⏱${row.elapsed}`
|
|
378
|
+
: "";
|
|
379
|
+
display = `${pinMark}${typeIcon(row.type)} ${theme.fg("accent", row.id)} ${progressBar} [${theme.fg(stateColor(row.state), row.state)}] ${tw(row.label, 20)} ${theme.fg("muted", tw(snippet, 22))}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
lines.push(tw(prefix + display, width));
|
|
383
|
+
|
|
384
|
+
if (isSelected && expanded === row.id) {
|
|
385
|
+
lines.push(...renderExpanded(row.id, width));
|
|
386
|
+
lines.push("");
|
|
387
|
+
}
|
|
388
|
+
flatIdx++;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Reply mode banner
|
|
394
|
+
if (replyTarget !== null) {
|
|
395
|
+
lines.push("");
|
|
396
|
+
lines.push(tw(theme.fg("warning", theme.bold(" ┌─ REPLY ─────────────────────────────┐")), width));
|
|
397
|
+
lines.push(tw(theme.fg("warning", ` │ ${tw(replyTarget || "", 28).padEnd(28)} │`), width));
|
|
398
|
+
lines.push(tw(theme.fg("warning", ` │ ${tw(replyBuffer || "(type message)", 28).padEnd(28)} │`), width));
|
|
399
|
+
lines.push(tw(theme.fg("warning", ` │ Enter=send Esc=cancel │`), width));
|
|
400
|
+
lines.push(tw(theme.fg("warning", theme.bold(" └──────────────────────────────────────┘")), width));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Footer
|
|
404
|
+
lines.push("");
|
|
405
|
+
lines.push(theme.fg("dim", ` ${tw(border, width)}`));
|
|
406
|
+
const help = showSearch
|
|
407
|
+
? tw(`Search: ${searchQuery}_ Enter done Esc cancel`, width - 4)
|
|
408
|
+
: tw("nav=↑↓ exp=Enter rep=i srch=/ kill=k rev=r export=e pin=P prune=p quit=q", width - 4);
|
|
409
|
+
lines.push(theme.fg("dim", ` ${help}`));
|
|
410
|
+
lines.push("");
|
|
411
|
+
|
|
412
|
+
// Final safety: truncate ALL lines
|
|
413
|
+
return lines.map(l => tw(l, width));
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
invalidate() {
|
|
417
|
+
cachedWidth = undefined;
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
return component;
|
|
394
422
|
}
|