pi-squad 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.
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/package.json +30 -0
- package/src/agent-pool.ts +445 -0
- package/src/agents/_defaults/architect.json +9 -0
- package/src/agents/_defaults/backend.json +9 -0
- package/src/agents/_defaults/debugger.json +9 -0
- package/src/agents/_defaults/devops.json +9 -0
- package/src/agents/_defaults/docs.json +9 -0
- package/src/agents/_defaults/frontend.json +9 -0
- package/src/agents/_defaults/fullstack.json +9 -0
- package/src/agents/_defaults/planner.json +9 -0
- package/src/agents/_defaults/qa.json +9 -0
- package/src/agents/_defaults/researcher.json +9 -0
- package/src/agents/_defaults/security.json +9 -0
- package/src/index.ts +1121 -0
- package/src/monitor.ts +204 -0
- package/src/panel/message-view.ts +232 -0
- package/src/panel/squad-panel.ts +383 -0
- package/src/panel/task-list.ts +264 -0
- package/src/planner.ts +275 -0
- package/src/protocol.ts +265 -0
- package/src/router.ts +207 -0
- package/src/scheduler.ts +732 -0
- package/src/skills/collaboration/SKILL.md +39 -0
- package/src/skills/squad-protocol/SKILL.md +65 -0
- package/src/skills/verification/SKILL.md +64 -0
- package/src/store.ts +458 -0
- package/src/supervisor.ts +143 -0
- package/src/types.ts +210 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* squad-panel.ts — Main overlay component for the squad TUI panel.
|
|
3
|
+
*
|
|
4
|
+
* Manages view switching between task list, message view, and agent list.
|
|
5
|
+
* Adapts layout: wide screen (>=160 cols) = right panel, narrow = centered overlay.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Component, Focusable, TUI, OverlayHandle, OverlayOptions } from "@mariozechner/pi-tui";
|
|
9
|
+
import { matchesKey, visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
|
|
10
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import type { PanelState, PanelView } from "../types.js";
|
|
12
|
+
import type { Scheduler } from "../scheduler.js";
|
|
13
|
+
import { TaskListView } from "./task-list.js";
|
|
14
|
+
import { MessageView } from "./message-view.js";
|
|
15
|
+
import * as store from "../store.js";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Constants
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
const WIDE_THRESHOLD = 160;
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Squad Panel
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
export class SquadPanel implements Component, Focusable {
|
|
28
|
+
focused = false;
|
|
29
|
+
|
|
30
|
+
private tui: TUI;
|
|
31
|
+
private theme: Theme;
|
|
32
|
+
private scheduler: Scheduler;
|
|
33
|
+
private squadId: string;
|
|
34
|
+
private handle: OverlayHandle | null = null;
|
|
35
|
+
/** Callback to send a human message — set by the extension */
|
|
36
|
+
onSendMessage?: (taskId: string, message: string) => void;
|
|
37
|
+
|
|
38
|
+
private state: PanelState = {
|
|
39
|
+
view: "tasks",
|
|
40
|
+
selectedTaskIndex: 0,
|
|
41
|
+
selectedTaskId: null,
|
|
42
|
+
scrollOffset: 0,
|
|
43
|
+
agentSelectedIndex: 0,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
private taskListView: TaskListView;
|
|
47
|
+
private messageView: MessageView;
|
|
48
|
+
|
|
49
|
+
private refreshInterval: ReturnType<typeof setInterval> | null = null;
|
|
50
|
+
|
|
51
|
+
constructor(tui: TUI, theme: Theme, scheduler: Scheduler, squadId: string) {
|
|
52
|
+
this.tui = tui;
|
|
53
|
+
this.theme = theme;
|
|
54
|
+
this.scheduler = scheduler;
|
|
55
|
+
this.squadId = squadId;
|
|
56
|
+
|
|
57
|
+
this.taskListView = new TaskListView(theme, squadId);
|
|
58
|
+
this.messageView = new MessageView(theme, squadId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// =========================================================================
|
|
62
|
+
// Overlay Lifecycle
|
|
63
|
+
// =========================================================================
|
|
64
|
+
|
|
65
|
+
/** Show the panel as an overlay */
|
|
66
|
+
show(): void {
|
|
67
|
+
if (this.handle) return;
|
|
68
|
+
|
|
69
|
+
this.handle = this.tui.showOverlay(this, this.getOverlayOptions());
|
|
70
|
+
|
|
71
|
+
// Refresh panel periodically
|
|
72
|
+
this.refreshInterval = setInterval(() => {
|
|
73
|
+
this.tui.requestRender();
|
|
74
|
+
}, 2000);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Hide the panel */
|
|
78
|
+
hide(): void {
|
|
79
|
+
if (this.handle) {
|
|
80
|
+
this.handle.hide();
|
|
81
|
+
this.handle = null;
|
|
82
|
+
}
|
|
83
|
+
if (this.refreshInterval) {
|
|
84
|
+
clearInterval(this.refreshInterval);
|
|
85
|
+
this.refreshInterval = null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Toggle panel visibility */
|
|
90
|
+
toggle(): void {
|
|
91
|
+
if (this.handle) {
|
|
92
|
+
if (this.handle.isHidden()) {
|
|
93
|
+
this.handle.setHidden(false);
|
|
94
|
+
} else {
|
|
95
|
+
this.handle.setHidden(true);
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
this.show();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Toggle focus between panel and main editor.
|
|
103
|
+
* Wide screen: panel stays visible, just switch focus.
|
|
104
|
+
* Narrow screen: show/hide since overlay covers content.
|
|
105
|
+
*/
|
|
106
|
+
toggleFocus(): void {
|
|
107
|
+
if (!this.handle) {
|
|
108
|
+
this.show();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const wide = this.tui.columns >= WIDE_THRESHOLD;
|
|
112
|
+
|
|
113
|
+
if (this.handle.isHidden()) {
|
|
114
|
+
this.handle.setHidden(false);
|
|
115
|
+
this.handle.focus();
|
|
116
|
+
} else if (this.handle.isFocused()) {
|
|
117
|
+
if (wide) {
|
|
118
|
+
// Wide: release focus back to editor, panel stays visible
|
|
119
|
+
this.handle.unfocus();
|
|
120
|
+
} else {
|
|
121
|
+
// Narrow: hide overlay entirely
|
|
122
|
+
this.handle.setHidden(true);
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
// Visible but not focused — take focus
|
|
126
|
+
this.handle.focus();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Check if panel is visible */
|
|
131
|
+
isVisible(): boolean {
|
|
132
|
+
return this.handle !== null && !this.handle.isHidden();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
dispose(): void {
|
|
136
|
+
this.hide();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// =========================================================================
|
|
140
|
+
// Overlay Options (adaptive layout)
|
|
141
|
+
// =========================================================================
|
|
142
|
+
|
|
143
|
+
private getOverlayOptions(): OverlayOptions {
|
|
144
|
+
const wide = this.tui.columns >= WIDE_THRESHOLD;
|
|
145
|
+
if (wide) {
|
|
146
|
+
return {
|
|
147
|
+
anchor: "top-right",
|
|
148
|
+
width: "35%",
|
|
149
|
+
maxHeight: "100%",
|
|
150
|
+
margin: { top: 0, right: 0, bottom: 1, left: 0 },
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
anchor: "center",
|
|
155
|
+
width: "90%",
|
|
156
|
+
maxHeight: "85%",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// =========================================================================
|
|
161
|
+
// Input Handling
|
|
162
|
+
// =========================================================================
|
|
163
|
+
|
|
164
|
+
handleInput(data: string): void {
|
|
165
|
+
const wide = this.tui.columns >= WIDE_THRESHOLD;
|
|
166
|
+
|
|
167
|
+
// Ctrl+Q: switch focus back to editor (wide) or hide (narrow)
|
|
168
|
+
if (data === "\x11") {
|
|
169
|
+
if (wide) {
|
|
170
|
+
this.handle?.unfocus();
|
|
171
|
+
} else {
|
|
172
|
+
this.handle?.setHidden(true);
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Escape: back from messages to tasks, or release focus
|
|
178
|
+
if (matchesKey(data, "escape")) {
|
|
179
|
+
if (this.state.view === "messages") {
|
|
180
|
+
this.state.view = "tasks";
|
|
181
|
+
this.tui.requestRender();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (wide) {
|
|
185
|
+
this.handle?.unfocus();
|
|
186
|
+
} else {
|
|
187
|
+
this.handle?.setHidden(true);
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// q: release focus (task list only, not from message view)
|
|
193
|
+
if (matchesKey(data, "q")) {
|
|
194
|
+
if (this.state.view === "tasks") {
|
|
195
|
+
if (wide) {
|
|
196
|
+
this.handle?.unfocus();
|
|
197
|
+
} else {
|
|
198
|
+
this.handle?.setHidden(true);
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Delegate to current view
|
|
205
|
+
switch (this.state.view) {
|
|
206
|
+
case "tasks":
|
|
207
|
+
this.handleTaskListInput(data);
|
|
208
|
+
break;
|
|
209
|
+
case "messages":
|
|
210
|
+
this.handleMessageViewInput(data);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private handleTaskListInput(data: string): void {
|
|
216
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
217
|
+
|
|
218
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
219
|
+
this.state.selectedTaskIndex = Math.max(0, this.state.selectedTaskIndex - 1);
|
|
220
|
+
if (tasks[this.state.selectedTaskIndex]) {
|
|
221
|
+
this.state.selectedTaskId = tasks[this.state.selectedTaskIndex].id;
|
|
222
|
+
}
|
|
223
|
+
this.tui.requestRender();
|
|
224
|
+
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
225
|
+
this.state.selectedTaskIndex = Math.min(tasks.length - 1, this.state.selectedTaskIndex + 1);
|
|
226
|
+
if (tasks[this.state.selectedTaskIndex]) {
|
|
227
|
+
this.state.selectedTaskId = tasks[this.state.selectedTaskIndex].id;
|
|
228
|
+
}
|
|
229
|
+
this.tui.requestRender();
|
|
230
|
+
} else if (matchesKey(data, "return")) {
|
|
231
|
+
// Open message view for selected task
|
|
232
|
+
if (this.state.selectedTaskId) {
|
|
233
|
+
this.state.view = "messages";
|
|
234
|
+
this.state.scrollOffset = 0;
|
|
235
|
+
this.messageView.setTaskId(this.state.selectedTaskId);
|
|
236
|
+
this.tui.requestRender();
|
|
237
|
+
}
|
|
238
|
+
} else if (matchesKey(data, "p")) {
|
|
239
|
+
// Pause/resume selected task
|
|
240
|
+
if (this.state.selectedTaskId) {
|
|
241
|
+
const task = store.loadTask(this.squadId, this.state.selectedTaskId);
|
|
242
|
+
if (task?.status === "in_progress") {
|
|
243
|
+
this.scheduler.pauseTask(this.state.selectedTaskId);
|
|
244
|
+
} else if (task?.status === "suspended") {
|
|
245
|
+
this.scheduler.resumeTask(this.state.selectedTaskId);
|
|
246
|
+
}
|
|
247
|
+
this.tui.requestRender();
|
|
248
|
+
}
|
|
249
|
+
} else if (matchesKey(data, "x")) {
|
|
250
|
+
// Cancel selected task
|
|
251
|
+
if (this.state.selectedTaskId) {
|
|
252
|
+
this.scheduler.cancelTask(this.state.selectedTaskId);
|
|
253
|
+
this.tui.requestRender();
|
|
254
|
+
}
|
|
255
|
+
} else if (matchesKey(data, "m")) {
|
|
256
|
+
if (this.state.selectedTaskId && this.onSendMessage) {
|
|
257
|
+
this.onSendMessage(this.state.selectedTaskId, "");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private handleMessageViewInput(data: string): void {
|
|
263
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
264
|
+
this.messageView.scrollUp();
|
|
265
|
+
this.tui.requestRender();
|
|
266
|
+
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
267
|
+
this.messageView.scrollDown();
|
|
268
|
+
this.tui.requestRender();
|
|
269
|
+
} else if (matchesKey(data, "escape")) {
|
|
270
|
+
this.state.view = "tasks";
|
|
271
|
+
this.tui.requestRender();
|
|
272
|
+
} else if (matchesKey(data, "m")) {
|
|
273
|
+
// Send message to this task's agent
|
|
274
|
+
const taskId = this.messageView.getTaskId();
|
|
275
|
+
if (taskId && this.onSendMessage) {
|
|
276
|
+
this.onSendMessage(taskId, "");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// =========================================================================
|
|
282
|
+
// Rendering
|
|
283
|
+
// =========================================================================
|
|
284
|
+
|
|
285
|
+
invalidate(): void {
|
|
286
|
+
this.taskListView.invalidate();
|
|
287
|
+
this.messageView.invalidate();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
render(width: number): string[] {
|
|
291
|
+
const th = this.theme;
|
|
292
|
+
const lines: string[] = [];
|
|
293
|
+
|
|
294
|
+
// Header
|
|
295
|
+
const squad = store.loadSquad(this.squadId);
|
|
296
|
+
const title = squad ? `squad: ${squad.goal.slice(0, width - 20)}` : "squad";
|
|
297
|
+
lines.push(...this.renderHeader(title, width));
|
|
298
|
+
|
|
299
|
+
// Content area: total height minus header (1 line) and footer (3 lines)
|
|
300
|
+
const contentWidth = width - 2; // Account for border chars
|
|
301
|
+
const totalAvailable = this.tui.rows || 24;
|
|
302
|
+
const availHeight = Math.max(5, totalAvailable - 4); // 1 header + 3 footer
|
|
303
|
+
|
|
304
|
+
switch (this.state.view) {
|
|
305
|
+
case "tasks": {
|
|
306
|
+
const taskLines = this.taskListView.render(
|
|
307
|
+
contentWidth,
|
|
308
|
+
this.state.selectedTaskIndex,
|
|
309
|
+
availHeight,
|
|
310
|
+
this.scheduler,
|
|
311
|
+
);
|
|
312
|
+
for (const line of taskLines) {
|
|
313
|
+
lines.push(this.borderLine(line, width));
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
case "messages": {
|
|
318
|
+
const msgLines = this.messageView.render(contentWidth, availHeight);
|
|
319
|
+
for (const line of msgLines) {
|
|
320
|
+
lines.push(this.borderLine(line, width));
|
|
321
|
+
}
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Footer with keybindings
|
|
327
|
+
lines.push(...this.renderFooter(width));
|
|
328
|
+
|
|
329
|
+
return lines;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private renderHeader(title: string, width: number): string[] {
|
|
333
|
+
const th = this.theme;
|
|
334
|
+
const innerW = width - 2;
|
|
335
|
+
const titleStr = ` ${title} `;
|
|
336
|
+
const titleVW = visibleWidth(titleStr);
|
|
337
|
+
const leftBar = "─";
|
|
338
|
+
const rightBarLen = Math.max(0, innerW - titleVW - 1);
|
|
339
|
+
const rightBar = "─".repeat(rightBarLen);
|
|
340
|
+
|
|
341
|
+
return [
|
|
342
|
+
th.fg("border", "╭" + leftBar) +
|
|
343
|
+
th.fg("accent", titleStr) +
|
|
344
|
+
th.fg("border", rightBar + "╮"),
|
|
345
|
+
];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private renderFooter(width: number): string[] {
|
|
349
|
+
const th = this.theme;
|
|
350
|
+
const innerW = width - 2;
|
|
351
|
+
const wide = this.tui.columns >= WIDE_THRESHOLD;
|
|
352
|
+
const switchHint = wide ? "^q switch" : "^q close";
|
|
353
|
+
|
|
354
|
+
let keys: string;
|
|
355
|
+
switch (this.state.view) {
|
|
356
|
+
case "tasks":
|
|
357
|
+
keys = ` ↑↓ nav ⏎ msgs m send p pause x cancel ${switchHint}`;
|
|
358
|
+
break;
|
|
359
|
+
case "messages":
|
|
360
|
+
keys = ` ↑↓ scroll m send esc back ${switchHint}`;
|
|
361
|
+
break;
|
|
362
|
+
default:
|
|
363
|
+
keys = ` ${switchHint}`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return [
|
|
367
|
+
th.fg("border", "├" + "─".repeat(innerW) + "┤"),
|
|
368
|
+
this.borderLine(th.fg("dim", keys), width),
|
|
369
|
+
th.fg("border", "╰" + "─".repeat(innerW) + "╯"),
|
|
370
|
+
];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Wrap a content line with border chars, padded to exact width */
|
|
374
|
+
private borderLine(content: string, width: number): string {
|
|
375
|
+
const th = this.theme;
|
|
376
|
+
const innerW = width - 2;
|
|
377
|
+
// Truncate content to fit, preserving ANSI codes
|
|
378
|
+
const truncated = truncateToWidth(content, innerW);
|
|
379
|
+
const contentVW = visibleWidth(truncated);
|
|
380
|
+
const pad = " ".repeat(Math.max(0, innerW - contentVW));
|
|
381
|
+
return th.fg("border", "│") + truncated + pad + th.fg("border", "│");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-list.ts — Task tree view with status icons, live activity preview.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import type { Task, TaskStatus } from "../types.js";
|
|
7
|
+
import type { Scheduler } from "../scheduler.js";
|
|
8
|
+
import * as store from "../store.js";
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Status Icons
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
function statusIcon(status: TaskStatus, th: Theme): string {
|
|
15
|
+
switch (status) {
|
|
16
|
+
case "done":
|
|
17
|
+
return th.fg("success", "✓");
|
|
18
|
+
case "in_progress":
|
|
19
|
+
return th.fg("warning", "⏳");
|
|
20
|
+
case "blocked":
|
|
21
|
+
return th.fg("muted", "◻");
|
|
22
|
+
case "failed":
|
|
23
|
+
return th.fg("error", "✗");
|
|
24
|
+
case "suspended":
|
|
25
|
+
return th.fg("muted", "⏸");
|
|
26
|
+
case "pending":
|
|
27
|
+
default:
|
|
28
|
+
return th.fg("dim", "·");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function statusLabel(status: TaskStatus): string {
|
|
33
|
+
switch (status) {
|
|
34
|
+
case "done":
|
|
35
|
+
return "done";
|
|
36
|
+
case "in_progress":
|
|
37
|
+
return "";
|
|
38
|
+
case "blocked":
|
|
39
|
+
return "blocked";
|
|
40
|
+
case "failed":
|
|
41
|
+
return "FAILED";
|
|
42
|
+
case "suspended":
|
|
43
|
+
return "paused";
|
|
44
|
+
case "pending":
|
|
45
|
+
return "pending";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// Task List View
|
|
51
|
+
// ============================================================================
|
|
52
|
+
|
|
53
|
+
export class TaskListView {
|
|
54
|
+
private theme: Theme;
|
|
55
|
+
private squadId: string;
|
|
56
|
+
|
|
57
|
+
constructor(theme: Theme, squadId: string) {
|
|
58
|
+
this.theme = theme;
|
|
59
|
+
this.squadId = squadId;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
invalidate(): void {
|
|
63
|
+
/* stateless rendering */
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
render(
|
|
67
|
+
width: number,
|
|
68
|
+
selectedIndex: number,
|
|
69
|
+
maxLines: number,
|
|
70
|
+
scheduler: Scheduler,
|
|
71
|
+
): string[] {
|
|
72
|
+
const th = this.theme;
|
|
73
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
74
|
+
const lines: string[] = [];
|
|
75
|
+
|
|
76
|
+
if (tasks.length === 0) {
|
|
77
|
+
lines.push("");
|
|
78
|
+
lines.push(th.fg("muted", " No tasks yet"));
|
|
79
|
+
lines.push("");
|
|
80
|
+
return this.padToHeight(lines, maxLines);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Render task list
|
|
84
|
+
const taskLines = this.renderTaskTree(tasks, selectedIndex, width);
|
|
85
|
+
|
|
86
|
+
// Scroll window
|
|
87
|
+
const scrollStart = Math.max(0, Math.min(selectedIndex - Math.floor(maxLines / 2), taskLines.length - maxLines + 4));
|
|
88
|
+
const visibleTasks = taskLines.slice(scrollStart, scrollStart + maxLines - 4);
|
|
89
|
+
lines.push(...visibleTasks);
|
|
90
|
+
|
|
91
|
+
// Separator
|
|
92
|
+
lines.push("");
|
|
93
|
+
lines.push(th.fg("border", " " + "─".repeat(width - 2)));
|
|
94
|
+
|
|
95
|
+
// Live activity for selected or first running task
|
|
96
|
+
const runningTask = tasks.find((t) => t.status === "in_progress");
|
|
97
|
+
if (runningTask) {
|
|
98
|
+
lines.push(...this.renderLiveActivity(runningTask, width, scheduler));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Summary line
|
|
102
|
+
lines.push("");
|
|
103
|
+
lines.push(this.renderSummary(tasks, width));
|
|
104
|
+
|
|
105
|
+
return this.padToHeight(lines, maxLines);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// =========================================================================
|
|
109
|
+
// Task Tree
|
|
110
|
+
// =========================================================================
|
|
111
|
+
|
|
112
|
+
private renderTaskTree(tasks: Task[], selectedIndex: number, width: number): string[] {
|
|
113
|
+
const th = this.theme;
|
|
114
|
+
const lines: string[] = [];
|
|
115
|
+
|
|
116
|
+
// Build parent → children map
|
|
117
|
+
const topLevel: Task[] = [];
|
|
118
|
+
const childMap = new Map<string, Task[]>();
|
|
119
|
+
|
|
120
|
+
for (const task of tasks) {
|
|
121
|
+
// Simple heuristic: tasks whose depends are not in the list are top-level
|
|
122
|
+
// For proper parent/child: check if task folder is nested
|
|
123
|
+
topLevel.push(task);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// TODO: proper parent/subtask tree from folder structure
|
|
127
|
+
// For now, render flat with dependency indicators
|
|
128
|
+
|
|
129
|
+
for (let i = 0; i < topLevel.length; i++) {
|
|
130
|
+
const task = topLevel[i];
|
|
131
|
+
const isSelected = i === selectedIndex;
|
|
132
|
+
const icon = statusIcon(task.status, th);
|
|
133
|
+
const label = statusLabel(task.status);
|
|
134
|
+
|
|
135
|
+
const cursor = isSelected ? th.fg("accent", "▸") : " ";
|
|
136
|
+
const taskName = isSelected
|
|
137
|
+
? th.fg("accent", th.bold(task.id))
|
|
138
|
+
: th.fg("muted", task.id);
|
|
139
|
+
const agentTag = th.fg("dim", `(${task.agent})`);
|
|
140
|
+
const labelStr = label ? th.fg("dim", ` ${label}`) : "";
|
|
141
|
+
|
|
142
|
+
// Format elapsed time for done/in_progress tasks
|
|
143
|
+
let timeStr = "";
|
|
144
|
+
if (task.status === "done" && task.started && task.completed) {
|
|
145
|
+
const elapsed = new Date(task.completed).getTime() - new Date(task.started).getTime();
|
|
146
|
+
timeStr = th.fg("dim", ` ${formatMs(elapsed)}`);
|
|
147
|
+
} else if (task.status === "in_progress" && task.started) {
|
|
148
|
+
const elapsed = Date.now() - new Date(task.started).getTime();
|
|
149
|
+
timeStr = th.fg("warning", ` ${formatMs(elapsed)}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const line = ` ${cursor} ${icon} ${taskName} ${agentTag}${labelStr}${timeStr}`;
|
|
153
|
+
lines.push(line);
|
|
154
|
+
|
|
155
|
+
// Show blocked-by info
|
|
156
|
+
if (task.status === "blocked" && task.depends.length > 0) {
|
|
157
|
+
const blockers = task.depends
|
|
158
|
+
.filter((depId) => {
|
|
159
|
+
const dep = tasks.find((t) => t.id === depId);
|
|
160
|
+
return dep && dep.status !== "done";
|
|
161
|
+
});
|
|
162
|
+
if (blockers.length > 0) {
|
|
163
|
+
lines.push(
|
|
164
|
+
` ${th.fg("dim", "└ waiting on: " + blockers.join(", "))}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Show error for failed tasks
|
|
170
|
+
if (task.status === "failed" && task.error) {
|
|
171
|
+
const errorPreview = task.error.slice(0, width - 10);
|
|
172
|
+
lines.push(` ${th.fg("error", "└ " + errorPreview)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return lines;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// =========================================================================
|
|
180
|
+
// Live Activity
|
|
181
|
+
// =========================================================================
|
|
182
|
+
|
|
183
|
+
private renderLiveActivity(task: Task, width: number, scheduler: Scheduler): string[] {
|
|
184
|
+
const th = this.theme;
|
|
185
|
+
const lines: string[] = [];
|
|
186
|
+
|
|
187
|
+
lines.push(th.fg("border", ` ── ${task.id} (live) `) + th.fg("border", "─".repeat(Math.max(0, width - task.id.length - 12))));
|
|
188
|
+
|
|
189
|
+
// Get recent messages for this task
|
|
190
|
+
const messages = store.loadMessages(this.squadId, task.id);
|
|
191
|
+
const recent = messages.slice(-3);
|
|
192
|
+
|
|
193
|
+
for (const msg of recent) {
|
|
194
|
+
if (msg.type === "tool") {
|
|
195
|
+
const toolStr = `→ ${msg.name || msg.text}`;
|
|
196
|
+
const argsStr = msg.args?.path || msg.args?.command || "";
|
|
197
|
+
const preview = argsStr ? `${toolStr} ${argsStr}` : toolStr;
|
|
198
|
+
lines.push(` ${th.fg("muted", preview.slice(0, width - 2))}`);
|
|
199
|
+
} else if (msg.type === "text" && msg.from !== "system") {
|
|
200
|
+
const preview = msg.text.split("\n")[0].slice(0, width - 4);
|
|
201
|
+
lines.push(` ${th.fg("dim", `"${preview}"`)}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Health indicator
|
|
206
|
+
const activity = scheduler.getPool().getActivity(task.id);
|
|
207
|
+
if (activity) {
|
|
208
|
+
const idleMs = Date.now() - activity.lastOutputTs;
|
|
209
|
+
if (idleMs > 60000) {
|
|
210
|
+
lines.push(` ${th.fg("warning", `⚠ idle ${formatMs(idleMs)}`)}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return lines;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// =========================================================================
|
|
218
|
+
// Summary
|
|
219
|
+
// =========================================================================
|
|
220
|
+
|
|
221
|
+
private renderSummary(tasks: Task[], width: number): string {
|
|
222
|
+
const th = this.theme;
|
|
223
|
+
const done = tasks.filter((t) => t.status === "done").length;
|
|
224
|
+
const total = tasks.length;
|
|
225
|
+
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
226
|
+
|
|
227
|
+
const parts: string[] = [];
|
|
228
|
+
parts.push(th.fg("accent", `${done}/${total}`));
|
|
229
|
+
if (totalCost > 0) parts.push(th.fg("dim", `$${totalCost.toFixed(4)}`));
|
|
230
|
+
|
|
231
|
+
// Find squad creation time for elapsed
|
|
232
|
+
const squad = store.loadSquad(this.squadId);
|
|
233
|
+
if (squad) {
|
|
234
|
+
const elapsed = Date.now() - new Date(squad.created).getTime();
|
|
235
|
+
parts.push(th.fg("dim", formatMs(elapsed)));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return ` ${parts.join(th.fg("dim", " · "))}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// =========================================================================
|
|
242
|
+
// Helpers
|
|
243
|
+
// =========================================================================
|
|
244
|
+
|
|
245
|
+
private padToHeight(lines: string[], maxLines: number): string[] {
|
|
246
|
+
while (lines.length < maxLines) {
|
|
247
|
+
lines.push("");
|
|
248
|
+
}
|
|
249
|
+
return lines.slice(0, maxLines);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ============================================================================
|
|
254
|
+
// Formatting
|
|
255
|
+
// ============================================================================
|
|
256
|
+
|
|
257
|
+
function formatMs(ms: number): string {
|
|
258
|
+
const seconds = Math.floor(ms / 1000);
|
|
259
|
+
const minutes = Math.floor(seconds / 60);
|
|
260
|
+
const hours = Math.floor(minutes / 60);
|
|
261
|
+
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
|
262
|
+
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
|
263
|
+
return `${seconds}s`;
|
|
264
|
+
}
|