pi-thread-engine 0.1.0

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.
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Interactive TUI dashboard for pi-threads.
3
+ * Shows threads + stories in a navigable overlay.
4
+ *
5
+ * Keys: ↑↓ navigate, Enter expand, k kill, r review, p prune, q/Esc close
6
+ */
7
+ import { Container, Text, Spacer, matchesKey, Key, truncateToWidth } from "@mariozechner/pi-tui";
8
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
9
+ import type { ThreadRegistry } from "./core/registry.js";
10
+ import type { Thread, Story, ThreadSummary } from "./core/types.js";
11
+
12
+ export interface DashboardTheme {
13
+ fg: (color: string, text: string) => string;
14
+ bold: (text: string) => string;
15
+ }
16
+
17
+ interface DashboardRow {
18
+ id: string;
19
+ kind: "thread" | "story";
20
+ label: string;
21
+ display: string;
22
+ }
23
+
24
+ export function createDashboard(
25
+ registry: ThreadRegistry,
26
+ theme: DashboardTheme,
27
+ onClose: () => void,
28
+ onKill?: (id: string) => void,
29
+ onReview?: (id: string) => void
30
+ ) {
31
+ let selected = 0;
32
+ let expanded: string | null = null;
33
+ let rows: DashboardRow[] = [];
34
+ let cachedWidth: number | undefined;
35
+ let cachedLines: string[] | undefined;
36
+
37
+ function stateIcon(state: string): string {
38
+ switch (state) {
39
+ case "running": return "⟳";
40
+ case "completed": return "✓";
41
+ case "failed": case "killed": return "✗";
42
+ case "pending": return "·";
43
+ case "planning": return "📋";
44
+ case "executing": return "⚡";
45
+ case "verifying": return "🔍";
46
+ case "done": return "✅";
47
+ default: return "?";
48
+ }
49
+ }
50
+
51
+ function stateColor(state: string): string {
52
+ switch (state) {
53
+ case "running": case "executing": return "warning";
54
+ case "completed": case "done": return "success";
55
+ case "failed": case "killed": return "error";
56
+ default: return "muted";
57
+ }
58
+ }
59
+
60
+ function typeIcon(type: string): string {
61
+ switch (type) {
62
+ case "parallel": return "⫘";
63
+ case "chained": return "⟶";
64
+ case "fusion": return "⊕";
65
+ case "meta": return "◎";
66
+ case "long": return "∞";
67
+ case "zero": return "⊘";
68
+ default: return "·";
69
+ }
70
+ }
71
+
72
+ function buildRows(): DashboardRow[] {
73
+ const result: DashboardRow[] = [];
74
+
75
+ // Stories first
76
+ for (const s of registry.allStories()) {
77
+ const phases = s.phases.map((p) => `${stateIcon(p.state)}${p.name}`).join("→");
78
+ const display = `📖 ${theme.fg("accent", s.id)} [${theme.fg(stateColor(s.state), s.state)}] ${s.goal.slice(0, 45)} ${theme.fg("dim", phases)}`;
79
+ result.push({ id: s.id, kind: "story", label: s.goal, display });
80
+ }
81
+
82
+ // Then threads
83
+ for (const t of registry.all()) {
84
+ const sum = registry.summarize(t);
85
+ const be = sum.backend === "subagent" ? theme.fg("dim", " [sub]") : "";
86
+ const display = `${typeIcon(sum.type)} ${theme.fg("accent", sum.id)} ${sum.type} [${theme.fg(stateColor(sum.state), sum.state)}] ${sum.progress} (${sum.elapsed})${be} ${sum.label.slice(0, 40)}`;
87
+ result.push({ id: sum.id, kind: "thread", label: sum.label, display });
88
+ }
89
+
90
+ return result;
91
+ }
92
+
93
+ function renderExpanded(id: string, width: number): string[] {
94
+ const lines: string[] = [];
95
+ const indent = " ";
96
+ const maxW = width - 6;
97
+
98
+ // Thread detail
99
+ const t = registry.get(id);
100
+ if (t) {
101
+ lines.push(theme.fg("accent", theme.bold(` Thread ${t.id} (${t.type}) — ${t.state}`)));
102
+ lines.push("");
103
+ for (const task of t.tasks) {
104
+ const icon = stateIcon(task.state);
105
+ const color = stateColor(task.state);
106
+ lines.push(theme.fg(color, `${indent}${icon} ${task.id}: ${truncateToWidth(task.label, maxW)}`));
107
+ if (task.model) lines.push(theme.fg("dim", `${indent} model: ${task.model}`));
108
+ if (task.result) {
109
+ const preview = task.result.replace(/\n/g, " ").slice(0, 200);
110
+ lines.push(theme.fg("muted", `${indent} → ${truncateToWidth(preview, maxW)}`));
111
+ }
112
+ if (task.error) {
113
+ lines.push(theme.fg("error", `${indent} ✗ ${truncateToWidth(task.error, maxW)}`));
114
+ }
115
+ }
116
+ return lines;
117
+ }
118
+
119
+ // Story detail
120
+ const s = registry.getStory(id);
121
+ if (s) {
122
+ lines.push(theme.fg("accent", theme.bold(` Story ${s.id} — ${s.state}`)));
123
+ lines.push(theme.fg("muted", ` ${s.goal}`));
124
+ lines.push("");
125
+ for (const phase of s.phases) {
126
+ const icon = stateIcon(phase.state);
127
+ const color = stateColor(phase.state);
128
+ const tid = phase.threadId ? theme.fg("dim", ` [${phase.threadId}]`) : "";
129
+ lines.push(theme.fg(color, `${indent}${icon} ${phase.name} (${phase.threadType})${tid}`));
130
+ lines.push(theme.fg("dim", `${indent} ${truncateToWidth(phase.description, maxW)}`));
131
+ }
132
+ return lines;
133
+ }
134
+
135
+ return [theme.fg("error", ` ${id} not found`)];
136
+ }
137
+
138
+ const component = {
139
+ handleInput(data: string) {
140
+ if (matchesKey(data, Key.escape) || data === "q") {
141
+ onClose();
142
+ return;
143
+ }
144
+ if (matchesKey(data, Key.up) && selected > 0) {
145
+ selected--;
146
+ expanded = null;
147
+ cachedWidth = undefined;
148
+ }
149
+ if (matchesKey(data, Key.down) && selected < rows.length - 1) {
150
+ selected++;
151
+ expanded = null;
152
+ cachedWidth = undefined;
153
+ }
154
+ if (matchesKey(data, Key.enter) && rows[selected]) {
155
+ expanded = expanded === rows[selected].id ? null : rows[selected].id;
156
+ cachedWidth = undefined;
157
+ }
158
+ if (data === "k" && rows[selected]) {
159
+ onKill?.(rows[selected].id);
160
+ cachedWidth = undefined;
161
+ }
162
+ if (data === "r" && rows[selected]) {
163
+ onReview?.(rows[selected].id);
164
+ cachedWidth = undefined;
165
+ }
166
+ if (data === "p") {
167
+ registry.prune();
168
+ cachedWidth = undefined;
169
+ }
170
+ },
171
+
172
+ render(width: number): string[] {
173
+ // Rebuild rows every render (state changes)
174
+ rows = buildRows();
175
+
176
+ if (selected >= rows.length) selected = Math.max(0, rows.length - 1);
177
+
178
+ const lines: string[] = [];
179
+ const border = "─".repeat(Math.min(width - 4, 80));
180
+
181
+ // Header
182
+ lines.push("");
183
+ lines.push(theme.fg("accent", theme.bold(" 🧵 Thread Dashboard")));
184
+ lines.push(theme.fg("dim", ` ${border}`));
185
+
186
+ if (rows.length === 0) {
187
+ lines.push("");
188
+ lines.push(theme.fg("muted", " No threads or stories."));
189
+ lines.push(theme.fg("dim", " Use /pthread /fthread /zthread /story to start."));
190
+ } else {
191
+ lines.push("");
192
+ for (let i = 0; i < rows.length; i++) {
193
+ const prefix = i === selected ? theme.fg("accent", " ▸ ") : " ";
194
+ const row = rows[i];
195
+ const line = prefix + truncateToWidth(row.display, width - 4);
196
+ lines.push(line);
197
+
198
+ // Show expanded detail
199
+ if (expanded === row.id) {
200
+ lines.push(...renderExpanded(row.id, width));
201
+ lines.push("");
202
+ }
203
+ }
204
+ }
205
+
206
+ // Footer
207
+ lines.push("");
208
+ lines.push(theme.fg("dim", ` ${border}`));
209
+ const help = "↑↓ navigate Enter expand k kill r review p prune q close";
210
+ lines.push(theme.fg("dim", ` ${help}`));
211
+ lines.push("");
212
+
213
+ return lines;
214
+ },
215
+
216
+ invalidate() {
217
+ cachedWidth = undefined;
218
+ cachedLines = undefined;
219
+ },
220
+ };
221
+
222
+ return component;
223
+ }