pi-thread-engine 0.4.6 → 0.4.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/src/dashboard.ts CHANGED
@@ -1,426 +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)}`));
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)}`));
197
202
  if (task.model) lines.push(theme.fg("dim", `${indent} model: ${task.model}`));
198
203
  if (task.usage) {
199
204
  const costStr = task.usage.cost > 0 ? ` ${task.usage.cost.toFixed(4)}` : "";
200
205
  lines.push(theme.fg("dim", `${indent} tokens: ${task.usage.totalTokens}${costStr}`));
201
- }
202
- if (task.result) {
203
- const preview = task.result.replace(/\n/g, " ").slice(0, 200);
204
- lines.push(theme.fg("muted", `${indent} → ${truncateToWidth(preview, maxW)}`));
205
- }
206
- if (task.error) {
207
- lines.push(theme.fg("error", `${indent} ✗ ${truncateToWidth(task.error, maxW)}`));
208
- }
209
- }
210
- return lines;
211
- }
212
-
213
- const s = registry.getStory(id);
214
- if (s) {
215
- lines.push(theme.fg("accent", theme.bold(` Story ${s.id} — ${s.state}`)));
216
- lines.push(theme.fg("muted", ` ${s.goal}`));
217
- lines.push("");
218
- for (const phase of s.phases) {
219
- const icon = stateIcon(phase.state);
220
- const color = stateColor(phase.state);
221
- const tid = phase.threadId ? theme.fg("dim", ` [${phase.threadId}]`) : "";
222
- lines.push(theme.fg(color, `${indent}${icon} ${phase.name} (${phase.threadType})${tid}`));
223
- lines.push(theme.fg("dim", `${indent} ${truncateToWidth(phase.description, maxW)}`));
224
- }
225
- return lines;
226
- }
227
-
228
- return [theme.fg("error", ` ${id} not found`)];
229
- }
230
-
231
- function tw(s: string, w: number): string {
232
- return truncateToWidth(s, Math.max(1, w));
233
- }
234
-
235
- const component = {
236
- handleInput(data: string) {
237
- if (replyTarget !== null) {
238
- if (matchesKey(data, Key.enter)) {
239
- onReply?.(replyTarget, replyBuffer);
240
- replyTarget = null;
241
- replyBuffer = "";
242
- cachedWidth = undefined;
243
- } else if (matchesKey(data, Key.escape)) {
244
- replyTarget = null;
245
- replyBuffer = "";
246
- cachedWidth = undefined;
247
- } else if (data.length === 1 && !data.startsWith("\x1b") && data !== "[" && data !== "o") {
248
- replyBuffer += data;
249
- cachedWidth = undefined;
250
- } else if (data === "Backspace" || data === "\x7f") {
251
- replyBuffer = replyBuffer.slice(0, -1);
252
- cachedWidth = undefined;
253
- }
254
- return;
255
- }
256
-
257
- if (matchesKey(data, Key.escape) || data === "q") {
258
- onClose();
259
- return;
260
- }
261
- if (data === "/") {
262
- showSearch = !showSearch;
263
- if (!showSearch) { searchQuery = ""; }
264
- cachedWidth = undefined;
265
- return;
266
- }
267
- if (showSearch && data.length === 1) {
268
- searchQuery += data;
269
- cachedWidth = undefined;
270
- return;
271
- }
272
- if (showSearch && (data === "Backspace" || data === "\x7f")) {
273
- searchQuery = searchQuery.slice(0, -1);
274
- cachedWidth = undefined;
275
- return;
276
- }
277
-
278
- const total = totalRows();
279
- if (matchesKey(data, Key.up) && selected > 0) { selected--; ensureGroups(); }
280
- if (matchesKey(data, Key.down) && selected < total - 1) { selected++; ensureGroups(); }
281
-
282
- if (data === "i") {
283
- const sel = getSelected();
284
- if (sel) {
285
- replyTarget = groups[sel.group].rows[sel.row].id;
286
- replyBuffer = "";
287
- cachedWidth = undefined;
288
- }
289
- return;
290
- }
291
- if (matchesKey(data, Key.enter)) {
292
- const sel = getSelected();
293
- if (sel) {
294
- const id = groups[sel.group].rows[sel.row].id;
295
- expanded = expanded === id ? null : id;
296
- cachedWidth = undefined;
297
- }
298
- return;
299
- }
300
- if (data === "e") {
301
- const sel = getSelected();
302
- if (sel) { onExport?.(groups[sel.group].rows[sel.row].id); cachedWidth = undefined; }
303
- return;
304
- }
305
-
306
- if (data === "k") {
307
- const sel = getSelected();
308
- if (sel) { onKill?.(groups[sel.group].rows[sel.row].id); cachedWidth = undefined; }
309
- return;
310
- }
311
- if (data === "r") {
312
- const sel = getSelected();
313
- if (sel) { onReview?.(groups[sel.group].rows[sel.row].id); cachedWidth = undefined; }
314
- return;
315
- }
316
- if (data === "p") { registry.prune(); ensureGroups(); cachedWidth = undefined; }
317
- if (data === "P") {
318
- const sel = getSelected();
319
- if (sel) {
320
- const id = groups[sel.group].rows[sel.row].id;
321
- if (pinned.has(id)) pinned.delete(id); else pinned.add(id);
322
- cachedWidth = undefined;
323
- }
324
- return;
325
- }
326
- },
327
-
328
- render(width: number): string[] {
329
- groups = buildGroups();
330
- const total = totalRows();
331
- if (selected >= total && total > 0) selected = total - 1;
332
-
333
- const lines: string[] = [];
334
- const border = "─".repeat(Math.min(width - 4, 80));
335
-
336
- lines.push("");
337
- lines.push(theme.fg("accent", theme.bold(" 🧵 Thread Dashboard")));
338
- lines.push(theme.fg("dim", ` ${tw(border, width)}`));
339
-
340
- if (groups.length === 0 && total === 0) {
341
- lines.push("");
342
- lines.push(theme.fg("muted", " No threads or stories."));
343
- lines.push(theme.fg("dim", " Use /pthread /fthread /zthread /story to start."));
344
- } else {
345
- let flatIdx = 0;
346
- for (const g of groups) {
347
- // Group header
348
- const gColor = g.color === "success" ? "success" : "accent";
349
- lines.push("");
350
- lines.push(theme.fg(gColor, ` ${g.icon} ${g.name} (${g.rows.length})`));
351
-
352
- for (const row of g.rows) {
353
- const isSelected = flatIdx === selected;
354
- const prefix = isSelected ? theme.fg("accent", " ▸ ") : " ";
355
-
356
- let display: string;
357
- if (row.kind === "story") {
358
- const s = registry.getStory(row.id);
359
- if (s) {
360
- const phases = s.phases.map(p => `${stateIcon(p.state)}${p.name}`).join("→");
361
- display = `📖 ${theme.fg("accent", row.id)} [${theme.fg(stateColor(row.state), row.state)}] ${tw(row.label, 22)} ${theme.fg("dim", tw(phases, 16))}`;
362
- } else {
363
- display = `📖 ${theme.fg("accent", row.id)} ${tw(row.label, 50)}`;
364
- }
365
- } else {
366
- // Thread with progress bar + last output snippet
367
- let progressBar = "░░░░░░░░░░";
368
- if (row.result) {
369
- progressBar = "██████████"; // done
370
- } else if (row.state === "running" || row.state === "executing") {
371
- progressBar = "████░░░░░░"; // in progress
372
- } else if (row.state === "failed") {
373
- progressBar = "✗✗✗✗✗✗✗✗✗✗"; // failed
374
- }
375
- const pinMark = pinned.has(row.id) ? "📌" : " ";
376
- const snippet = row.result
377
- ? `→${row.result.slice(0, 40)}`
378
- : row.error
379
- ? `✗${row.error.slice(0, 40)}`
380
- : row.elapsed
381
- ? `⏱${row.elapsed}`
382
- : "";
383
- 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))}`;
384
- }
385
-
386
- lines.push(tw(prefix + display, width));
387
-
388
- if (isSelected && expanded === row.id) {
389
- lines.push(...renderExpanded(row.id, width));
390
- lines.push("");
391
- }
392
- flatIdx++;
393
- }
394
- }
395
- }
396
-
397
- // Reply mode banner
398
- if (replyTarget !== null) {
399
- lines.push("");
400
- lines.push(tw(theme.fg("warning", theme.bold(" ┌─ REPLY ─────────────────────────────┐")), width));
401
- lines.push(tw(theme.fg("warning", ` │ ${tw(replyTarget || "", 28).padEnd(28)} │`), width));
402
- lines.push(tw(theme.fg("warning", ` │ ${tw(replyBuffer || "(type message)", 28).padEnd(28)} │`), width));
403
- lines.push(tw(theme.fg("warning", ` │ Enter=send Esc=cancel │`), width));
404
- lines.push(tw(theme.fg("warning", theme.bold(" └──────────────────────────────────────┘")), width));
405
- }
406
-
407
- // Footer
408
- lines.push("");
409
- lines.push(theme.fg("dim", ` ${tw(border, width)}`));
410
- const help = showSearch
411
- ? tw(`Search: ${searchQuery}_ Enter done Esc cancel`, width - 4)
412
- : tw("nav=↑↓ exp=Enter rep=i srch=/ kill=k rev=r export=e pin=P prune=p quit=q", width - 4);
413
- lines.push(theme.fg("dim", ` ${help}`));
414
- lines.push("");
415
-
416
- // Final safety: truncate ALL lines
417
- return lines.map(l => tw(l, width));
418
- },
419
-
420
- invalidate() {
421
- cachedWidth = undefined;
422
- },
423
- };
424
-
425
- return component;
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;
426
431
  }