pi-squad 0.1.0 → 0.4.14
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/README.md +248 -70
- package/package.json +7 -3
- package/src/index.ts +559 -320
- package/src/panel/message-view.ts +100 -125
- package/src/panel/squad-panel.ts +94 -148
- package/src/panel/squad-widget.ts +192 -0
- package/src/panel/task-list.ts +38 -28
- package/src/planner.ts +1 -1
- package/src/protocol.ts +37 -0
- package/src/scheduler.ts +188 -3
- package/src/skills/supervisor/SKILL.md +111 -0
- package/src/skills/verification/SKILL.md +32 -0
- package/src/store.ts +29 -1
- package/src/types.ts +11 -0
package/src/panel/squad-panel.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* squad-panel.ts — Main overlay component for the squad TUI panel.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Uses ctx.ui.custom() with a proper done() callback for lifecycle management.
|
|
5
|
+
* Inspired by pi-interactive-shell's overlay pattern:
|
|
6
|
+
* - Component implements Component + Focusable
|
|
7
|
+
* - handleInput for key dispatch
|
|
8
|
+
* - render(width) returns string[]
|
|
9
|
+
* - done() closes the overlay cleanly
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
|
-
import type { Component, Focusable, TUI
|
|
12
|
+
import type { Component, Focusable, TUI } from "@mariozechner/pi-tui";
|
|
9
13
|
import { matchesKey, visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
|
|
10
14
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
11
15
|
import type { PanelState, PanelView } from "../types.js";
|
|
@@ -15,10 +19,15 @@ import { MessageView } from "./message-view.js";
|
|
|
15
19
|
import * as store from "../store.js";
|
|
16
20
|
|
|
17
21
|
// ============================================================================
|
|
18
|
-
//
|
|
22
|
+
// Result type — what the panel returns when closed
|
|
19
23
|
// ============================================================================
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
export interface SquadPanelResult {
|
|
26
|
+
/** How the panel was closed */
|
|
27
|
+
action: "close" | "send-message";
|
|
28
|
+
taskId?: string;
|
|
29
|
+
message?: string;
|
|
30
|
+
}
|
|
22
31
|
|
|
23
32
|
// ============================================================================
|
|
24
33
|
// Squad Panel
|
|
@@ -29,11 +38,14 @@ export class SquadPanel implements Component, Focusable {
|
|
|
29
38
|
|
|
30
39
|
private tui: TUI;
|
|
31
40
|
private theme: Theme;
|
|
41
|
+
private done: (result: SquadPanelResult) => void;
|
|
32
42
|
private scheduler: Scheduler;
|
|
33
43
|
private squadId: string;
|
|
34
|
-
|
|
35
|
-
/** Callback
|
|
36
|
-
onSendMessage?: (taskId: string, message: string) => void
|
|
44
|
+
|
|
45
|
+
/** Callback for sending messages — set by the extension */
|
|
46
|
+
onSendMessage?: (taskId: string, message: string) => Promise<void>;
|
|
47
|
+
/** Callback when panel closes */
|
|
48
|
+
onClose?: () => void;
|
|
37
49
|
|
|
38
50
|
private state: PanelState = {
|
|
39
51
|
view: "tasks",
|
|
@@ -45,116 +57,54 @@ export class SquadPanel implements Component, Focusable {
|
|
|
45
57
|
|
|
46
58
|
private taskListView: TaskListView;
|
|
47
59
|
private messageView: MessageView;
|
|
48
|
-
|
|
49
|
-
private
|
|
50
|
-
|
|
51
|
-
|
|
60
|
+
/** Debounced render timer */
|
|
61
|
+
private renderTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
62
|
+
/** Auto-refresh timer for live activity */
|
|
63
|
+
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
64
|
+
private finished = false;
|
|
65
|
+
|
|
66
|
+
constructor(
|
|
67
|
+
tui: TUI,
|
|
68
|
+
theme: Theme,
|
|
69
|
+
scheduler: Scheduler,
|
|
70
|
+
squadId: string,
|
|
71
|
+
done: (result: SquadPanelResult) => void,
|
|
72
|
+
) {
|
|
52
73
|
this.tui = tui;
|
|
53
74
|
this.theme = theme;
|
|
54
75
|
this.scheduler = scheduler;
|
|
55
76
|
this.squadId = squadId;
|
|
77
|
+
this.done = done;
|
|
56
78
|
|
|
57
79
|
this.taskListView = new TaskListView(theme, squadId);
|
|
58
80
|
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
81
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
this.refreshInterval = setInterval(() => {
|
|
82
|
+
// Auto-refresh for live activity. 5s is enough — faster causes
|
|
83
|
+
// flicker and races with agents writing to JSONL files.
|
|
84
|
+
this.refreshTimer = setInterval(() => {
|
|
73
85
|
this.tui.requestRender();
|
|
74
|
-
},
|
|
86
|
+
}, 5000);
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
this.handle.hide();
|
|
81
|
-
this.handle = null;
|
|
82
|
-
}
|
|
83
|
-
if (this.refreshInterval) {
|
|
84
|
-
clearInterval(this.refreshInterval);
|
|
85
|
-
this.refreshInterval = null;
|
|
86
|
-
}
|
|
89
|
+
/** Trigger a render update (called from outside, e.g., on scheduler events) */
|
|
90
|
+
requestUpdate(): void {
|
|
91
|
+
this.debouncedRender();
|
|
87
92
|
}
|
|
88
93
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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();
|
|
94
|
+
private debouncedRender(): void {
|
|
95
|
+
if (this.renderTimeout) clearTimeout(this.renderTimeout);
|
|
96
|
+
this.renderTimeout = setTimeout(() => {
|
|
97
|
+
this.renderTimeout = null;
|
|
98
|
+
this.tui.requestRender();
|
|
99
|
+
}, 16);
|
|
137
100
|
}
|
|
138
101
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
};
|
|
102
|
+
private finish(result: SquadPanelResult): void {
|
|
103
|
+
if (this.finished) return;
|
|
104
|
+
this.finished = true;
|
|
105
|
+
this.dispose();
|
|
106
|
+
this.onClose?.();
|
|
107
|
+
this.done(result);
|
|
158
108
|
}
|
|
159
109
|
|
|
160
110
|
// =========================================================================
|
|
@@ -162,43 +112,27 @@ export class SquadPanel implements Component, Focusable {
|
|
|
162
112
|
// =========================================================================
|
|
163
113
|
|
|
164
114
|
handleInput(data: string): void {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// Ctrl+Q: switch focus back to editor (wide) or hide (narrow)
|
|
115
|
+
// Ctrl+Q or q: close panel
|
|
168
116
|
if (data === "\x11") {
|
|
169
|
-
|
|
170
|
-
this.handle?.unfocus();
|
|
171
|
-
} else {
|
|
172
|
-
this.handle?.setHidden(true);
|
|
173
|
-
}
|
|
117
|
+
this.finish({ action: "close" });
|
|
174
118
|
return;
|
|
175
119
|
}
|
|
176
120
|
|
|
177
|
-
// Escape: back from messages
|
|
121
|
+
// Escape: back from messages, or close panel
|
|
178
122
|
if (matchesKey(data, "escape")) {
|
|
179
123
|
if (this.state.view === "messages") {
|
|
180
124
|
this.state.view = "tasks";
|
|
181
125
|
this.tui.requestRender();
|
|
182
126
|
return;
|
|
183
127
|
}
|
|
184
|
-
|
|
185
|
-
this.handle?.unfocus();
|
|
186
|
-
} else {
|
|
187
|
-
this.handle?.setHidden(true);
|
|
188
|
-
}
|
|
128
|
+
this.finish({ action: "close" });
|
|
189
129
|
return;
|
|
190
130
|
}
|
|
191
131
|
|
|
192
|
-
// q:
|
|
193
|
-
if (matchesKey(data, "q")) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
this.handle?.unfocus();
|
|
197
|
-
} else {
|
|
198
|
-
this.handle?.setHidden(true);
|
|
199
|
-
}
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
132
|
+
// q: close from task list
|
|
133
|
+
if (matchesKey(data, "q") && this.state.view === "tasks") {
|
|
134
|
+
this.finish({ action: "close" });
|
|
135
|
+
return;
|
|
202
136
|
}
|
|
203
137
|
|
|
204
138
|
// Delegate to current view
|
|
@@ -228,7 +162,6 @@ export class SquadPanel implements Component, Focusable {
|
|
|
228
162
|
}
|
|
229
163
|
this.tui.requestRender();
|
|
230
164
|
} else if (matchesKey(data, "return")) {
|
|
231
|
-
// Open message view for selected task
|
|
232
165
|
if (this.state.selectedTaskId) {
|
|
233
166
|
this.state.view = "messages";
|
|
234
167
|
this.state.scrollOffset = 0;
|
|
@@ -236,7 +169,6 @@ export class SquadPanel implements Component, Focusable {
|
|
|
236
169
|
this.tui.requestRender();
|
|
237
170
|
}
|
|
238
171
|
} else if (matchesKey(data, "p")) {
|
|
239
|
-
// Pause/resume selected task
|
|
240
172
|
if (this.state.selectedTaskId) {
|
|
241
173
|
const task = store.loadTask(this.squadId, this.state.selectedTaskId);
|
|
242
174
|
if (task?.status === "in_progress") {
|
|
@@ -247,7 +179,6 @@ export class SquadPanel implements Component, Focusable {
|
|
|
247
179
|
this.tui.requestRender();
|
|
248
180
|
}
|
|
249
181
|
} else if (matchesKey(data, "x")) {
|
|
250
|
-
// Cancel selected task
|
|
251
182
|
if (this.state.selectedTaskId) {
|
|
252
183
|
this.scheduler.cancelTask(this.state.selectedTaskId);
|
|
253
184
|
this.tui.requestRender();
|
|
@@ -270,7 +201,6 @@ export class SquadPanel implements Component, Focusable {
|
|
|
270
201
|
this.state.view = "tasks";
|
|
271
202
|
this.tui.requestRender();
|
|
272
203
|
} else if (matchesKey(data, "m")) {
|
|
273
|
-
// Send message to this task's agent
|
|
274
204
|
const taskId = this.messageView.getTaskId();
|
|
275
205
|
if (taskId && this.onSendMessage) {
|
|
276
206
|
this.onSendMessage(taskId, "");
|
|
@@ -296,10 +226,14 @@ export class SquadPanel implements Component, Focusable {
|
|
|
296
226
|
const title = squad ? `squad: ${squad.goal.slice(0, width - 20)}` : "squad";
|
|
297
227
|
lines.push(...this.renderHeader(title, width));
|
|
298
228
|
|
|
299
|
-
// Content area
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const
|
|
229
|
+
// Content area — calculate available height for task list.
|
|
230
|
+
// The overlay has maxHeight "80%", so we must fit within that.
|
|
231
|
+
// Layout: 1 header + content + 3 footer = content gets the rest.
|
|
232
|
+
const contentWidth = width - 2;
|
|
233
|
+
const termRows = this.tui.terminal.rows || 24;
|
|
234
|
+
const overlayMaxRows = Math.floor(termRows * 0.8);
|
|
235
|
+
const chromeLines = 4; // 1 header + 3 footer
|
|
236
|
+
const availHeight = Math.max(5, overlayMaxRows - chromeLines);
|
|
303
237
|
|
|
304
238
|
switch (this.state.view) {
|
|
305
239
|
case "tasks": {
|
|
@@ -323,10 +257,13 @@ export class SquadPanel implements Component, Focusable {
|
|
|
323
257
|
}
|
|
324
258
|
}
|
|
325
259
|
|
|
326
|
-
// Footer
|
|
260
|
+
// Footer
|
|
327
261
|
lines.push(...this.renderFooter(width));
|
|
328
262
|
|
|
329
|
-
|
|
263
|
+
// Strict height: cap to overlayMaxRows so the overlay never overflows.
|
|
264
|
+
// Also truncate all lines to width to prevent pi-tui crash.
|
|
265
|
+
const maxTotalLines = overlayMaxRows;
|
|
266
|
+
return lines.slice(0, maxTotalLines).map((line) => truncateToWidth(line, width, ""));
|
|
330
267
|
}
|
|
331
268
|
|
|
332
269
|
private renderHeader(title: string, width: number): string[] {
|
|
@@ -334,33 +271,29 @@ export class SquadPanel implements Component, Focusable {
|
|
|
334
271
|
const innerW = width - 2;
|
|
335
272
|
const titleStr = ` ${title} `;
|
|
336
273
|
const titleVW = visibleWidth(titleStr);
|
|
337
|
-
const leftBar = "─";
|
|
338
274
|
const rightBarLen = Math.max(0, innerW - titleVW - 1);
|
|
339
|
-
const rightBar = "─".repeat(rightBarLen);
|
|
340
275
|
|
|
341
276
|
return [
|
|
342
|
-
th.fg("border", "
|
|
277
|
+
th.fg("border", "╭─") +
|
|
343
278
|
th.fg("accent", titleStr) +
|
|
344
|
-
th.fg("border",
|
|
279
|
+
th.fg("border", "─".repeat(rightBarLen) + "╮"),
|
|
345
280
|
];
|
|
346
281
|
}
|
|
347
282
|
|
|
348
283
|
private renderFooter(width: number): string[] {
|
|
349
284
|
const th = this.theme;
|
|
350
285
|
const innerW = width - 2;
|
|
351
|
-
const wide = this.tui.columns >= WIDE_THRESHOLD;
|
|
352
|
-
const switchHint = wide ? "^q switch" : "^q close";
|
|
353
286
|
|
|
354
287
|
let keys: string;
|
|
355
288
|
switch (this.state.view) {
|
|
356
289
|
case "tasks":
|
|
357
|
-
keys = ` ↑↓ nav ⏎ msgs m send p pause x cancel
|
|
290
|
+
keys = ` ↑↓ nav ⏎ msgs m send p pause x cancel ^q close`;
|
|
358
291
|
break;
|
|
359
292
|
case "messages":
|
|
360
|
-
keys = ` ↑↓ scroll m send esc back
|
|
293
|
+
keys = ` ↑↓ scroll m send esc back ^q close`;
|
|
361
294
|
break;
|
|
362
295
|
default:
|
|
363
|
-
keys = `
|
|
296
|
+
keys = ` ^q close`;
|
|
364
297
|
}
|
|
365
298
|
|
|
366
299
|
return [
|
|
@@ -370,14 +303,27 @@ export class SquadPanel implements Component, Focusable {
|
|
|
370
303
|
];
|
|
371
304
|
}
|
|
372
305
|
|
|
373
|
-
/** Wrap a content line with border chars, padded to exact width */
|
|
374
306
|
private borderLine(content: string, width: number): string {
|
|
375
307
|
const th = this.theme;
|
|
376
308
|
const innerW = width - 2;
|
|
377
|
-
// Truncate content to fit, preserving ANSI codes
|
|
378
309
|
const truncated = truncateToWidth(content, innerW);
|
|
379
310
|
const contentVW = visibleWidth(truncated);
|
|
380
311
|
const pad = " ".repeat(Math.max(0, innerW - contentVW));
|
|
381
312
|
return th.fg("border", "│") + truncated + pad + th.fg("border", "│");
|
|
382
313
|
}
|
|
314
|
+
|
|
315
|
+
// =========================================================================
|
|
316
|
+
// Lifecycle
|
|
317
|
+
// =========================================================================
|
|
318
|
+
|
|
319
|
+
dispose(): void {
|
|
320
|
+
if (this.renderTimeout) {
|
|
321
|
+
clearTimeout(this.renderTimeout);
|
|
322
|
+
this.renderTimeout = null;
|
|
323
|
+
}
|
|
324
|
+
if (this.refreshTimer) {
|
|
325
|
+
clearInterval(this.refreshTimer);
|
|
326
|
+
this.refreshTimer = null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
383
329
|
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* squad-widget.ts — Simple string[] widget for squad status.
|
|
3
|
+
*
|
|
4
|
+
* Uses the simple setWidget(key, string[]) API which pi-tui handles
|
|
5
|
+
* reliably. Component-based widgets with variable height cause TUI
|
|
6
|
+
* layout corruption.
|
|
7
|
+
*
|
|
8
|
+
* Updates are pushed by calling requestUpdate() which rebuilds the
|
|
9
|
+
* string[] and calls setWidget() again.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { truncateToWidth } from "@mariozechner/pi-tui";
|
|
13
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
14
|
+
import type { TaskStatus } from "../types.js";
|
|
15
|
+
import * as store from "../store.js";
|
|
16
|
+
|
|
17
|
+
function statusIcon(status: TaskStatus, th: Theme): string {
|
|
18
|
+
switch (status) {
|
|
19
|
+
case "done": return th.fg("success", "✓");
|
|
20
|
+
case "in_progress": return th.fg("warning", "⏳");
|
|
21
|
+
case "blocked": return th.fg("muted", "◻");
|
|
22
|
+
case "failed": return th.fg("error", "✗");
|
|
23
|
+
case "suspended": return th.fg("muted", "⏸");
|
|
24
|
+
default: return th.fg("dim", "·");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatElapsed(ms: number): string {
|
|
29
|
+
const s = Math.floor(ms / 1000);
|
|
30
|
+
const m = Math.floor(s / 60);
|
|
31
|
+
const h = Math.floor(m / 60);
|
|
32
|
+
if (h > 0) return `${h}h${m % 60}m`;
|
|
33
|
+
if (m > 0) return `${m}m${s % 60}s`;
|
|
34
|
+
return `${s}s`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SquadWidgetState {
|
|
38
|
+
squadId: string | null;
|
|
39
|
+
enabled: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Set up the squad widget. Returns control functions.
|
|
44
|
+
* Uses simple string[] setWidget — no component factory.
|
|
45
|
+
*/
|
|
46
|
+
export function setupSquadWidget(
|
|
47
|
+
ctx: { ui: { setWidget: Function; setStatus: Function; [key: string]: any }; hasUI?: boolean },
|
|
48
|
+
state: SquadWidgetState,
|
|
49
|
+
): {
|
|
50
|
+
requestUpdate: () => void;
|
|
51
|
+
dispose: () => void;
|
|
52
|
+
} {
|
|
53
|
+
if (!ctx.hasUI) return { requestUpdate: () => {}, dispose: () => {} };
|
|
54
|
+
|
|
55
|
+
let durationTimer: ReturnType<typeof setInterval> | null = null;
|
|
56
|
+
let renderTimer: ReturnType<typeof setTimeout> | null = null;
|
|
57
|
+
/** Cache key to skip redundant setWidget calls */
|
|
58
|
+
let lastCacheKey = "";
|
|
59
|
+
|
|
60
|
+
function render(): void {
|
|
61
|
+
if (!state.enabled || !state.squadId) {
|
|
62
|
+
ctx.ui.setWidget("squad-tasks", undefined);
|
|
63
|
+
ctx.ui.setStatus("squad", undefined);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const th = ctx.ui.theme;
|
|
68
|
+
const tasks = store.loadAllTasks(state.squadId);
|
|
69
|
+
const squad = store.loadSquad(state.squadId);
|
|
70
|
+
if (!squad || tasks.length === 0) {
|
|
71
|
+
ctx.ui.setWidget("squad-tasks", undefined);
|
|
72
|
+
ctx.ui.setStatus("squad", undefined);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const lines: string[] = [];
|
|
77
|
+
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
78
|
+
const doneCount = tasks.filter((t) => t.status === "done").length;
|
|
79
|
+
const elapsed = Date.now() - new Date(squad.created).getTime();
|
|
80
|
+
|
|
81
|
+
const sIcon = squad.status === "done" ? th.fg("success", "✓")
|
|
82
|
+
: squad.status === "failed" ? th.fg("error", "✗")
|
|
83
|
+
: th.fg("warning", "⏳");
|
|
84
|
+
|
|
85
|
+
lines.push(
|
|
86
|
+
`${sIcon} ${th.fg("accent", "squad")} ${th.fg("dim", squad.goal.slice(0, 35))} ` +
|
|
87
|
+
`${th.fg("muted", `${doneCount}/${tasks.length}`)} ` +
|
|
88
|
+
`${th.fg("dim", `$${totalCost.toFixed(2)}`)} ` +
|
|
89
|
+
`${th.fg("dim", formatElapsed(elapsed))} ` +
|
|
90
|
+
`${th.fg("dim", "^q detail · /squad msg")}`
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Cap visible tasks based on total count
|
|
94
|
+
const maxVisible = tasks.length > 6 ? 4 : tasks.length;
|
|
95
|
+
const visibleTasks = tasks.slice(0, maxVisible);
|
|
96
|
+
|
|
97
|
+
for (const task of visibleTasks) {
|
|
98
|
+
const icon = statusIcon(task.status, th);
|
|
99
|
+
let line = ` ${icon} ${th.fg("muted", task.id)} ${th.fg("dim", `(${task.agent})`)}`;
|
|
100
|
+
|
|
101
|
+
if (task.status === "done" && task.output) {
|
|
102
|
+
let timeStr = "";
|
|
103
|
+
if (task.started && task.completed) {
|
|
104
|
+
const dur = new Date(task.completed).getTime() - new Date(task.started).getTime();
|
|
105
|
+
timeStr = ` ${formatElapsed(dur)}`;
|
|
106
|
+
}
|
|
107
|
+
line += th.fg("dim", `${timeStr} ${task.output.split("\n")[0].slice(0, 40)}`);
|
|
108
|
+
} else if (task.status === "failed" && task.error) {
|
|
109
|
+
line += ` ${th.fg("error", task.error.slice(0, 40))}`;
|
|
110
|
+
} else if (task.status === "in_progress") {
|
|
111
|
+
const runningFor = task.started ? Date.now() - new Date(task.started).getTime() : 0;
|
|
112
|
+
const timeColor = runningFor > 180_000 ? "warning" : "dim";
|
|
113
|
+
line += ` ${th.fg(timeColor as any, formatElapsed(runningFor))}`;
|
|
114
|
+
const messages = store.loadMessages(state.squadId!, task.id);
|
|
115
|
+
const lastTool = [...messages].reverse().find(m => m.type === "tool");
|
|
116
|
+
if (lastTool) {
|
|
117
|
+
const rawDetail = (lastTool.args?.path || lastTool.args?.command || "").toString();
|
|
118
|
+
const detail = rawDetail.split("\n")[0]; // first line only
|
|
119
|
+
const toolStr = `→ ${lastTool.name || lastTool.text}`;
|
|
120
|
+
line += ` ${th.fg("dim", (detail ? `${toolStr} ${detail}` : toolStr).slice(0, 30))}`;
|
|
121
|
+
}
|
|
122
|
+
} else if (task.status === "blocked") {
|
|
123
|
+
const blockers = task.depends.filter((d) => {
|
|
124
|
+
const dep = tasks.find((t) => t.id === d);
|
|
125
|
+
return dep && dep.status !== "done";
|
|
126
|
+
});
|
|
127
|
+
if (blockers.length > 0) {
|
|
128
|
+
line += ` ${th.fg("dim", "← " + blockers.join(", "))}`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
lines.push(line);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (tasks.length > maxVisible) {
|
|
136
|
+
lines.push(` ${th.fg("dim", ` +${tasks.length - maxVisible} more · ^q detail`)}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Skip if nothing changed (avoid redundant setWidget calls that cause flicker)
|
|
140
|
+
const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}`;
|
|
141
|
+
if (cacheKey === lastCacheKey) return;
|
|
142
|
+
lastCacheKey = cacheKey;
|
|
143
|
+
|
|
144
|
+
ctx.ui.setWidget("squad-tasks", lines);
|
|
145
|
+
|
|
146
|
+
// Update status bar
|
|
147
|
+
const statusText = squad.status === "done"
|
|
148
|
+
? th.fg("success", `✓ squad ${doneCount}/${tasks.length}`)
|
|
149
|
+
: squad.status === "failed"
|
|
150
|
+
? th.fg("error", `✗ squad ${doneCount}/${tasks.length}`)
|
|
151
|
+
: th.fg("accent", `⏳ squad ${doneCount}/${tasks.length} $${totalCost.toFixed(2)}`);
|
|
152
|
+
ctx.ui.setStatus("squad", statusText);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function manageDurationTimer(): void {
|
|
156
|
+
if (!state.squadId || !state.enabled) {
|
|
157
|
+
if (durationTimer) { clearInterval(durationTimer); durationTimer = null; }
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const squad = store.loadSquad(state.squadId);
|
|
161
|
+
const isActive = squad && (squad.status === "running" || squad.status === "paused");
|
|
162
|
+
if (isActive && !durationTimer) {
|
|
163
|
+
durationTimer = setInterval(() => render(), 5000);
|
|
164
|
+
} else if (!isActive && durationTimer) {
|
|
165
|
+
clearInterval(durationTimer);
|
|
166
|
+
durationTimer = null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Initial render
|
|
171
|
+
render();
|
|
172
|
+
manageDurationTimer();
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
requestUpdate(): void {
|
|
176
|
+
// Debounce: multiple rapid events (scheduler) coalesce into one render
|
|
177
|
+
if (renderTimer) return;
|
|
178
|
+
renderTimer = setTimeout(() => {
|
|
179
|
+
renderTimer = null;
|
|
180
|
+
render();
|
|
181
|
+
manageDurationTimer();
|
|
182
|
+
}, 50);
|
|
183
|
+
},
|
|
184
|
+
dispose(): void {
|
|
185
|
+
if (durationTimer) { clearInterval(durationTimer); durationTimer = null; }
|
|
186
|
+
if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
|
|
187
|
+
lastCacheKey = "";
|
|
188
|
+
ctx.ui.setWidget("squad-tasks", undefined);
|
|
189
|
+
ctx.ui.setStatus("squad", undefined);
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|