pi-thread-engine 0.4.5 → 0.4.7

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