supipowers 0.7.10 → 0.8.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "supipowers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "OMP-native workflow extension inspired by supipowers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -38,7 +38,9 @@
|
|
|
38
38
|
"@oh-my-pi/pi-coding-agent": "latest",
|
|
39
39
|
"@oh-my-pi/pi-tui": "latest",
|
|
40
40
|
"@sinclair/typebox": "^0.34.48",
|
|
41
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
41
42
|
"@types/node": "^22.0.0",
|
|
43
|
+
"better-sqlite3": "^12.8.0",
|
|
42
44
|
"typescript": "^5.9.3",
|
|
43
45
|
"vitest": "^4.0.0"
|
|
44
46
|
},
|
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 { RunProgressState, activeRuns } from "../orchestrator/run-progress.js";
|
|
30
31
|
|
|
31
32
|
interface ParsedRunArgs {
|
|
32
33
|
profile?: string;
|
|
@@ -89,7 +90,17 @@ export function registerRunCommand(pi: ExtensionAPI): void {
|
|
|
89
90
|
updateRun(ctx.cwd, manifest);
|
|
90
91
|
manifest = null;
|
|
91
92
|
} else {
|
|
92
|
-
|
|
93
|
+
const completedBatches = manifest.batches.filter((b) => b.status === "completed").length;
|
|
94
|
+
const totalBatches = manifest.batches.length;
|
|
95
|
+
const completedTasks = manifest.batches
|
|
96
|
+
.filter((b) => b.status === "completed")
|
|
97
|
+
.reduce((sum, b) => sum + b.taskIds.length, 0);
|
|
98
|
+
const totalTasks = manifest.batches.reduce((sum, b) => sum + b.taskIds.length, 0);
|
|
99
|
+
notifyInfo(
|
|
100
|
+
ctx,
|
|
101
|
+
`Resuming run: ${manifest.id}`,
|
|
102
|
+
`${completedTasks}/${totalTasks} tasks done, ${completedBatches}/${totalBatches} batches completed`,
|
|
103
|
+
);
|
|
93
104
|
}
|
|
94
105
|
} else {
|
|
95
106
|
// No UI — resume automatically
|
|
@@ -175,6 +186,36 @@ export function registerRunCommand(pi: ExtensionAPI): void {
|
|
|
175
186
|
const lsp = isLspAvailable(pi.getActiveTools());
|
|
176
187
|
const ctxMode = detectContextMode(pi.getActiveTools()).available;
|
|
177
188
|
|
|
189
|
+
// Create shared progress state and send inline progress message
|
|
190
|
+
const progress = new RunProgressState();
|
|
191
|
+
for (const task of plan.tasks) {
|
|
192
|
+
const deps = task.parallelism.type === "sequential" ? task.parallelism.dependsOn : [];
|
|
193
|
+
progress.addTask(task.id, task.name, deps);
|
|
194
|
+
}
|
|
195
|
+
// On resume, mark already-completed tasks
|
|
196
|
+
const existingResults = loadAllAgentResults(ctx.cwd, manifest!.id);
|
|
197
|
+
for (const result of existingResults) {
|
|
198
|
+
if (result.status === "done") {
|
|
199
|
+
progress.setStatus(result.taskId, "done");
|
|
200
|
+
} else if (result.status === "done_with_concerns") {
|
|
201
|
+
progress.setStatus(result.taskId, "done_with_concerns", result.concerns);
|
|
202
|
+
} else if (result.status === "blocked") {
|
|
203
|
+
progress.setStatus(result.taskId, "blocked", result.output);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
activeRuns.set(manifest.id, progress);
|
|
207
|
+
|
|
208
|
+
// Send inline progress message — the registered renderer will display it
|
|
209
|
+
pi.sendMessage(
|
|
210
|
+
{
|
|
211
|
+
customType: "supi-run-progress",
|
|
212
|
+
content: [{ type: "text", text: "Running tasks..." }],
|
|
213
|
+
display: "custom",
|
|
214
|
+
details: { runId: manifest.id },
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
try {
|
|
178
219
|
for (const batch of manifest.batches) {
|
|
179
220
|
if (batch.status === "completed") continue;
|
|
180
221
|
|
|
@@ -186,6 +227,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
|
|
|
186
227
|
`Batch ${batch.index + 1}/${manifest.batches.length}`,
|
|
187
228
|
`${batch.taskIds.length} tasks`
|
|
188
229
|
);
|
|
230
|
+
progress.batchLabel = `Batch ${batch.index + 1}/${manifest.batches.length}`;
|
|
189
231
|
|
|
190
232
|
const batchResults: AgentResult[] = [];
|
|
191
233
|
const agentPromises = batch.taskIds.map((taskId) => {
|
|
@@ -200,6 +242,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
|
|
|
200
242
|
config,
|
|
201
243
|
lspAvailable: lsp,
|
|
202
244
|
contextModeAvailable: ctxMode,
|
|
245
|
+
progress,
|
|
203
246
|
});
|
|
204
247
|
});
|
|
205
248
|
|
|
@@ -236,6 +279,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
|
|
|
236
279
|
config,
|
|
237
280
|
lspAvailable: lsp,
|
|
238
281
|
contextModeAvailable: ctxMode,
|
|
282
|
+
progress,
|
|
239
283
|
previousOutput: failed.output,
|
|
240
284
|
failureReason: failed.output,
|
|
241
285
|
});
|
|
@@ -267,6 +311,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
|
|
|
267
311
|
manifest.completedAt = new Date().toISOString();
|
|
268
312
|
updateRun(ctx.cwd, manifest);
|
|
269
313
|
|
|
314
|
+
|
|
270
315
|
const durationSec = Math.round(runSummary.totalDuration / 1000);
|
|
271
316
|
notifySummary(
|
|
272
317
|
ctx,
|
|
@@ -292,6 +337,9 @@ export function registerRunCommand(pi: ExtensionAPI): void {
|
|
|
292
337
|
);
|
|
293
338
|
notifyInfo(ctx, "Run succeeded", "Follow branch finish instructions to integrate your work");
|
|
294
339
|
}
|
|
340
|
+
} finally {
|
|
341
|
+
activeRuns.delete(manifest.id);
|
|
342
|
+
}
|
|
295
343
|
},
|
|
296
344
|
});
|
|
297
345
|
}
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { registerUpdateCommand, handleUpdate } from "./commands/update.js";
|
|
|
15
15
|
import { registerFixPrCommand } from "./commands/fix-pr.js";
|
|
16
16
|
import { loadConfig } from "./config/loader.js";
|
|
17
17
|
import { registerContextModeHooks } from "./context-mode/hooks.js";
|
|
18
|
+
import { registerProgressRenderer } from "./orchestrator/progress-renderer.js";
|
|
18
19
|
|
|
19
20
|
// TUI-only commands — intercepted at the input level to prevent
|
|
20
21
|
// message submission and "Working..." indicator
|
|
@@ -48,6 +49,9 @@ export default function supipowers(pi: ExtensionAPI): void {
|
|
|
48
49
|
registerUpdateCommand(pi);
|
|
49
50
|
registerFixPrCommand(pi);
|
|
50
51
|
|
|
52
|
+
// Register custom message renderers
|
|
53
|
+
registerProgressRenderer(pi);
|
|
54
|
+
|
|
51
55
|
// Intercept TUI-only commands at the input level — this runs BEFORE
|
|
52
56
|
// message submission, so no chat message appears and no "Working..." indicator
|
|
53
57
|
pi.on("input", (event, ctx) => {
|
|
@@ -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 { RunProgressState } from "./run-progress.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
|
+
progress?: RunProgressState;
|
|
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, progress } = 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
|
+
progress?.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, progress);
|
|
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
|
+
progress?.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
|
+
progress?.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
|
+
progress?.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
|
+
progress?.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,11 +97,41 @@ 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
|
+
progress?: RunProgressState,
|
|
97
135
|
): Promise<SubAgentResult> {
|
|
98
136
|
const { createAgentSession } = pi.pi;
|
|
99
137
|
|
|
@@ -102,22 +140,50 @@ async function executeSubAgent(
|
|
|
102
140
|
hasUI: false,
|
|
103
141
|
taskDepth: 1,
|
|
104
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: [],
|
|
105
148
|
});
|
|
106
149
|
|
|
107
|
-
// Track files changed
|
|
150
|
+
// Track files changed and emit live progress
|
|
108
151
|
const filesChanged = new Set<string>();
|
|
109
152
|
const FILE_TOOLS = new Set(["Edit", "Write", "NotebookEdit"]);
|
|
110
153
|
const pendingToolArgs = new Map<string, Record<string, unknown>>();
|
|
154
|
+
const tag = `Task ${task.id}`;
|
|
155
|
+
let lastThinkingPreview = "";
|
|
111
156
|
const unsubscribe = session.subscribe((event) => {
|
|
112
|
-
if (event.type === "
|
|
113
|
-
|
|
114
|
-
|
|
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 (progress) {
|
|
164
|
+
progress.setActivity(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 (progress) {
|
|
176
|
+
progress.setActivity(task.id, formatToolAction(event.toolName, args));
|
|
177
|
+
progress.incrementTools(task.id);
|
|
178
|
+
} else if (ctx) {
|
|
179
|
+
ctx.ui.notify(`${tag}: ${formatToolAction(event.toolName, args)}`, "info");
|
|
115
180
|
}
|
|
116
181
|
}
|
|
117
|
-
if (event.type === "tool_execution_end"
|
|
182
|
+
if (event.type === "tool_execution_end") {
|
|
118
183
|
const args = pendingToolArgs.get(event.toolCallId);
|
|
119
|
-
if (args?.file_path) {
|
|
184
|
+
if (args?.file_path && !event.isError) {
|
|
120
185
|
filesChanged.add(String(args.file_path));
|
|
186
|
+
progress?.incrementFiles(task.id);
|
|
121
187
|
}
|
|
122
188
|
pendingToolArgs.delete(event.toolCallId);
|
|
123
189
|
}
|
|
@@ -132,7 +198,8 @@ async function executeSubAgent(
|
|
|
132
198
|
.reverse()
|
|
133
199
|
.find((m) => m.role === "assistant");
|
|
134
200
|
|
|
135
|
-
const
|
|
201
|
+
const lastMsg = lastAssistant as { content?: unknown } | undefined;
|
|
202
|
+
const output = extractTextContent(lastMsg?.content);
|
|
136
203
|
const status = parseAgentStatus(output);
|
|
137
204
|
|
|
138
205
|
return {
|
|
@@ -162,15 +229,12 @@ function extractTextContent(content: unknown): string {
|
|
|
162
229
|
}
|
|
163
230
|
|
|
164
231
|
function parseAgentStatus(output: string): AgentStatus {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return "done_with_concerns";
|
|
171
|
-
}
|
|
172
|
-
// "DONE" appears in both "DONE" and "DONE_WITH_CONCERNS", so check after
|
|
173
|
-
if (upper.includes("**STATUS:** DONE") || upper.includes("STATUS: DONE")) {
|
|
232
|
+
// Look for structured "**Status:** X" or "Status: X" patterns to avoid false positives
|
|
233
|
+
const statusMatch = output.match(/\*?\*?status\*?\*?:\s*(BLOCKED|NEEDS_CONTEXT|DONE_WITH_CONCERNS|DONE)/i);
|
|
234
|
+
if (statusMatch) {
|
|
235
|
+
const val = statusMatch[1].toUpperCase();
|
|
236
|
+
if (val === "BLOCKED" || val === "NEEDS_CONTEXT") return "blocked";
|
|
237
|
+
if (val === "DONE_WITH_CONCERNS") return "done_with_concerns";
|
|
174
238
|
return "done";
|
|
175
239
|
}
|
|
176
240
|
// Default: if agent completed without explicit status, treat as done
|
|
@@ -212,6 +276,7 @@ export async function dispatchAgentWithReview(
|
|
|
212
276
|
}
|
|
213
277
|
|
|
214
278
|
// Step 2: Spec compliance review
|
|
279
|
+
options.progress?.setStatus(task.id, "reviewing");
|
|
215
280
|
for (let attempt = 0; attempt <= maxReviewRetries; attempt++) {
|
|
216
281
|
const specReview = await dispatchSpecReview(
|
|
217
282
|
pi,
|
|
@@ -255,6 +320,7 @@ export async function dispatchAgentWithReview(
|
|
|
255
320
|
}
|
|
256
321
|
|
|
257
322
|
// Step 3: Code quality review
|
|
323
|
+
options.progress?.setStatus(task.id, "reviewing");
|
|
258
324
|
for (let attempt = 0; attempt <= maxReviewRetries; attempt++) {
|
|
259
325
|
const qualityReview = await dispatchQualityReview(
|
|
260
326
|
pi,
|
|
@@ -357,7 +423,7 @@ async function dispatchQualityReview(
|
|
|
357
423
|
export async function dispatchFixAgent(
|
|
358
424
|
options: DispatchOptions & { previousOutput: string; failureReason: string },
|
|
359
425
|
): Promise<AgentResult> {
|
|
360
|
-
const { pi, ctx, task, config, lspAvailable, contextModeAvailable, previousOutput, failureReason } =
|
|
426
|
+
const { pi, ctx, task, config, lspAvailable, contextModeAvailable, previousOutput, failureReason, progress } =
|
|
361
427
|
options;
|
|
362
428
|
const startTime = Date.now();
|
|
363
429
|
|
|
@@ -369,8 +435,10 @@ export async function dispatchFixAgent(
|
|
|
369
435
|
contextModeAvailable,
|
|
370
436
|
);
|
|
371
437
|
|
|
438
|
+
progress?.setStatus(task.id, "running");
|
|
439
|
+
|
|
372
440
|
try {
|
|
373
|
-
const result = await executeSubAgent(pi, prompt, task, config);
|
|
441
|
+
const result = await executeSubAgent(pi, prompt, task, config, ctx, progress);
|
|
374
442
|
return {
|
|
375
443
|
taskId: task.id,
|
|
376
444
|
status: result.status,
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// src/orchestrator/progress-renderer.ts
|
|
2
|
+
import type { TaskStatus } from "./agent-grid.js";
|
|
3
|
+
import { activeRuns, type TaskProgress } from "./run-progress.js";
|
|
4
|
+
|
|
5
|
+
// ── Types ──────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
interface Theme {
|
|
8
|
+
fg(color: string, text: string): string;
|
|
9
|
+
bold(text: string): string;
|
|
10
|
+
sep: { dot: string };
|
|
11
|
+
tree: {
|
|
12
|
+
branch: string;
|
|
13
|
+
last: string;
|
|
14
|
+
vertical: string;
|
|
15
|
+
hook: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Component {
|
|
20
|
+
render(width: number): string[];
|
|
21
|
+
invalidate(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CustomMessage<T> {
|
|
25
|
+
details: T;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface RunProgressDetails {
|
|
29
|
+
runId: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type MessageRenderer<T> = (
|
|
33
|
+
message: CustomMessage<T>,
|
|
34
|
+
options: { expanded: boolean },
|
|
35
|
+
theme: Theme,
|
|
36
|
+
) => Component | undefined;
|
|
37
|
+
|
|
38
|
+
interface ExtensionAPI {
|
|
39
|
+
registerMessageRenderer<T>(customType: string, renderer: MessageRenderer<T>): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Constants ──────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
45
|
+
|
|
46
|
+
interface StatusConfig {
|
|
47
|
+
color: string;
|
|
48
|
+
icon: (frame: number) => string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const STATUS_CONFIG: Record<TaskStatus, StatusConfig> = {
|
|
52
|
+
pending: { color: "dim", icon: () => "○" },
|
|
53
|
+
running: { color: "accent", icon: (f) => SPINNER_FRAMES[f % SPINNER_FRAMES.length] },
|
|
54
|
+
reviewing: { color: "warning", icon: () => "◎" },
|
|
55
|
+
done: { color: "success", icon: () => "✓" },
|
|
56
|
+
done_with_concerns: { color: "warning", icon: () => "⚠" },
|
|
57
|
+
blocked: { color: "error", icon: () => "✗" },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ── Helper: format elapsed time ────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
function formatElapsed(startedAt: number, completedAt?: number): string {
|
|
63
|
+
const ms = (completedAt ?? Date.now()) - startedAt;
|
|
64
|
+
const s = Math.floor(ms / 1000);
|
|
65
|
+
if (s < 60) return `${s}s`;
|
|
66
|
+
const m = Math.floor(s / 60);
|
|
67
|
+
const rem = s % 60;
|
|
68
|
+
return rem > 0 ? `${m}m${rem}s` : `${m}m`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Inline Progress Component ──────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
class InlineProgressComponent implements Component {
|
|
74
|
+
#runId: string;
|
|
75
|
+
#theme: Theme;
|
|
76
|
+
#spinnerFrame = 0;
|
|
77
|
+
|
|
78
|
+
constructor(runId: string, theme: Theme) {
|
|
79
|
+
this.#runId = runId;
|
|
80
|
+
this.#theme = theme;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
render(_width: number): string[] {
|
|
84
|
+
// Advance spinner on every render call (called each repaint)
|
|
85
|
+
this.#spinnerFrame = (this.#spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
86
|
+
|
|
87
|
+
const state = activeRuns.get(this.#runId);
|
|
88
|
+
if (!state || state.tasks.size === 0) return [];
|
|
89
|
+
|
|
90
|
+
const tasks = [...state.tasks.values()];
|
|
91
|
+
const lines: string[] = [];
|
|
92
|
+
const tree = this.#theme.tree;
|
|
93
|
+
const sep = this.#theme.sep.dot;
|
|
94
|
+
|
|
95
|
+
tasks.forEach((task, idx) => {
|
|
96
|
+
const isLast = idx === tasks.length - 1;
|
|
97
|
+
const prefix = isLast ? tree.last : tree.branch;
|
|
98
|
+
const cfg = STATUS_CONFIG[task.status];
|
|
99
|
+
const icon = this.#theme.fg(cfg.color, cfg.icon(this.#spinnerFrame));
|
|
100
|
+
const taskLabel = this.#theme.fg(cfg.color, `T${task.taskId}`);
|
|
101
|
+
const name = this.#theme.bold(task.name);
|
|
102
|
+
|
|
103
|
+
const parts: string[] = [`${prefix} ${icon} ${taskLabel} ${sep} ${name}`];
|
|
104
|
+
this.#appendTaskMeta(task, parts, sep);
|
|
105
|
+
|
|
106
|
+
// Show dependencies for pending tasks
|
|
107
|
+
if (task.status === "pending" && task.dependsOn.length > 0) {
|
|
108
|
+
const depLabels = task.dependsOn.map((d) => `T${d}`).join(", ");
|
|
109
|
+
parts.push(` ${sep} `);
|
|
110
|
+
parts.push(this.#theme.fg("dim", `Depends on ${depLabels}`));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
lines.push(parts.join(""));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Summary line
|
|
117
|
+
const summary = state.summary;
|
|
118
|
+
const summaryParts: string[] = [];
|
|
119
|
+
if (state.batchLabel) summaryParts.push(this.#theme.fg("muted", state.batchLabel));
|
|
120
|
+
if (summary.done > 0) summaryParts.push(this.#theme.fg("success", `${summary.done} done`));
|
|
121
|
+
if (summary.running > 0) summaryParts.push(this.#theme.fg("accent", `${summary.running} running`));
|
|
122
|
+
if (summary.pending > 0) summaryParts.push(this.#theme.fg("dim", `${summary.pending} pending`));
|
|
123
|
+
if (summary.blocked > 0) summaryParts.push(this.#theme.fg("error", `${summary.blocked} blocked`));
|
|
124
|
+
|
|
125
|
+
if (summaryParts.length > 0) {
|
|
126
|
+
const indent = ` `; // align under tree
|
|
127
|
+
lines.push(`${indent}${summaryParts.join(` ${sep} `)}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return lines;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
invalidate(): void {
|
|
134
|
+
// No cache to bust — we render fresh each time
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Private ────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
#appendTaskMeta(task: TaskProgress, parts: string[], sep: string): void {
|
|
140
|
+
const isActive = task.status === "running" || task.status === "reviewing";
|
|
141
|
+
const isDone =
|
|
142
|
+
task.status === "done" ||
|
|
143
|
+
task.status === "done_with_concerns" ||
|
|
144
|
+
task.status === "blocked";
|
|
145
|
+
|
|
146
|
+
if (task.toolCount > 0) {
|
|
147
|
+
parts.push(` ${sep} `);
|
|
148
|
+
parts.push(this.#theme.fg("muted", `${task.toolCount} tools`));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (isDone && task.filesChanged > 0) {
|
|
152
|
+
parts.push(` ${sep} `);
|
|
153
|
+
parts.push(this.#theme.fg("muted", `${task.filesChanged} files`));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (isDone) {
|
|
157
|
+
const elapsed = formatElapsed(task.startedAt, task.completedAt);
|
|
158
|
+
parts.push(` ${sep} `);
|
|
159
|
+
parts.push(this.#theme.fg("muted", elapsed));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (isActive && task.currentActivity) {
|
|
163
|
+
parts.push(` ${sep} `);
|
|
164
|
+
parts.push(this.#theme.fg("dim", task.currentActivity));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Registration ───────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
export function registerProgressRenderer(pi: ExtensionAPI): void {
|
|
172
|
+
pi.registerMessageRenderer<RunProgressDetails>(
|
|
173
|
+
"supi-run-progress",
|
|
174
|
+
(message, _options, theme) => {
|
|
175
|
+
const { runId } = message.details;
|
|
176
|
+
if (!runId) return undefined;
|
|
177
|
+
return new InlineProgressComponent(runId, theme);
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// src/orchestrator/run-progress.ts
|
|
2
|
+
import type { TaskStatus } from "./agent-grid.js";
|
|
3
|
+
|
|
4
|
+
export interface TaskProgress {
|
|
5
|
+
taskId: number;
|
|
6
|
+
name: string;
|
|
7
|
+
status: TaskStatus;
|
|
8
|
+
currentActivity: string;
|
|
9
|
+
toolCount: number;
|
|
10
|
+
filesChanged: number;
|
|
11
|
+
startedAt: number;
|
|
12
|
+
completedAt?: number;
|
|
13
|
+
errorReason?: string;
|
|
14
|
+
concerns?: string;
|
|
15
|
+
dependsOn: number[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Shared state store for a single run — written by dispatcher, read by renderer */
|
|
19
|
+
export class RunProgressState {
|
|
20
|
+
readonly tasks = new Map<number, TaskProgress>();
|
|
21
|
+
batchLabel = "";
|
|
22
|
+
|
|
23
|
+
addTask(taskId: number, name: string, dependsOn: number[] = []): void {
|
|
24
|
+
this.tasks.set(taskId, {
|
|
25
|
+
taskId,
|
|
26
|
+
name,
|
|
27
|
+
status: "pending",
|
|
28
|
+
currentActivity: "",
|
|
29
|
+
toolCount: 0,
|
|
30
|
+
filesChanged: 0,
|
|
31
|
+
startedAt: Date.now(),
|
|
32
|
+
dependsOn,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setStatus(taskId: number, status: TaskStatus, reason?: string): void {
|
|
37
|
+
const task = this.tasks.get(taskId);
|
|
38
|
+
if (!task) return;
|
|
39
|
+
task.status = status;
|
|
40
|
+
if (status === "blocked") task.errorReason = reason;
|
|
41
|
+
if (status === "done_with_concerns") task.concerns = reason;
|
|
42
|
+
if (status === "done" || status === "done_with_concerns" || status === "blocked") {
|
|
43
|
+
task.completedAt = Date.now();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setActivity(taskId: number, activity: string): void {
|
|
48
|
+
const task = this.tasks.get(taskId);
|
|
49
|
+
if (!task) return;
|
|
50
|
+
task.currentActivity = activity;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
incrementTools(taskId: number): void {
|
|
54
|
+
const task = this.tasks.get(taskId);
|
|
55
|
+
if (!task) return;
|
|
56
|
+
task.toolCount++;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
incrementFiles(taskId: number): void {
|
|
60
|
+
const task = this.tasks.get(taskId);
|
|
61
|
+
if (!task) return;
|
|
62
|
+
task.filesChanged++;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get summary() {
|
|
66
|
+
const all = [...this.tasks.values()];
|
|
67
|
+
return {
|
|
68
|
+
total: all.length,
|
|
69
|
+
done: all.filter((t) => t.status === "done" || t.status === "done_with_concerns").length,
|
|
70
|
+
running: all.filter((t) => t.status === "running" || t.status === "reviewing").length,
|
|
71
|
+
blocked: all.filter((t) => t.status === "blocked").length,
|
|
72
|
+
pending: all.filter((t) => t.status === "pending").length,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Module-level store: runId → state. Renderer reads from here. */
|
|
78
|
+
export const activeRuns = new Map<string, RunProgressState>();
|