pi-thread-engine 0.4.2 → 0.4.4

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