supipowers 0.7.9 → 0.7.11
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/package.json +1 -1
- package/src/commands/run.ts +28 -0
- package/src/orchestrator/agent-grid.ts +439 -0
- package/src/orchestrator/dispatcher.ts +162 -14
package/package.json
CHANGED
package/src/commands/run.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { buildWorktreePrompt } from "../git/worktree.js";
|
|
|
27
27
|
import { buildBranchFinishPrompt } from "../git/branch-finish.js";
|
|
28
28
|
import { detectBaseBranch } from "../git/base-branch.js";
|
|
29
29
|
import type { RunManifest, AgentResult } from "../types.js";
|
|
30
|
+
import { AgentGridWidget, createAgentGridFactory } from "../orchestrator/agent-grid.js";
|
|
30
31
|
|
|
31
32
|
interface ParsedRunArgs {
|
|
32
33
|
profile?: string;
|
|
@@ -175,6 +176,23 @@ export function registerRunCommand(pi: ExtensionAPI): void {
|
|
|
175
176
|
const lsp = isLspAvailable(pi.getActiveTools());
|
|
176
177
|
const ctxMode = detectContextMode(pi.getActiveTools()).available;
|
|
177
178
|
|
|
179
|
+
// Mount agent grid widget for live progress visualization
|
|
180
|
+
let widget: AgentGridWidget | undefined;
|
|
181
|
+
if (ctx.hasUI) {
|
|
182
|
+
const widgetReady = new Promise<AgentGridWidget>((resolve) => {
|
|
183
|
+
ctx.ui.setWidget("supi-agents", createAgentGridFactory((w) => {
|
|
184
|
+
widget = w;
|
|
185
|
+
for (const task of plan.tasks) {
|
|
186
|
+
w.addTask(task.id, task.name);
|
|
187
|
+
}
|
|
188
|
+
resolve(w);
|
|
189
|
+
}));
|
|
190
|
+
});
|
|
191
|
+
// Wait for TUI to instantiate the widget before dispatching agents
|
|
192
|
+
widget = await widgetReady;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
178
196
|
for (const batch of manifest.batches) {
|
|
179
197
|
if (batch.status === "completed") continue;
|
|
180
198
|
|
|
@@ -200,6 +218,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
|
|
|
200
218
|
config,
|
|
201
219
|
lspAvailable: lsp,
|
|
202
220
|
contextModeAvailable: ctxMode,
|
|
221
|
+
widget,
|
|
203
222
|
});
|
|
204
223
|
});
|
|
205
224
|
|
|
@@ -236,6 +255,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
|
|
|
236
255
|
config,
|
|
237
256
|
lspAvailable: lsp,
|
|
238
257
|
contextModeAvailable: ctxMode,
|
|
258
|
+
widget,
|
|
239
259
|
previousOutput: failed.output,
|
|
240
260
|
failureReason: failed.output,
|
|
241
261
|
});
|
|
@@ -267,6 +287,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
|
|
|
267
287
|
manifest.completedAt = new Date().toISOString();
|
|
268
288
|
updateRun(ctx.cwd, manifest);
|
|
269
289
|
|
|
290
|
+
|
|
270
291
|
const durationSec = Math.round(runSummary.totalDuration / 1000);
|
|
271
292
|
notifySummary(
|
|
272
293
|
ctx,
|
|
@@ -292,6 +313,13 @@ export function registerRunCommand(pi: ExtensionAPI): void {
|
|
|
292
313
|
);
|
|
293
314
|
notifyInfo(ctx, "Run succeeded", "Follow branch finish instructions to integrate your work");
|
|
294
315
|
}
|
|
316
|
+
} finally {
|
|
317
|
+
// Ensure widget interval timer is cleaned up even on exception
|
|
318
|
+
widget?.dispose();
|
|
319
|
+
if (ctx.hasUI) {
|
|
320
|
+
ctx.ui.setWidget("supi-agents", undefined);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
295
323
|
},
|
|
296
324
|
});
|
|
297
325
|
}
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Grid Widget — TUI component for live sub-agent progress visualization.
|
|
3
|
+
*
|
|
4
|
+
* Renders task cards in a flexbox-like grid that reflows based on terminal width.
|
|
5
|
+
* Each card shows: status icon, task name, current thinking, tool activity log,
|
|
6
|
+
* files changed count, and elapsed time.
|
|
7
|
+
*
|
|
8
|
+
* Cards collapse when done/blocked to make room for active cards.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
12
|
+
const MIN_CARD_WIDTH = 38;
|
|
13
|
+
const MAX_ACTIVITY_LOG = 4;
|
|
14
|
+
const ANIMATION_INTERVAL_MS = 200;
|
|
15
|
+
|
|
16
|
+
// ── Types ──────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export type TaskStatus =
|
|
19
|
+
| "pending"
|
|
20
|
+
| "running"
|
|
21
|
+
| "reviewing"
|
|
22
|
+
| "done"
|
|
23
|
+
| "done_with_concerns"
|
|
24
|
+
| "blocked";
|
|
25
|
+
|
|
26
|
+
export interface TaskCardState {
|
|
27
|
+
taskId: number;
|
|
28
|
+
name: string;
|
|
29
|
+
status: TaskStatus;
|
|
30
|
+
currentThinking: string;
|
|
31
|
+
activityLog: string[];
|
|
32
|
+
filesChanged: number;
|
|
33
|
+
toolCount: number;
|
|
34
|
+
startedAt: number;
|
|
35
|
+
completedAt?: number;
|
|
36
|
+
errorReason?: string;
|
|
37
|
+
concerns?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Minimal TUI interface — matches what setWidget factory receives */
|
|
41
|
+
interface TUI {
|
|
42
|
+
requestRender(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Minimal Theme interface — matches OMP's Theme class */
|
|
46
|
+
interface Theme {
|
|
47
|
+
fg(color: string, text: string): string;
|
|
48
|
+
symbol(key: string): string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Component interface as defined by @oh-my-pi/pi-tui */
|
|
52
|
+
interface Component {
|
|
53
|
+
render(width: number): string[];
|
|
54
|
+
invalidate(): void;
|
|
55
|
+
dispose?(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Status Config ──────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
interface StatusConfig {
|
|
61
|
+
color: string;
|
|
62
|
+
icon: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const STATUS_CONFIG: Record<TaskStatus, StatusConfig> = {
|
|
66
|
+
pending: { color: "dim", icon: "○" },
|
|
67
|
+
running: { color: "accent", icon: "●" },
|
|
68
|
+
reviewing: { color: "warning", icon: "◎" },
|
|
69
|
+
done: { color: "success", icon: "✓" },
|
|
70
|
+
done_with_concerns: { color: "warning", icon: "⚠" },
|
|
71
|
+
blocked: { color: "error", icon: "✗" },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ── AgentGridWidget ────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export class AgentGridWidget implements Component {
|
|
77
|
+
#tasks = new Map<number, TaskCardState>();
|
|
78
|
+
#tui: TUI;
|
|
79
|
+
#theme: Theme;
|
|
80
|
+
#spinnerFrame = 0;
|
|
81
|
+
#intervalId: ReturnType<typeof setInterval> | null = null;
|
|
82
|
+
#cachedLines: string[] | undefined;
|
|
83
|
+
#cachedWidth: number | undefined;
|
|
84
|
+
|
|
85
|
+
constructor(tui: TUI, theme: Theme) {
|
|
86
|
+
this.#tui = tui;
|
|
87
|
+
this.#theme = theme;
|
|
88
|
+
|
|
89
|
+
// Animate spinner + elapsed time
|
|
90
|
+
this.#intervalId = setInterval(() => {
|
|
91
|
+
this.#spinnerFrame = (this.#spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
92
|
+
// Only re-render if we have active tasks
|
|
93
|
+
const hasActive = [...this.#tasks.values()].some(
|
|
94
|
+
(t) => t.status === "running" || t.status === "reviewing",
|
|
95
|
+
);
|
|
96
|
+
if (hasActive) {
|
|
97
|
+
this.invalidate();
|
|
98
|
+
this.#tui.requestRender();
|
|
99
|
+
}
|
|
100
|
+
}, ANIMATION_INTERVAL_MS);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Public API (called by dispatcher) ──────────────────────────
|
|
104
|
+
|
|
105
|
+
/** Initialize a task card */
|
|
106
|
+
addTask(taskId: number, name: string): void {
|
|
107
|
+
this.#tasks.set(taskId, {
|
|
108
|
+
taskId,
|
|
109
|
+
name,
|
|
110
|
+
status: "pending",
|
|
111
|
+
currentThinking: "",
|
|
112
|
+
activityLog: [],
|
|
113
|
+
filesChanged: 0,
|
|
114
|
+
toolCount: 0,
|
|
115
|
+
startedAt: Date.now(),
|
|
116
|
+
});
|
|
117
|
+
this.#invalidateAndRender();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Update task status */
|
|
121
|
+
setStatus(taskId: number, status: TaskStatus, reason?: string): void {
|
|
122
|
+
const task = this.#tasks.get(taskId);
|
|
123
|
+
if (!task) return;
|
|
124
|
+
task.status = status;
|
|
125
|
+
if (status === "blocked") task.errorReason = reason;
|
|
126
|
+
if (status === "done_with_concerns") task.concerns = reason;
|
|
127
|
+
if (status === "done" || status === "done_with_concerns" || status === "blocked") {
|
|
128
|
+
task.completedAt = Date.now();
|
|
129
|
+
}
|
|
130
|
+
this.#invalidateAndRender();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Update thinking preview */
|
|
134
|
+
setThinking(taskId: number, text: string): void {
|
|
135
|
+
const task = this.#tasks.get(taskId);
|
|
136
|
+
if (!task) return;
|
|
137
|
+
task.currentThinking = text;
|
|
138
|
+
this.#invalidateAndRender();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Add a tool call to the activity log */
|
|
142
|
+
addActivity(taskId: number, description: string): void {
|
|
143
|
+
const task = this.#tasks.get(taskId);
|
|
144
|
+
if (!task) return;
|
|
145
|
+
task.toolCount++;
|
|
146
|
+
task.activityLog.push(description);
|
|
147
|
+
if (task.activityLog.length > MAX_ACTIVITY_LOG) {
|
|
148
|
+
task.activityLog.shift();
|
|
149
|
+
}
|
|
150
|
+
this.#invalidateAndRender();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Increment files changed count */
|
|
154
|
+
addFileChanged(taskId: number): void {
|
|
155
|
+
const task = this.#tasks.get(taskId);
|
|
156
|
+
if (!task) return;
|
|
157
|
+
task.filesChanged++;
|
|
158
|
+
this.#invalidateAndRender();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Component Interface ────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
render(width: number): string[] {
|
|
164
|
+
if (this.#cachedLines && this.#cachedWidth === width) return this.#cachedLines;
|
|
165
|
+
if (this.#tasks.size === 0) return [];
|
|
166
|
+
|
|
167
|
+
const tasks = [...this.#tasks.values()];
|
|
168
|
+
const cardWidth = this.#computeCardWidth(width);
|
|
169
|
+
const cardsPerRow = Math.max(1, Math.floor(width / cardWidth));
|
|
170
|
+
|
|
171
|
+
const lines: string[] = [];
|
|
172
|
+
|
|
173
|
+
// Render in rows
|
|
174
|
+
for (let i = 0; i < tasks.length; i += cardsPerRow) {
|
|
175
|
+
const rowTasks = tasks.slice(i, i + cardsPerRow);
|
|
176
|
+
const rowCards = rowTasks.map((t) => this.#renderCard(t, cardWidth));
|
|
177
|
+
const merged = this.#mergeCardRows(rowCards, cardWidth, width);
|
|
178
|
+
lines.push(...merged);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
this.#cachedLines = lines;
|
|
182
|
+
this.#cachedWidth = width;
|
|
183
|
+
return lines;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
invalidate(): void {
|
|
187
|
+
this.#cachedLines = undefined;
|
|
188
|
+
this.#cachedWidth = undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
dispose(): void {
|
|
192
|
+
if (this.#intervalId) {
|
|
193
|
+
clearInterval(this.#intervalId);
|
|
194
|
+
this.#intervalId = null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Private Rendering ──────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/** Box drawing chars shorthand */
|
|
201
|
+
get #box() {
|
|
202
|
+
return {
|
|
203
|
+
tl: this.#theme.symbol("boxRound.topLeft"),
|
|
204
|
+
tr: this.#theme.symbol("boxRound.topRight"),
|
|
205
|
+
bl: this.#theme.symbol("boxRound.bottomLeft"),
|
|
206
|
+
br: this.#theme.symbol("boxRound.bottomRight"),
|
|
207
|
+
h: this.#theme.symbol("boxRound.horizontal"),
|
|
208
|
+
v: this.#theme.symbol("boxRound.vertical"),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#invalidateAndRender(): void {
|
|
213
|
+
this.invalidate();
|
|
214
|
+
this.#tui.requestRender();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#computeCardWidth(termWidth: number): number {
|
|
218
|
+
if (termWidth < MIN_CARD_WIDTH) return termWidth; // terminal too narrow, use full width
|
|
219
|
+
const maxWidth = Math.floor(termWidth / 2);
|
|
220
|
+
const cardsPerRow = Math.max(1, Math.floor(termWidth / MIN_CARD_WIDTH));
|
|
221
|
+
const computed = Math.floor(termWidth / cardsPerRow);
|
|
222
|
+
return Math.max(MIN_CARD_WIDTH, Math.min(computed, maxWidth));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#renderCard(task: TaskCardState, width: number): string[] {
|
|
226
|
+
const isCollapsed =
|
|
227
|
+
task.status === "done" ||
|
|
228
|
+
task.status === "done_with_concerns" ||
|
|
229
|
+
task.status === "blocked" ||
|
|
230
|
+
task.status === "pending";
|
|
231
|
+
|
|
232
|
+
if (isCollapsed) {
|
|
233
|
+
return this.#renderCollapsedCard(task, width);
|
|
234
|
+
}
|
|
235
|
+
return this.#renderActiveCard(task, width);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#renderCollapsedCard(task: TaskCardState, width: number): string[] {
|
|
239
|
+
const { color, icon } = STATUS_CONFIG[task.status];
|
|
240
|
+
const box = this.#box;
|
|
241
|
+
const inner = width - 4; // 2 border + 2 padding
|
|
242
|
+
|
|
243
|
+
if (task.status === "pending") {
|
|
244
|
+
const title = this.#truncate(`${icon} Task ${task.taskId}: ${task.name}`, inner);
|
|
245
|
+
return [
|
|
246
|
+
this.#theme.fg(color, `${box.tl}${box.h} ${title} ${this.#pad(box.h, inner - title.length - 1)}${box.tr}`),
|
|
247
|
+
this.#theme.fg(color, `${box.bl}${this.#pad(box.h, width - 2)}${box.br}`),
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Done / blocked / concerns
|
|
252
|
+
let suffix = "";
|
|
253
|
+
if (task.status === "done" || task.status === "done_with_concerns") {
|
|
254
|
+
const elapsed = this.#formatElapsed(task);
|
|
255
|
+
suffix = `${task.filesChanged} files, ${elapsed}`;
|
|
256
|
+
} else if (task.status === "blocked") {
|
|
257
|
+
suffix = "BLOCKED";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const title = this.#truncate(`${icon} Task ${task.taskId}: ${task.name}`, inner - suffix.length - 4);
|
|
261
|
+
const headerContent = `${title} ── ${suffix}`;
|
|
262
|
+
const headerPad = Math.max(0, inner - this.#visibleLength(headerContent) - 1);
|
|
263
|
+
|
|
264
|
+
const lines = [
|
|
265
|
+
this.#theme.fg(color, `${box.tl}${box.h} ${headerContent}${this.#pad(box.h, headerPad)} ${box.tr}`),
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
// Show error reason for blocked, concerns for done_with_concerns
|
|
269
|
+
const detail = task.status === "blocked" ? task.errorReason : task.concerns;
|
|
270
|
+
if (detail) {
|
|
271
|
+
const detailText = this.#truncate(` ${detail}`, inner);
|
|
272
|
+
lines.push(
|
|
273
|
+
this.#theme.fg(color, box.v) +
|
|
274
|
+
` ${detailText}${" ".repeat(Math.max(0, inner - this.#visibleLength(detailText)))} ` +
|
|
275
|
+
this.#theme.fg(color, box.v),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
lines.push(
|
|
280
|
+
this.#theme.fg(color, `${box.bl}${this.#pad(box.h, width - 2)}${box.br}`),
|
|
281
|
+
);
|
|
282
|
+
return lines;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#renderActiveCard(task: TaskCardState, width: number): string[] {
|
|
286
|
+
const { color, icon } = STATUS_CONFIG[task.status];
|
|
287
|
+
const box = this.#box;
|
|
288
|
+
const inner = width - 4; // 2 border + 2 padding
|
|
289
|
+
const spinner = SPINNER_FRAMES[this.#spinnerFrame];
|
|
290
|
+
|
|
291
|
+
const lines: string[] = [];
|
|
292
|
+
|
|
293
|
+
// ── Header
|
|
294
|
+
const title = this.#truncate(`${icon} Task ${task.taskId}: ${task.name}`, inner);
|
|
295
|
+
lines.push(
|
|
296
|
+
this.#theme.fg(color, `${box.tl}${box.h} ${title} ${this.#pad(box.h, inner - this.#visibleLength(title) - 1)}${box.tr}`),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// ── Sticky thinking line
|
|
300
|
+
const thinkingText = task.currentThinking
|
|
301
|
+
? this.#truncate(`${spinner} ${task.currentThinking}`, inner)
|
|
302
|
+
: `${spinner} Working...`;
|
|
303
|
+
lines.push(this.#line(
|
|
304
|
+
this.#theme.fg("dim", thinkingText),
|
|
305
|
+
inner,
|
|
306
|
+
color,
|
|
307
|
+
));
|
|
308
|
+
|
|
309
|
+
// ── Separator
|
|
310
|
+
const sep = this.#pad("─", inner);
|
|
311
|
+
lines.push(this.#line(this.#theme.fg("dim", sep), inner, color));
|
|
312
|
+
|
|
313
|
+
// ── Activity log (pad to MAX_ACTIVITY_LOG lines)
|
|
314
|
+
const logLines = task.activityLog.slice(-MAX_ACTIVITY_LOG);
|
|
315
|
+
for (let i = 0; i < MAX_ACTIVITY_LOG; i++) {
|
|
316
|
+
const entry = logLines[i];
|
|
317
|
+
if (entry) {
|
|
318
|
+
const text = this.#truncate(` ${entry}`, inner);
|
|
319
|
+
// Tool entries start with action verbs (Reading, Editing, Running, etc.)
|
|
320
|
+
// Thinking entries are plain text from the agent
|
|
321
|
+
const isToolAction = /^ (?:Reading|Editing|Writing|Running|Searching|Finding|Spawning|Glob|Grep|Bash|Edit|Read|Write)/.test(entry);
|
|
322
|
+
const styled = isToolAction ? text : this.#theme.fg("dim", text);
|
|
323
|
+
lines.push(this.#line(styled, inner, color));
|
|
324
|
+
} else {
|
|
325
|
+
lines.push(this.#line("", inner, color));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── Empty line
|
|
330
|
+
lines.push(this.#line("", inner, color));
|
|
331
|
+
|
|
332
|
+
// ── Footer
|
|
333
|
+
const filesLabel = `${task.filesChanged} file${task.filesChanged !== 1 ? "s" : ""}`;
|
|
334
|
+
const elapsed = `⏱ ${this.#formatElapsed(task)}`;
|
|
335
|
+
const gap = Math.max(1, inner - this.#visibleLength(filesLabel) - this.#visibleLength(elapsed));
|
|
336
|
+
const footerContent = `${filesLabel}${" ".repeat(gap)}${elapsed}`;
|
|
337
|
+
lines.push(this.#line(this.#theme.fg("dim", footerContent), inner, color));
|
|
338
|
+
|
|
339
|
+
// ── Bottom border
|
|
340
|
+
lines.push(
|
|
341
|
+
this.#theme.fg(color, `${box.bl}${this.#pad(box.h, width - 2)}${box.br}`),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
return lines;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Render a content line with colored side borders */
|
|
348
|
+
#line(content: string, inner: number, borderColor: string): string {
|
|
349
|
+
const box = this.#box;
|
|
350
|
+
const contentLen = this.#visibleLength(content);
|
|
351
|
+
const pad = Math.max(0, inner - contentLen);
|
|
352
|
+
return (
|
|
353
|
+
this.#theme.fg(borderColor, box.v) +
|
|
354
|
+
` ${content}${" ".repeat(pad)} ` +
|
|
355
|
+
this.#theme.fg(borderColor, box.v)
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Merge multiple card columns into rows of lines */
|
|
360
|
+
#mergeCardRows(cards: string[][], cardWidth: number, termWidth: number): string[] {
|
|
361
|
+
const maxHeight = Math.max(...cards.map((c) => c.length));
|
|
362
|
+
const gap = 1; // space between cards
|
|
363
|
+
const merged: string[] = [];
|
|
364
|
+
|
|
365
|
+
for (let row = 0; row < maxHeight; row++) {
|
|
366
|
+
let line = "";
|
|
367
|
+
for (let col = 0; col < cards.length; col++) {
|
|
368
|
+
if (col > 0) line += " ".repeat(gap);
|
|
369
|
+
const cardLine = cards[col][row] ?? " ".repeat(cardWidth);
|
|
370
|
+
line += cardLine;
|
|
371
|
+
}
|
|
372
|
+
merged.push(line);
|
|
373
|
+
}
|
|
374
|
+
return merged;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── Utilities ──────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
#truncate(text: string, maxLen: number): string {
|
|
380
|
+
if (this.#visibleLength(text) <= maxLen) return text;
|
|
381
|
+
// Walk through the string, counting only visible chars, preserving ANSI sequences
|
|
382
|
+
const ansiRegex = /\x1b\[[0-9;]*m/g;
|
|
383
|
+
let result = "";
|
|
384
|
+
let visible = 0;
|
|
385
|
+
let lastIndex = 0;
|
|
386
|
+
let match: RegExpExecArray | null;
|
|
387
|
+
while ((match = ansiRegex.exec(text)) !== null) {
|
|
388
|
+
// Add visible chars before this ANSI sequence
|
|
389
|
+
const before = text.slice(lastIndex, match.index);
|
|
390
|
+
const remaining = maxLen - 1 - visible;
|
|
391
|
+
if (before.length > remaining) {
|
|
392
|
+
result += before.slice(0, remaining) + "…";
|
|
393
|
+
return result + "\x1b[0m"; // reset styling
|
|
394
|
+
}
|
|
395
|
+
result += before;
|
|
396
|
+
visible += before.length;
|
|
397
|
+
result += match[0]; // preserve ANSI sequence
|
|
398
|
+
lastIndex = match.index + match[0].length;
|
|
399
|
+
}
|
|
400
|
+
// Handle remaining text after last ANSI sequence
|
|
401
|
+
const tail = text.slice(lastIndex);
|
|
402
|
+
const remaining = maxLen - 1 - visible;
|
|
403
|
+
if (tail.length > remaining) {
|
|
404
|
+
result += tail.slice(0, remaining) + "…";
|
|
405
|
+
return result + "\x1b[0m";
|
|
406
|
+
}
|
|
407
|
+
result += tail;
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
#visibleLength(text: string): number {
|
|
412
|
+
return text.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
#pad(char: string, count: number): string {
|
|
416
|
+
return char.repeat(Math.max(0, count));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
#formatElapsed(task: TaskCardState): string {
|
|
420
|
+
const end = task.completedAt ?? Date.now();
|
|
421
|
+
const sec = Math.floor((end - task.startedAt) / 1000);
|
|
422
|
+
if (sec < 60) return `${sec}s`;
|
|
423
|
+
const min = Math.floor(sec / 60);
|
|
424
|
+
return `${min}m ${sec % 60}s`;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── Factory ────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
/** Create the widget factory for ctx.ui.setWidget */
|
|
431
|
+
export function createAgentGridFactory(
|
|
432
|
+
onCreated: (widget: AgentGridWidget) => void,
|
|
433
|
+
): (tui: TUI, theme: Theme) => AgentGridWidget {
|
|
434
|
+
return (tui: TUI, theme: Theme) => {
|
|
435
|
+
const widget = new AgentGridWidget(tui, theme);
|
|
436
|
+
onCreated(widget);
|
|
437
|
+
return widget;
|
|
438
|
+
};
|
|
439
|
+
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
notifyError,
|
|
19
19
|
notifyInfo,
|
|
20
20
|
} from "../notifications/renderer.js";
|
|
21
|
+
import type { AgentGridWidget } from "./agent-grid.js";
|
|
21
22
|
|
|
22
23
|
export interface DispatchOptions {
|
|
23
24
|
pi: ExtensionAPI;
|
|
@@ -30,18 +31,22 @@ export interface DispatchOptions {
|
|
|
30
31
|
config: SupipowersConfig;
|
|
31
32
|
lspAvailable: boolean;
|
|
32
33
|
contextModeAvailable: boolean;
|
|
34
|
+
widget?: AgentGridWidget;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export async function dispatchAgent(
|
|
36
38
|
options: DispatchOptions,
|
|
37
39
|
): Promise<AgentResult> {
|
|
38
|
-
const { pi, ctx, task, planContext, config, lspAvailable, contextModeAvailable } = options;
|
|
40
|
+
const { pi, ctx, task, planContext, config, lspAvailable, contextModeAvailable, widget } = options;
|
|
39
41
|
const startTime = Date.now();
|
|
40
42
|
|
|
41
43
|
const prompt = buildTaskPrompt(task, planContext, config, lspAvailable, contextModeAvailable);
|
|
42
44
|
|
|
45
|
+
// Initialize widget card if available
|
|
46
|
+
widget?.setStatus(task.id, "running");
|
|
47
|
+
|
|
43
48
|
try {
|
|
44
|
-
const result = await executeSubAgent(pi, prompt, task, config);
|
|
49
|
+
const result = await executeSubAgent(pi, prompt, task, config, ctx, widget);
|
|
45
50
|
|
|
46
51
|
const agentResult: AgentResult = {
|
|
47
52
|
taskId: task.id,
|
|
@@ -52,28 +57,31 @@ export async function dispatchAgent(
|
|
|
52
57
|
duration: Date.now() - startTime,
|
|
53
58
|
};
|
|
54
59
|
|
|
60
|
+
// Update widget with final status
|
|
55
61
|
switch (agentResult.status) {
|
|
56
62
|
case "done":
|
|
63
|
+
widget?.setStatus(task.id, "done");
|
|
57
64
|
notifySuccess(ctx, `Task ${task.id} completed`, task.name);
|
|
58
65
|
break;
|
|
59
66
|
case "done_with_concerns":
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
`Task ${task.id} done with concerns`,
|
|
63
|
-
agentResult.concerns,
|
|
64
|
-
);
|
|
67
|
+
widget?.setStatus(task.id, "done_with_concerns", agentResult.concerns);
|
|
68
|
+
notifyWarning(ctx, `Task ${task.id} done with concerns`, agentResult.concerns);
|
|
65
69
|
break;
|
|
66
70
|
case "blocked":
|
|
71
|
+
widget?.setStatus(task.id, "blocked", agentResult.output);
|
|
67
72
|
notifyError(ctx, `Task ${task.id} blocked`, agentResult.output);
|
|
68
73
|
break;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
return agentResult;
|
|
72
77
|
} catch (error) {
|
|
78
|
+
const errorMsg = `Agent error: ${error instanceof Error ? error.message : String(error)}`;
|
|
79
|
+
widget?.setStatus(task.id, "blocked", errorMsg);
|
|
80
|
+
|
|
73
81
|
const agentResult: AgentResult = {
|
|
74
82
|
taskId: task.id,
|
|
75
83
|
status: "blocked",
|
|
76
|
-
output:
|
|
84
|
+
output: errorMsg,
|
|
77
85
|
filesChanged: [],
|
|
78
86
|
duration: Date.now() - startTime,
|
|
79
87
|
};
|
|
@@ -89,16 +97,152 @@ interface SubAgentResult {
|
|
|
89
97
|
filesChanged: string[];
|
|
90
98
|
}
|
|
91
99
|
|
|
100
|
+
type NotifyCtx = { ui: { notify(msg: string, type?: "info" | "warning" | "error"): void } };
|
|
101
|
+
|
|
102
|
+
/** Shorten a file path for display */
|
|
103
|
+
function shortenPath(filePath: string): string {
|
|
104
|
+
const parts = filePath.split("/");
|
|
105
|
+
return parts.length > 3
|
|
106
|
+
? `.../${parts.slice(-3).join("/")}`
|
|
107
|
+
: filePath;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Format a tool call for display */
|
|
111
|
+
function formatToolAction(toolName: string, args?: Record<string, unknown>): string {
|
|
112
|
+
const path = args?.file_path ? shortenPath(String(args.file_path)) : "";
|
|
113
|
+
switch (toolName) {
|
|
114
|
+
case "Read": return `Reading ${path}`;
|
|
115
|
+
case "Edit": return `Editing ${path}`;
|
|
116
|
+
case "Write": return `Writing ${path}`;
|
|
117
|
+
case "Bash": {
|
|
118
|
+
const cmd = String(args?.command ?? "").slice(0, 60);
|
|
119
|
+
return `Running: ${cmd}${String(args?.command ?? "").length > 60 ? "..." : ""}`;
|
|
120
|
+
}
|
|
121
|
+
case "Grep": return `Searching for ${args?.pattern ?? "pattern"}`;
|
|
122
|
+
case "Glob": return `Finding files ${args?.pattern ?? ""}`;
|
|
123
|
+
case "Agent": return "Spawning sub-agent";
|
|
124
|
+
default: return `${toolName}${path ? ` ${path}` : ""}`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
92
128
|
async function executeSubAgent(
|
|
93
129
|
pi: ExtensionAPI,
|
|
94
130
|
prompt: string,
|
|
95
131
|
task: PlanTask,
|
|
96
132
|
config: SupipowersConfig,
|
|
133
|
+
ctx?: NotifyCtx,
|
|
134
|
+
widget?: AgentGridWidget,
|
|
97
135
|
): Promise<SubAgentResult> {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
136
|
+
const { createAgentSession } = pi.pi;
|
|
137
|
+
|
|
138
|
+
const { session } = await createAgentSession({
|
|
139
|
+
cwd: process.cwd(),
|
|
140
|
+
hasUI: false,
|
|
141
|
+
taskDepth: 1,
|
|
142
|
+
parentTaskPrefix: `task-${task.id}`,
|
|
143
|
+
// Prevent sub-agent from re-loading supipowers (causes recursive init error)
|
|
144
|
+
disableExtensionDiscovery: true,
|
|
145
|
+
skills: [],
|
|
146
|
+
promptTemplates: [],
|
|
147
|
+
slashCommands: [],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Track files changed and emit live progress
|
|
151
|
+
const filesChanged = new Set<string>();
|
|
152
|
+
const FILE_TOOLS = new Set(["Edit", "Write", "NotebookEdit"]);
|
|
153
|
+
const pendingToolArgs = new Map<string, Record<string, unknown>>();
|
|
154
|
+
const tag = `Task ${task.id}`;
|
|
155
|
+
let lastThinkingPreview = "";
|
|
156
|
+
const unsubscribe = session.subscribe((event) => {
|
|
157
|
+
if (event.type === "message_update") {
|
|
158
|
+
const msg = event.message as { content?: unknown } | undefined;
|
|
159
|
+
const content = extractTextContent(msg?.content);
|
|
160
|
+
const preview = content.split("\n").filter(Boolean).pop()?.slice(0, 80) ?? "";
|
|
161
|
+
if (preview && preview !== lastThinkingPreview) {
|
|
162
|
+
lastThinkingPreview = preview;
|
|
163
|
+
if (widget) {
|
|
164
|
+
widget.setThinking(task.id, preview);
|
|
165
|
+
} else if (ctx) {
|
|
166
|
+
ctx.ui.notify(`${tag}: thinking — ${preview}`, "info");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (event.type === "tool_execution_start") {
|
|
171
|
+
const args = event.args as Record<string, unknown> | undefined;
|
|
172
|
+
if (FILE_TOOLS.has(event.toolName) && args?.file_path) {
|
|
173
|
+
pendingToolArgs.set(event.toolCallId, args);
|
|
174
|
+
}
|
|
175
|
+
if (widget) {
|
|
176
|
+
widget.addActivity(task.id, formatToolAction(event.toolName, args));
|
|
177
|
+
} else if (ctx) {
|
|
178
|
+
ctx.ui.notify(`${tag}: ${formatToolAction(event.toolName, args)}`, "info");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (event.type === "tool_execution_end") {
|
|
182
|
+
const args = pendingToolArgs.get(event.toolCallId);
|
|
183
|
+
if (args?.file_path && !event.isError) {
|
|
184
|
+
filesChanged.add(String(args.file_path));
|
|
185
|
+
widget?.addFileChanged(task.id);
|
|
186
|
+
}
|
|
187
|
+
pendingToolArgs.delete(event.toolCallId);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
await session.prompt(prompt, { expandPromptTemplates: false });
|
|
193
|
+
|
|
194
|
+
// Extract the last assistant message
|
|
195
|
+
const messages = session.state.messages;
|
|
196
|
+
const lastAssistant = [...messages]
|
|
197
|
+
.reverse()
|
|
198
|
+
.find((m) => m.role === "assistant");
|
|
199
|
+
|
|
200
|
+
const lastMsg = lastAssistant as { content?: unknown } | undefined;
|
|
201
|
+
const output = extractTextContent(lastMsg?.content);
|
|
202
|
+
const status = parseAgentStatus(output);
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
status,
|
|
206
|
+
output,
|
|
207
|
+
concerns: status === "done_with_concerns"
|
|
208
|
+
? extractConcerns(output)
|
|
209
|
+
: undefined,
|
|
210
|
+
filesChanged: [...filesChanged],
|
|
211
|
+
};
|
|
212
|
+
} finally {
|
|
213
|
+
unsubscribe();
|
|
214
|
+
await session.dispose();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function extractTextContent(content: unknown): string {
|
|
219
|
+
if (!content) return "";
|
|
220
|
+
if (typeof content === "string") return content;
|
|
221
|
+
if (Array.isArray(content)) {
|
|
222
|
+
return content
|
|
223
|
+
.filter((block: { type?: string }) => block.type === "text")
|
|
224
|
+
.map((block: { text?: string }) => block.text ?? "")
|
|
225
|
+
.join("\n");
|
|
226
|
+
}
|
|
227
|
+
return String(content);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseAgentStatus(output: string): AgentStatus {
|
|
231
|
+
// Look for structured "**Status:** X" or "Status: X" patterns to avoid false positives
|
|
232
|
+
const statusMatch = output.match(/\*?\*?status\*?\*?:\s*(BLOCKED|NEEDS_CONTEXT|DONE_WITH_CONCERNS|DONE)/i);
|
|
233
|
+
if (statusMatch) {
|
|
234
|
+
const val = statusMatch[1].toUpperCase();
|
|
235
|
+
if (val === "BLOCKED" || val === "NEEDS_CONTEXT") return "blocked";
|
|
236
|
+
if (val === "DONE_WITH_CONCERNS") return "done_with_concerns";
|
|
237
|
+
return "done";
|
|
238
|
+
}
|
|
239
|
+
// Default: if agent completed without explicit status, treat as done
|
|
240
|
+
return "done";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function extractConcerns(output: string): string {
|
|
244
|
+
const match = output.match(/(?:concerns?|issues?|worries):\s*(.+?)(?:\n\n|$)/is);
|
|
245
|
+
return match?.[1]?.trim() ?? "";
|
|
102
246
|
}
|
|
103
247
|
|
|
104
248
|
/** Review result from a spec compliance or code quality reviewer */
|
|
@@ -131,6 +275,7 @@ export async function dispatchAgentWithReview(
|
|
|
131
275
|
}
|
|
132
276
|
|
|
133
277
|
// Step 2: Spec compliance review
|
|
278
|
+
options.widget?.setStatus(task.id, "reviewing");
|
|
134
279
|
for (let attempt = 0; attempt <= maxReviewRetries; attempt++) {
|
|
135
280
|
const specReview = await dispatchSpecReview(
|
|
136
281
|
pi,
|
|
@@ -174,6 +319,7 @@ export async function dispatchAgentWithReview(
|
|
|
174
319
|
}
|
|
175
320
|
|
|
176
321
|
// Step 3: Code quality review
|
|
322
|
+
options.widget?.setStatus(task.id, "reviewing");
|
|
177
323
|
for (let attempt = 0; attempt <= maxReviewRetries; attempt++) {
|
|
178
324
|
const qualityReview = await dispatchQualityReview(
|
|
179
325
|
pi,
|
|
@@ -276,7 +422,7 @@ async function dispatchQualityReview(
|
|
|
276
422
|
export async function dispatchFixAgent(
|
|
277
423
|
options: DispatchOptions & { previousOutput: string; failureReason: string },
|
|
278
424
|
): Promise<AgentResult> {
|
|
279
|
-
const { pi, ctx, task, config, lspAvailable, contextModeAvailable, previousOutput, failureReason } =
|
|
425
|
+
const { pi, ctx, task, config, lspAvailable, contextModeAvailable, previousOutput, failureReason, widget } =
|
|
280
426
|
options;
|
|
281
427
|
const startTime = Date.now();
|
|
282
428
|
|
|
@@ -288,8 +434,10 @@ export async function dispatchFixAgent(
|
|
|
288
434
|
contextModeAvailable,
|
|
289
435
|
);
|
|
290
436
|
|
|
437
|
+
widget?.setStatus(task.id, "running");
|
|
438
|
+
|
|
291
439
|
try {
|
|
292
|
-
const result = await executeSubAgent(pi, prompt, task, config);
|
|
440
|
+
const result = await executeSubAgent(pi, prompt, task, config, ctx, widget);
|
|
293
441
|
return {
|
|
294
442
|
taskId: task.id,
|
|
295
443
|
status: result.status,
|