pi-thread-engine 0.4.3 → 0.4.5

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