pi-thread-engine 0.4.2 → 0.4.3
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 -43
- package/extensions/index.ts +699 -666
- package/package.json +1 -1
- package/src/dashboard.ts +393 -393
package/src/dashboard.ts
CHANGED
|
@@ -1,394 +1,394 @@
|
|
|
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
|
-
let selected = 0;
|
|
42
|
-
let expanded: string | null = null;
|
|
43
|
-
let searchQuery = "";
|
|
44
|
-
let showSearch = false;
|
|
45
|
-
let replyTarget: string | null = null;
|
|
46
|
-
let replyBuffer = "";
|
|
47
|
-
let groups: Group[] = [];
|
|
48
|
-
let cachedWidth: number | undefined;
|
|
49
|
-
|
|
50
|
-
// Safety: cap at 100 rows to prevent terminal overflow
|
|
51
|
-
const MAX_ROWS = 100;
|
|
52
|
-
|
|
53
|
-
function stateIcon(state: string): string {
|
|
54
|
-
switch (state) {
|
|
55
|
-
case "running": return "⟳";
|
|
56
|
-
case "completed": return "✓";
|
|
57
|
-
case "failed": case "killed": return "✗";
|
|
58
|
-
case "pending": return "·";
|
|
59
|
-
case "planning": return "📋";
|
|
60
|
-
case "executing": return "⚡";
|
|
61
|
-
case "verifying": return "🔍";
|
|
62
|
-
case "done": return "✅";
|
|
63
|
-
case "needs_input": return "⚠";
|
|
64
|
-
default: return "?";
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function stateColor(state: string): string {
|
|
69
|
-
switch (state) {
|
|
70
|
-
case "running": case "executing": return "warning";
|
|
71
|
-
case "completed": case "done": return "success";
|
|
72
|
-
case "failed": case "killed": return "error";
|
|
73
|
-
case "needs_input": return "warning";
|
|
74
|
-
default: return "muted";
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function typeIcon(type: string): string {
|
|
79
|
-
switch (type) {
|
|
80
|
-
case "parallel": return "⫘";
|
|
81
|
-
case "chained": return "⟶";
|
|
82
|
-
case "fusion": return "⊕";
|
|
83
|
-
case "meta": return "◎";
|
|
84
|
-
case "long": return "∞";
|
|
85
|
-
case "zero": return "⊘";
|
|
86
|
-
default: return "·";
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function buildGroups(): Group[] {
|
|
91
|
-
const allThreads = registry.all();
|
|
92
|
-
const allStories = registry.allStories();
|
|
93
|
-
|
|
94
|
-
const needsInput: Row[] = [];
|
|
95
|
-
const working: Row[] = [];
|
|
96
|
-
const done: Row[] = [];
|
|
97
|
-
|
|
98
|
-
for (const t of allThreads) {
|
|
99
|
-
const sum = registry.summarize(t);
|
|
100
|
-
const row: Row = {
|
|
101
|
-
id: sum.id,
|
|
102
|
-
kind: "thread",
|
|
103
|
-
label: sum.label,
|
|
104
|
-
state: sum.state,
|
|
105
|
-
progress: sum.progress,
|
|
106
|
-
elapsed: sum.elapsed,
|
|
107
|
-
result: t.tasks.find(x => x.result)?.result?.slice(0, 80) ?? "",
|
|
108
|
-
error: t.tasks.find(x => x.error)?.error?.slice(0, 80) ?? "",
|
|
109
|
-
type: t.type ?? "",
|
|
110
|
-
};
|
|
111
|
-
if (sum.state === "needs_input") needsInput.push(row);
|
|
112
|
-
else if (["running", "pending", "executing", "verifying", "planning"].includes(sum.state as string)) working.push(row);
|
|
113
|
-
else done.push(row);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
for (const s of allStories) {
|
|
117
|
-
const row: Row = {
|
|
118
|
-
id: s.id,
|
|
119
|
-
kind: "story",
|
|
120
|
-
label: s.goal,
|
|
121
|
-
state: s.state,
|
|
122
|
-
progress: "",
|
|
123
|
-
elapsed: "",
|
|
124
|
-
result: "",
|
|
125
|
-
error: "",
|
|
126
|
-
type: "story",
|
|
127
|
-
};
|
|
128
|
-
if ((s.state as string) === "done" || (s.state as string) === "failed" || (s.state as string) === "completed") done.push(row);
|
|
129
|
-
else working.push(row);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const result: Group[] = [];
|
|
133
|
-
if (needsInput.length > 0) result.push({ name: "Needs Input", icon: "⚠", color: "warning", rows: needsInput });
|
|
134
|
-
if (working.length > 0) result.push({ name: "Working", icon: "⟳", color: "warning", rows: working });
|
|
135
|
-
if (done.length > 0) result.push({ name: "Done", icon: "✓", color: "success", rows: done });
|
|
136
|
-
|
|
137
|
-
if (searchQuery) {
|
|
138
|
-
const q = searchQuery.toLowerCase();
|
|
139
|
-
for (const g of result) {
|
|
140
|
-
g.rows = g.rows.filter(r => r.label.toLowerCase().includes(q) || r.id.toLowerCase().includes(q));
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
for (const g of result) {
|
|
145
|
-
if (g.rows.length > MAX_ROWS) g.rows.length = MAX_ROWS;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return result.filter(g => g.rows.length > 0);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function totalRows(): number {
|
|
152
|
-
let n = 0;
|
|
153
|
-
for (const g of groups) n += g.rows.length;
|
|
154
|
-
return n;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function getSelected(): { group: number; row: number } | null {
|
|
158
|
-
let idx = 0;
|
|
159
|
-
for (let gi = 0; gi < groups.length; gi++) {
|
|
160
|
-
for (let ri = 0; ri < groups[gi].rows.length; ri++) {
|
|
161
|
-
if (idx === selected) return { group: gi, row: ri };
|
|
162
|
-
idx++;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function ensureGroups() {
|
|
169
|
-
if (groups.length === 0) groups = buildGroups();
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function renderExpanded(id: string, width: number): string[] {
|
|
173
|
-
const lines: string[] = [];
|
|
174
|
-
const indent = " ";
|
|
175
|
-
const maxW = width - 6;
|
|
176
|
-
|
|
177
|
-
const t = registry.get(id);
|
|
178
|
-
if (t) {
|
|
179
|
-
lines.push(theme.fg("accent", theme.bold(` Thread ${t.id} (${t.type}) — ${t.state}`)));
|
|
180
|
-
lines.push("");
|
|
181
|
-
for (const task of t.tasks) {
|
|
182
|
-
const icon = stateIcon(task.state);
|
|
183
|
-
const color = stateColor(task.state);
|
|
184
|
-
lines.push(theme.fg(color, `${indent}${icon} ${task.id}: ${truncateToWidth(task.label, maxW)}`));
|
|
185
|
-
if (task.model) lines.push(theme.fg("dim", `${indent} model: ${task.model}`));
|
|
186
|
-
if (task.result) {
|
|
187
|
-
const preview = task.result.replace(/\n/g, " ").slice(0, 200);
|
|
188
|
-
lines.push(theme.fg("muted", `${indent} → ${truncateToWidth(preview, maxW)}`));
|
|
189
|
-
}
|
|
190
|
-
if (task.error) {
|
|
191
|
-
lines.push(theme.fg("error", `${indent} ✗ ${truncateToWidth(task.error, maxW)}`));
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
return lines;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const s = registry.getStory(id);
|
|
198
|
-
if (s) {
|
|
199
|
-
lines.push(theme.fg("accent", theme.bold(` Story ${s.id} — ${s.state}`)));
|
|
200
|
-
lines.push(theme.fg("muted", ` ${s.goal}`));
|
|
201
|
-
lines.push("");
|
|
202
|
-
for (const phase of s.phases) {
|
|
203
|
-
const icon = stateIcon(phase.state);
|
|
204
|
-
const color = stateColor(phase.state);
|
|
205
|
-
const tid = phase.threadId ? theme.fg("dim", ` [${phase.threadId}]`) : "";
|
|
206
|
-
lines.push(theme.fg(color, `${indent}${icon} ${phase.name} (${phase.threadType})${tid}`));
|
|
207
|
-
lines.push(theme.fg("dim", `${indent} ${truncateToWidth(phase.description, maxW)}`));
|
|
208
|
-
}
|
|
209
|
-
return lines;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return [theme.fg("error", ` ${id} not found`)];
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function tw(s: string, w: number): string {
|
|
216
|
-
return truncateToWidth(s, Math.max(1, w));
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const component = {
|
|
220
|
-
handleInput(data: string) {
|
|
221
|
-
if (replyTarget !== null) {
|
|
222
|
-
if (matchesKey(data, Key.enter)) {
|
|
223
|
-
onReply?.(replyTarget, replyBuffer);
|
|
224
|
-
replyTarget = null;
|
|
225
|
-
replyBuffer = "";
|
|
226
|
-
cachedWidth = undefined;
|
|
227
|
-
} else if (matchesKey(data, Key.escape)) {
|
|
228
|
-
replyTarget = null;
|
|
229
|
-
replyBuffer = "";
|
|
230
|
-
cachedWidth = undefined;
|
|
231
|
-
} else if (data.length === 1 && !data.startsWith("\x1b") && data !== "[" && data !== "o") {
|
|
232
|
-
replyBuffer += data;
|
|
233
|
-
cachedWidth = undefined;
|
|
234
|
-
} else if (data === "Backspace" || data === "\x7f") {
|
|
235
|
-
replyBuffer = replyBuffer.slice(0, -1);
|
|
236
|
-
cachedWidth = undefined;
|
|
237
|
-
}
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (matchesKey(data, Key.escape) || data === "q") {
|
|
242
|
-
onClose();
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
if (data === "/") {
|
|
246
|
-
showSearch = !showSearch;
|
|
247
|
-
if (!showSearch) { searchQuery = ""; }
|
|
248
|
-
cachedWidth = undefined;
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
if (showSearch && data.length === 1) {
|
|
252
|
-
searchQuery += data;
|
|
253
|
-
cachedWidth = undefined;
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
if (showSearch && (data === "Backspace" || data === "\x7f")) {
|
|
257
|
-
searchQuery = searchQuery.slice(0, -1);
|
|
258
|
-
cachedWidth = undefined;
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const total = totalRows();
|
|
263
|
-
if (matchesKey(data, Key.up) && selected > 0) { selected--; ensureGroups(); }
|
|
264
|
-
if (matchesKey(data, Key.down) && selected < total - 1) { selected++; ensureGroups(); }
|
|
265
|
-
|
|
266
|
-
if (data === "i") {
|
|
267
|
-
const sel = getSelected();
|
|
268
|
-
if (sel) {
|
|
269
|
-
replyTarget = groups[sel.group].rows[sel.row].id;
|
|
270
|
-
replyBuffer = "";
|
|
271
|
-
cachedWidth = undefined;
|
|
272
|
-
}
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
if (matchesKey(data, Key.enter)) {
|
|
276
|
-
const sel = getSelected();
|
|
277
|
-
if (sel) {
|
|
278
|
-
const id = groups[sel.group].rows[sel.row].id;
|
|
279
|
-
expanded = expanded === id ? null : id;
|
|
280
|
-
cachedWidth = undefined;
|
|
281
|
-
}
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
if (data === "k") {
|
|
285
|
-
const sel = getSelected();
|
|
286
|
-
if (sel) { onKill?.(groups[sel.group].rows[sel.row].id); cachedWidth = undefined; }
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
if (data === "r") {
|
|
290
|
-
const sel = getSelected();
|
|
291
|
-
if (sel) { onReview?.(groups[sel.group].rows[sel.row].id); cachedWidth = undefined; }
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
if (data === "p") { registry.prune(); ensureGroups(); cachedWidth = undefined; }
|
|
295
|
-
},
|
|
296
|
-
|
|
297
|
-
render(width: number): string[] {
|
|
298
|
-
groups = buildGroups();
|
|
299
|
-
const total = totalRows();
|
|
300
|
-
if (selected >= total && total > 0) selected = total - 1;
|
|
301
|
-
|
|
302
|
-
const lines: string[] = [];
|
|
303
|
-
const border = "─".repeat(Math.min(width - 4, 80));
|
|
304
|
-
|
|
305
|
-
lines.push("");
|
|
306
|
-
lines.push(theme.fg("accent", theme.bold(" 🧵 Thread Dashboard")));
|
|
307
|
-
lines.push(theme.fg("dim", ` ${tw(border, width)}`));
|
|
308
|
-
|
|
309
|
-
if (groups.length === 0 && total === 0) {
|
|
310
|
-
lines.push("");
|
|
311
|
-
lines.push(theme.fg("muted", " No threads or stories."));
|
|
312
|
-
lines.push(theme.fg("dim", " Use /pthread /fthread /zthread /story to start."));
|
|
313
|
-
} else {
|
|
314
|
-
let flatIdx = 0;
|
|
315
|
-
for (const g of groups) {
|
|
316
|
-
// Group header
|
|
317
|
-
const gColor = g.color === "success" ? "success" : "accent";
|
|
318
|
-
lines.push("");
|
|
319
|
-
lines.push(theme.fg(gColor, ` ${g.icon} ${g.name} (${g.rows.length})`));
|
|
320
|
-
|
|
321
|
-
for (const row of g.rows) {
|
|
322
|
-
const isSelected = flatIdx === selected;
|
|
323
|
-
const prefix = isSelected ? theme.fg("accent", " ▸ ") : " ";
|
|
324
|
-
|
|
325
|
-
let display: string;
|
|
326
|
-
if (row.kind === "story") {
|
|
327
|
-
const s = registry.getStory(row.id);
|
|
328
|
-
if (s) {
|
|
329
|
-
const phases = s.phases.map(p => `${stateIcon(p.state)}${p.name}`).join("→");
|
|
330
|
-
display = `📖 ${theme.fg("accent", row.id)} [${theme.fg(stateColor(row.state), row.state)}] ${tw(row.label, 22)} ${theme.fg("dim", tw(phases, 16))}`;
|
|
331
|
-
} else {
|
|
332
|
-
display = `📖 ${theme.fg("accent", row.id)} ${tw(row.label, 50)}`;
|
|
333
|
-
}
|
|
334
|
-
} else {
|
|
335
|
-
// Thread with progress bar + last output snippet
|
|
336
|
-
let progressBar = "░░░░░░░░░░";
|
|
337
|
-
if (row.result) {
|
|
338
|
-
progressBar = "██████████"; // done
|
|
339
|
-
} else if (row.state === "running" || row.state === "executing") {
|
|
340
|
-
progressBar = "████░░░░░░"; // in progress
|
|
341
|
-
} else if (row.state === "failed") {
|
|
342
|
-
progressBar = "✗✗✗✗✗✗✗✗✗✗"; // failed
|
|
343
|
-
}
|
|
344
|
-
const snippet = row.result
|
|
345
|
-
? `→${row.result.slice(0, 40)}`
|
|
346
|
-
: row.error
|
|
347
|
-
? `✗${row.error.slice(0, 40)}`
|
|
348
|
-
: row.elapsed
|
|
349
|
-
? `⏱${row.elapsed}`
|
|
350
|
-
: "";
|
|
351
|
-
display = `${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))}`;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
lines.push(tw(prefix + display, width));
|
|
355
|
-
|
|
356
|
-
if (isSelected && expanded === row.id) {
|
|
357
|
-
lines.push(...renderExpanded(row.id, width));
|
|
358
|
-
lines.push("");
|
|
359
|
-
}
|
|
360
|
-
flatIdx++;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// Reply mode banner
|
|
366
|
-
if (replyTarget !== null) {
|
|
367
|
-
lines.push("");
|
|
368
|
-
lines.push(tw(theme.fg("warning", theme.bold(" ┌─ REPLY ─────────────────────────────┐")), width));
|
|
369
|
-
lines.push(tw(theme.fg("warning", ` │ ${tw(replyTarget || "", 28).padEnd(28)} │`), width));
|
|
370
|
-
lines.push(tw(theme.fg("warning", ` │ ${tw(replyBuffer || "(type message)", 28).padEnd(28)} │`), width));
|
|
371
|
-
lines.push(tw(theme.fg("warning", ` │ Enter=send Esc=cancel │`), width));
|
|
372
|
-
lines.push(tw(theme.fg("warning", theme.bold(" └──────────────────────────────────────┘")), width));
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// Footer
|
|
376
|
-
lines.push("");
|
|
377
|
-
lines.push(theme.fg("dim", ` ${tw(border, width)}`));
|
|
378
|
-
const help = showSearch
|
|
379
|
-
? tw(`Search: ${searchQuery}_ Enter done Esc cancel`, width - 4)
|
|
380
|
-
: tw("nav=↑↓ exp=Enter rep=i srch=/ kill=k rev=r prune=p quit=q", width - 4);
|
|
381
|
-
lines.push(theme.fg("dim", ` ${help}`));
|
|
382
|
-
lines.push("");
|
|
383
|
-
|
|
384
|
-
// Final safety: truncate ALL lines
|
|
385
|
-
return lines.map(l => tw(l, width));
|
|
386
|
-
},
|
|
387
|
-
|
|
388
|
-
invalidate() {
|
|
389
|
-
cachedWidth = undefined;
|
|
390
|
-
},
|
|
391
|
-
};
|
|
392
|
-
|
|
393
|
-
return component;
|
|
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
|
+
let selected = 0;
|
|
42
|
+
let expanded: string | null = null;
|
|
43
|
+
let searchQuery = "";
|
|
44
|
+
let showSearch = false;
|
|
45
|
+
let replyTarget: string | null = null;
|
|
46
|
+
let replyBuffer = "";
|
|
47
|
+
let groups: Group[] = [];
|
|
48
|
+
let cachedWidth: number | undefined;
|
|
49
|
+
|
|
50
|
+
// Safety: cap at 100 rows to prevent terminal overflow
|
|
51
|
+
const MAX_ROWS = 100;
|
|
52
|
+
|
|
53
|
+
function stateIcon(state: string): string {
|
|
54
|
+
switch (state) {
|
|
55
|
+
case "running": return "⟳";
|
|
56
|
+
case "completed": return "✓";
|
|
57
|
+
case "failed": case "killed": return "✗";
|
|
58
|
+
case "pending": return "·";
|
|
59
|
+
case "planning": return "📋";
|
|
60
|
+
case "executing": return "⚡";
|
|
61
|
+
case "verifying": return "🔍";
|
|
62
|
+
case "done": return "✅";
|
|
63
|
+
case "needs_input": return "⚠";
|
|
64
|
+
default: return "?";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stateColor(state: string): string {
|
|
69
|
+
switch (state) {
|
|
70
|
+
case "running": case "executing": return "warning";
|
|
71
|
+
case "completed": case "done": return "success";
|
|
72
|
+
case "failed": case "killed": return "error";
|
|
73
|
+
case "needs_input": return "warning";
|
|
74
|
+
default: return "muted";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function typeIcon(type: string): string {
|
|
79
|
+
switch (type) {
|
|
80
|
+
case "parallel": return "⫘";
|
|
81
|
+
case "chained": return "⟶";
|
|
82
|
+
case "fusion": return "⊕";
|
|
83
|
+
case "meta": return "◎";
|
|
84
|
+
case "long": return "∞";
|
|
85
|
+
case "zero": return "⊘";
|
|
86
|
+
default: return "·";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildGroups(): Group[] {
|
|
91
|
+
const allThreads = registry.all();
|
|
92
|
+
const allStories = registry.allStories();
|
|
93
|
+
|
|
94
|
+
const needsInput: Row[] = [];
|
|
95
|
+
const working: Row[] = [];
|
|
96
|
+
const done: Row[] = [];
|
|
97
|
+
|
|
98
|
+
for (const t of allThreads) {
|
|
99
|
+
const sum = registry.summarize(t);
|
|
100
|
+
const row: Row = {
|
|
101
|
+
id: sum.id,
|
|
102
|
+
kind: "thread",
|
|
103
|
+
label: sum.label,
|
|
104
|
+
state: sum.state,
|
|
105
|
+
progress: sum.progress,
|
|
106
|
+
elapsed: sum.elapsed,
|
|
107
|
+
result: t.tasks.find(x => x.result)?.result?.slice(0, 80) ?? "",
|
|
108
|
+
error: t.tasks.find(x => x.error)?.error?.slice(0, 80) ?? "",
|
|
109
|
+
type: t.type ?? "",
|
|
110
|
+
};
|
|
111
|
+
if (sum.state === "needs_input") needsInput.push(row);
|
|
112
|
+
else if (["running", "pending", "executing", "verifying", "planning"].includes(sum.state as string)) working.push(row);
|
|
113
|
+
else done.push(row);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const s of allStories) {
|
|
117
|
+
const row: Row = {
|
|
118
|
+
id: s.id,
|
|
119
|
+
kind: "story",
|
|
120
|
+
label: s.goal,
|
|
121
|
+
state: s.state,
|
|
122
|
+
progress: "",
|
|
123
|
+
elapsed: "",
|
|
124
|
+
result: "",
|
|
125
|
+
error: "",
|
|
126
|
+
type: "story",
|
|
127
|
+
};
|
|
128
|
+
if ((s.state as string) === "done" || (s.state as string) === "failed" || (s.state as string) === "completed") done.push(row);
|
|
129
|
+
else working.push(row);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const result: Group[] = [];
|
|
133
|
+
if (needsInput.length > 0) result.push({ name: "Needs Input", icon: "⚠", color: "warning", rows: needsInput });
|
|
134
|
+
if (working.length > 0) result.push({ name: "Working", icon: "⟳", color: "warning", rows: working });
|
|
135
|
+
if (done.length > 0) result.push({ name: "Done", icon: "✓", color: "success", rows: done });
|
|
136
|
+
|
|
137
|
+
if (searchQuery) {
|
|
138
|
+
const q = searchQuery.toLowerCase();
|
|
139
|
+
for (const g of result) {
|
|
140
|
+
g.rows = g.rows.filter(r => r.label.toLowerCase().includes(q) || r.id.toLowerCase().includes(q));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const g of result) {
|
|
145
|
+
if (g.rows.length > MAX_ROWS) g.rows.length = MAX_ROWS;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return result.filter(g => g.rows.length > 0);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function totalRows(): number {
|
|
152
|
+
let n = 0;
|
|
153
|
+
for (const g of groups) n += g.rows.length;
|
|
154
|
+
return n;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function getSelected(): { group: number; row: number } | null {
|
|
158
|
+
let idx = 0;
|
|
159
|
+
for (let gi = 0; gi < groups.length; gi++) {
|
|
160
|
+
for (let ri = 0; ri < groups[gi].rows.length; ri++) {
|
|
161
|
+
if (idx === selected) return { group: gi, row: ri };
|
|
162
|
+
idx++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function ensureGroups() {
|
|
169
|
+
if (groups.length === 0) groups = buildGroups();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderExpanded(id: string, width: number): string[] {
|
|
173
|
+
const lines: string[] = [];
|
|
174
|
+
const indent = " ";
|
|
175
|
+
const maxW = width - 6;
|
|
176
|
+
|
|
177
|
+
const t = registry.get(id);
|
|
178
|
+
if (t) {
|
|
179
|
+
lines.push(theme.fg("accent", theme.bold(` Thread ${t.id} (${t.type}) — ${t.state}`)));
|
|
180
|
+
lines.push("");
|
|
181
|
+
for (const task of t.tasks) {
|
|
182
|
+
const icon = stateIcon(task.state);
|
|
183
|
+
const color = stateColor(task.state);
|
|
184
|
+
lines.push(theme.fg(color, `${indent}${icon} ${task.id}: ${truncateToWidth(task.label, maxW)}`));
|
|
185
|
+
if (task.model) lines.push(theme.fg("dim", `${indent} model: ${task.model}`));
|
|
186
|
+
if (task.result) {
|
|
187
|
+
const preview = task.result.replace(/\n/g, " ").slice(0, 200);
|
|
188
|
+
lines.push(theme.fg("muted", `${indent} → ${truncateToWidth(preview, maxW)}`));
|
|
189
|
+
}
|
|
190
|
+
if (task.error) {
|
|
191
|
+
lines.push(theme.fg("error", `${indent} ✗ ${truncateToWidth(task.error, maxW)}`));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return lines;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const s = registry.getStory(id);
|
|
198
|
+
if (s) {
|
|
199
|
+
lines.push(theme.fg("accent", theme.bold(` Story ${s.id} — ${s.state}`)));
|
|
200
|
+
lines.push(theme.fg("muted", ` ${s.goal}`));
|
|
201
|
+
lines.push("");
|
|
202
|
+
for (const phase of s.phases) {
|
|
203
|
+
const icon = stateIcon(phase.state);
|
|
204
|
+
const color = stateColor(phase.state);
|
|
205
|
+
const tid = phase.threadId ? theme.fg("dim", ` [${phase.threadId}]`) : "";
|
|
206
|
+
lines.push(theme.fg(color, `${indent}${icon} ${phase.name} (${phase.threadType})${tid}`));
|
|
207
|
+
lines.push(theme.fg("dim", `${indent} ${truncateToWidth(phase.description, maxW)}`));
|
|
208
|
+
}
|
|
209
|
+
return lines;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return [theme.fg("error", ` ${id} not found`)];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function tw(s: string, w: number): string {
|
|
216
|
+
return truncateToWidth(s, Math.max(1, w));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const component = {
|
|
220
|
+
handleInput(data: string) {
|
|
221
|
+
if (replyTarget !== null) {
|
|
222
|
+
if (matchesKey(data, Key.enter)) {
|
|
223
|
+
onReply?.(replyTarget, replyBuffer);
|
|
224
|
+
replyTarget = null;
|
|
225
|
+
replyBuffer = "";
|
|
226
|
+
cachedWidth = undefined;
|
|
227
|
+
} else if (matchesKey(data, Key.escape)) {
|
|
228
|
+
replyTarget = null;
|
|
229
|
+
replyBuffer = "";
|
|
230
|
+
cachedWidth = undefined;
|
|
231
|
+
} else if (data.length === 1 && !data.startsWith("\x1b") && data !== "[" && data !== "o") {
|
|
232
|
+
replyBuffer += data;
|
|
233
|
+
cachedWidth = undefined;
|
|
234
|
+
} else if (data === "Backspace" || data === "\x7f") {
|
|
235
|
+
replyBuffer = replyBuffer.slice(0, -1);
|
|
236
|
+
cachedWidth = undefined;
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
242
|
+
onClose();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (data === "/") {
|
|
246
|
+
showSearch = !showSearch;
|
|
247
|
+
if (!showSearch) { searchQuery = ""; }
|
|
248
|
+
cachedWidth = undefined;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (showSearch && data.length === 1) {
|
|
252
|
+
searchQuery += data;
|
|
253
|
+
cachedWidth = undefined;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (showSearch && (data === "Backspace" || data === "\x7f")) {
|
|
257
|
+
searchQuery = searchQuery.slice(0, -1);
|
|
258
|
+
cachedWidth = undefined;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const total = totalRows();
|
|
263
|
+
if (matchesKey(data, Key.up) && selected > 0) { selected--; ensureGroups(); }
|
|
264
|
+
if (matchesKey(data, Key.down) && selected < total - 1) { selected++; ensureGroups(); }
|
|
265
|
+
|
|
266
|
+
if (data === "i") {
|
|
267
|
+
const sel = getSelected();
|
|
268
|
+
if (sel) {
|
|
269
|
+
replyTarget = groups[sel.group].rows[sel.row].id;
|
|
270
|
+
replyBuffer = "";
|
|
271
|
+
cachedWidth = undefined;
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (matchesKey(data, Key.enter)) {
|
|
276
|
+
const sel = getSelected();
|
|
277
|
+
if (sel) {
|
|
278
|
+
const id = groups[sel.group].rows[sel.row].id;
|
|
279
|
+
expanded = expanded === id ? null : id;
|
|
280
|
+
cachedWidth = undefined;
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (data === "k") {
|
|
285
|
+
const sel = getSelected();
|
|
286
|
+
if (sel) { onKill?.(groups[sel.group].rows[sel.row].id); cachedWidth = undefined; }
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (data === "r") {
|
|
290
|
+
const sel = getSelected();
|
|
291
|
+
if (sel) { onReview?.(groups[sel.group].rows[sel.row].id); cachedWidth = undefined; }
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (data === "p") { registry.prune(); ensureGroups(); cachedWidth = undefined; }
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
render(width: number): string[] {
|
|
298
|
+
groups = buildGroups();
|
|
299
|
+
const total = totalRows();
|
|
300
|
+
if (selected >= total && total > 0) selected = total - 1;
|
|
301
|
+
|
|
302
|
+
const lines: string[] = [];
|
|
303
|
+
const border = "─".repeat(Math.min(width - 4, 80));
|
|
304
|
+
|
|
305
|
+
lines.push("");
|
|
306
|
+
lines.push(theme.fg("accent", theme.bold(" 🧵 Thread Dashboard")));
|
|
307
|
+
lines.push(theme.fg("dim", ` ${tw(border, width)}`));
|
|
308
|
+
|
|
309
|
+
if (groups.length === 0 && total === 0) {
|
|
310
|
+
lines.push("");
|
|
311
|
+
lines.push(theme.fg("muted", " No threads or stories."));
|
|
312
|
+
lines.push(theme.fg("dim", " Use /pthread /fthread /zthread /story to start."));
|
|
313
|
+
} else {
|
|
314
|
+
let flatIdx = 0;
|
|
315
|
+
for (const g of groups) {
|
|
316
|
+
// Group header
|
|
317
|
+
const gColor = g.color === "success" ? "success" : "accent";
|
|
318
|
+
lines.push("");
|
|
319
|
+
lines.push(theme.fg(gColor, ` ${g.icon} ${g.name} (${g.rows.length})`));
|
|
320
|
+
|
|
321
|
+
for (const row of g.rows) {
|
|
322
|
+
const isSelected = flatIdx === selected;
|
|
323
|
+
const prefix = isSelected ? theme.fg("accent", " ▸ ") : " ";
|
|
324
|
+
|
|
325
|
+
let display: string;
|
|
326
|
+
if (row.kind === "story") {
|
|
327
|
+
const s = registry.getStory(row.id);
|
|
328
|
+
if (s) {
|
|
329
|
+
const phases = s.phases.map(p => `${stateIcon(p.state)}${p.name}`).join("→");
|
|
330
|
+
display = `📖 ${theme.fg("accent", row.id)} [${theme.fg(stateColor(row.state), row.state)}] ${tw(row.label, 22)} ${theme.fg("dim", tw(phases, 16))}`;
|
|
331
|
+
} else {
|
|
332
|
+
display = `📖 ${theme.fg("accent", row.id)} ${tw(row.label, 50)}`;
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
// Thread with progress bar + last output snippet
|
|
336
|
+
let progressBar = "░░░░░░░░░░";
|
|
337
|
+
if (row.result) {
|
|
338
|
+
progressBar = "██████████"; // done
|
|
339
|
+
} else if (row.state === "running" || row.state === "executing") {
|
|
340
|
+
progressBar = "████░░░░░░"; // in progress
|
|
341
|
+
} else if (row.state === "failed") {
|
|
342
|
+
progressBar = "✗✗✗✗✗✗✗✗✗✗"; // failed
|
|
343
|
+
}
|
|
344
|
+
const snippet = row.result
|
|
345
|
+
? `→${row.result.slice(0, 40)}`
|
|
346
|
+
: row.error
|
|
347
|
+
? `✗${row.error.slice(0, 40)}`
|
|
348
|
+
: row.elapsed
|
|
349
|
+
? `⏱${row.elapsed}`
|
|
350
|
+
: "";
|
|
351
|
+
display = `${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))}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
lines.push(tw(prefix + display, width));
|
|
355
|
+
|
|
356
|
+
if (isSelected && expanded === row.id) {
|
|
357
|
+
lines.push(...renderExpanded(row.id, width));
|
|
358
|
+
lines.push("");
|
|
359
|
+
}
|
|
360
|
+
flatIdx++;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Reply mode banner
|
|
366
|
+
if (replyTarget !== null) {
|
|
367
|
+
lines.push("");
|
|
368
|
+
lines.push(tw(theme.fg("warning", theme.bold(" ┌─ REPLY ─────────────────────────────┐")), width));
|
|
369
|
+
lines.push(tw(theme.fg("warning", ` │ ${tw(replyTarget || "", 28).padEnd(28)} │`), width));
|
|
370
|
+
lines.push(tw(theme.fg("warning", ` │ ${tw(replyBuffer || "(type message)", 28).padEnd(28)} │`), width));
|
|
371
|
+
lines.push(tw(theme.fg("warning", ` │ Enter=send Esc=cancel │`), width));
|
|
372
|
+
lines.push(tw(theme.fg("warning", theme.bold(" └──────────────────────────────────────┘")), width));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Footer
|
|
376
|
+
lines.push("");
|
|
377
|
+
lines.push(theme.fg("dim", ` ${tw(border, width)}`));
|
|
378
|
+
const help = showSearch
|
|
379
|
+
? tw(`Search: ${searchQuery}_ Enter done Esc cancel`, width - 4)
|
|
380
|
+
: tw("nav=↑↓ exp=Enter rep=i srch=/ kill=k rev=r prune=p quit=q", width - 4);
|
|
381
|
+
lines.push(theme.fg("dim", ` ${help}`));
|
|
382
|
+
lines.push("");
|
|
383
|
+
|
|
384
|
+
// Final safety: truncate ALL lines
|
|
385
|
+
return lines.map(l => tw(l, width));
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
invalidate() {
|
|
389
|
+
cachedWidth = undefined;
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
return component;
|
|
394
394
|
}
|