pi-crew 0.10.1 → 0.10.3
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/CHANGELOG.md +347 -0
- package/NOTICE.md +21 -0
- package/README.md +44 -2
- package/agents/executor.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/dist/index.mjs +2555 -932
- package/package.json +2 -1
- package/schema.json +503 -94
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
- package/skills/real-test-pi-crew/SKILL.md +278 -79
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +47 -2
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +43 -11
- package/src/config/env-vars.ts +27 -2
- package/src/config/role-tools.ts +4 -2
- package/src/config/types.ts +55 -1
- package/src/extension/crew-cleanup.ts +13 -0
- package/src/extension/crew-vibes/footer.ts +19 -0
- package/src/extension/crew-vibes/index.ts +11 -1
- package/src/extension/register.ts +8 -0
- package/src/extension/registration/command-registration.ts +1 -0
- package/src/extension/registration/commands/run.ts +15 -1
- package/src/extension/registration/commands/shared.ts +8 -0
- package/src/extension/registration/foreground-run-controller.ts +10 -2
- package/src/extension/registration/lifecycle-handlers.ts +92 -11
- package/src/extension/registration/runtime-cleanup.ts +23 -5
- package/src/extension/registration/team-tool.ts +58 -6
- package/src/extension/registration/ui.ts +5 -4
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +19 -0
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/run-deadline.ts +20 -3
- package/src/extension/team-tool/run.ts +26 -2
- package/src/extension/team-tool/status.ts +7 -0
- package/src/extension/team-tool.ts +35 -2
- package/src/hooks/registry.ts +59 -56
- package/src/prompt/inbox-poll.ts +90 -0
- package/src/prompt/message-tool.ts +166 -0
- package/src/prompt/prompt-runtime.ts +201 -18
- package/src/prompt/surface-worker.ts +720 -0
- package/src/prompt/worker-events-channel.ts +49 -3
- package/src/runtime/async-runner.ts +29 -1
- package/src/runtime/background-runner.ts +13 -7
- package/src/runtime/broker/broker-issuer.ts +27 -2
- package/src/runtime/broker/crew-broker-tokens.ts +56 -4
- package/src/runtime/broker/crew-broker.ts +261 -41
- package/src/runtime/child-pi/child-pi-constants.ts +8 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +30 -2
- package/src/runtime/child-pi/child-pi.ts +353 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/detached-run-results.ts +90 -0
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +4 -0
- package/src/runtime/goal-workflow/adaptive-plan.ts +30 -3
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +5 -1
- package/src/runtime/live-session/live-agent-manager.ts +34 -1
- package/src/runtime/live-session/live-control-realtime.ts +10 -0
- package/src/runtime/live-session/live-session-runtime.ts +47 -27
- package/src/runtime/manifest-cache.ts +128 -17
- package/src/runtime/merge-gate.ts +25 -9
- package/src/runtime/model/model-fallback.ts +7 -3
- package/src/runtime/model/pi-args.ts +54 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/process-status.ts +16 -1
- package/src/runtime/recovery/crash-recovery.ts +16 -0
- package/src/runtime/run-tracker.ts +77 -10
- package/src/runtime/spawn-policy.ts +27 -41
- package/src/runtime/surface/degrade.ts +776 -0
- package/src/runtime/surface/herdr-provider.ts +546 -0
- package/src/runtime/surface/launch-script.ts +172 -0
- package/src/runtime/surface/resolve-surface.ts +274 -0
- package/src/runtime/surface/surface-provider.ts +129 -0
- package/src/runtime/surface/surface-spawn.ts +475 -0
- package/src/runtime/surface/tmux-provider.ts +400 -0
- package/src/runtime/task-runner/child-executor.ts +47 -0
- package/src/runtime/task-runner/post-execution.ts +57 -2
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
- package/src/runtime/task-runner/state-helpers.ts +54 -30
- package/src/runtime/task-runner.ts +4 -2
- package/src/runtime/team-runner.ts +104 -2
- package/src/schema/config-schema.ts +25 -1
- package/src/state/atomic-write.ts +219 -40
- package/src/state/coordination/locks.ts +7 -5
- package/src/state/coordination/mailbox.ts +56 -10
- package/src/state/event-log/cursor.ts +413 -23
- package/src/state/event-log/event-log.ts +120 -113
- package/src/state/event-log/sequence-cache.ts +21 -3
- package/src/state/stores/plan-store.ts +1 -1
- package/src/state/stores/state-store.ts +171 -9
- package/src/state/types.ts +53 -0
- package/src/ui/dock-footer.ts +49 -0
- package/src/ui/inline-panel/agent-pane.ts +378 -0
- package/src/ui/inline-panel/agent-transcript.ts +338 -0
- package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
- package/src/ui/inline-panel/crew-editor.ts +192 -0
- package/src/ui/inline-panel/index.ts +290 -0
- package/src/ui/inline-panel/panel-rows.ts +37 -0
- package/src/ui/inline-panel/panel-selection.ts +157 -0
- package/src/ui/inline-panel/panel-store.ts +111 -0
- package/src/ui/inline-panel/view-session-store.ts +36 -0
- package/src/ui/pi-ui-compat.ts +9 -0
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-dashboard.ts +87 -42
- package/src/ui/run-event-bus.ts +10 -1
- package/src/ui/run-snapshot-cache.ts +83 -35
- package/src/ui/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +203 -25
- package/src/ui/widget/task-list.ts +198 -0
- package/src/ui/widget/widget-formatters.ts +240 -4
- package/src/ui/widget/widget-renderer.ts +234 -37
- package/src/ui/widget/widget-types.ts +11 -0
- package/src/utils/child-process-shield.ts +106 -0
- package/src/utils/redaction.ts +7 -0
- package/src/utils/safe-abort.ts +45 -0
- package/src/utils/visual.ts +43 -0
- package/src/workflows/discover-workflows.ts +1 -0
- package/src/workflows/workflow-config.ts +7 -0
- package/src/worktree/worktree-manager.ts +65 -4
- package/workflows/default.workflow.md +36 -26
- package/workflows/strict-fast-fix.workflow.md +26 -0
package/src/ui/widget/index.ts
CHANGED
|
@@ -11,6 +11,9 @@ import { DEFAULT_UI } from "../../config/defaults.ts";
|
|
|
11
11
|
import type { ManifestCache } from "../../runtime/manifest-cache.ts";
|
|
12
12
|
import type { TeamRunManifest } from "../../state/types.ts";
|
|
13
13
|
import { truncate } from "../../utils/visual.ts";
|
|
14
|
+
import { isFooterDockSinkActive, setFooterDockProvider } from "../dock-footer.ts";
|
|
15
|
+
import { panelRowsFromRuns } from "../inline-panel/panel-rows.ts";
|
|
16
|
+
import { panelDisplayState, setPanelRowsProvider, subscribePanelChange } from "../inline-panel/panel-store.ts";
|
|
14
17
|
import { requestRender, requestRenderTarget, setExtensionWidget } from "../pi-ui-compat.ts";
|
|
15
18
|
import type { OverlaySchedulerHandle } from "../shared-overlay-scheduler.ts";
|
|
16
19
|
import { registerOverlayScheduler } from "../shared-overlay-scheduler.ts";
|
|
@@ -18,6 +21,7 @@ import type { RunSnapshotCache } from "../snapshot-types.ts";
|
|
|
18
21
|
import { spinnerBucket, spinnerFrame } from "../spinner.ts";
|
|
19
22
|
import type { CrewTheme } from "../theme-adapter.ts";
|
|
20
23
|
import { asCrewTheme, subscribeThemeChange } from "../theme-adapter.ts";
|
|
24
|
+
import { buildTaskListLines } from "./task-list.ts";
|
|
21
25
|
import { activeWidgetRuns, statusSummary } from "./widget-model.ts";
|
|
22
26
|
import { buildWidgetLines, colorWidgetLine, DEFAULT_WIDGET_WIDTH, renderLines } from "./widget-renderer.ts";
|
|
23
27
|
import type { CrewWidgetModel, CrewWidgetState, WidgetRun } from "./widget-types.ts";
|
|
@@ -62,6 +66,8 @@ export {
|
|
|
62
66
|
const MAX_LINES_DEFAULT = DEFAULT_UI.widgetMaxLines;
|
|
63
67
|
const LEGACY_WIDGET_KEY = "pi-crew";
|
|
64
68
|
const WIDGET_KEY = "pi-crew-active";
|
|
69
|
+
/** The run's plan progress, painted ABOVE the editor (task-list.ts). */
|
|
70
|
+
const TASKS_WIDGET_KEY = "pi-crew-tasks";
|
|
65
71
|
const STATUS_KEY = "pi-crew";
|
|
66
72
|
|
|
67
73
|
/**
|
|
@@ -78,11 +84,12 @@ const SIGNATURE_CACHE_TTL_MS = 100;
|
|
|
78
84
|
// next invalidate; a mid-run resize could briefly paint a frame at the old
|
|
79
85
|
// width. We register ONE debounced process-level listener (guarded so it never
|
|
80
86
|
// accumulates across widget reinstalls) that busts the active widget's cache
|
|
81
|
-
// and pokes Pi to repaint at the new width. The listener references
|
|
82
|
-
// module-level `
|
|
83
|
-
//
|
|
87
|
+
// and pokes Pi to repaint at the new width. The listener references the
|
|
88
|
+
// module-level `activeResizeTargets` set (every mounted widget) rather than
|
|
89
|
+
// specific instances, so replaced widgets do not leak listeners.
|
|
84
90
|
let resizeListenerInstalled = false;
|
|
85
|
-
|
|
91
|
+
/** All mounted widgets (dock + task list) — every one gets resize-busted. */
|
|
92
|
+
const activeResizeTargets = new Set<{ invalidate(): void; requestRepaint(): void }>();
|
|
86
93
|
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
87
94
|
|
|
88
95
|
/**
|
|
@@ -95,8 +102,10 @@ const onResize = (): void => {
|
|
|
95
102
|
// Debounce (~120ms) so a drag-resize doesn't thrash renders.
|
|
96
103
|
resizeTimer = setTimeout(() => {
|
|
97
104
|
resizeTimer = undefined;
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
for (const target of activeResizeTargets) {
|
|
106
|
+
target.invalidate();
|
|
107
|
+
target.requestRepaint();
|
|
108
|
+
}
|
|
100
109
|
}, 120);
|
|
101
110
|
};
|
|
102
111
|
|
|
@@ -134,7 +143,7 @@ export function uninstallResizeListener(): void {
|
|
|
134
143
|
clearTimeout(resizeTimer);
|
|
135
144
|
resizeTimer = undefined;
|
|
136
145
|
}
|
|
137
|
-
|
|
146
|
+
activeResizeTargets.clear();
|
|
138
147
|
process.off("SIGWINCH", onResize);
|
|
139
148
|
// Windows has no SIGWINCH; Node emits "resize" on stdout instead.
|
|
140
149
|
// Remove via whichever API is present. The listener was only registered
|
|
@@ -159,6 +168,8 @@ interface WidgetComponent extends CrewComponent {}
|
|
|
159
168
|
class CrewWidgetComponent implements WidgetComponent {
|
|
160
169
|
private readonly model: CrewWidgetModel;
|
|
161
170
|
private theme: CrewTheme;
|
|
171
|
+
/** Which surface this instance paints: the agent dock, or the task list. */
|
|
172
|
+
private readonly variant: "dock" | "tasks";
|
|
162
173
|
private cacheSignature = "";
|
|
163
174
|
/** C4 — invalidate-on-write cache for the buildSignature() result. */
|
|
164
175
|
private cachedBuildSignature = "";
|
|
@@ -166,13 +177,23 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
166
177
|
private cachedWidth = 0;
|
|
167
178
|
private cachedLines: string[] = [];
|
|
168
179
|
private cachedBaseLines: string[] = [];
|
|
180
|
+
/**
|
|
181
|
+
* PERF (2026-08-24): truncate at build time, re-truncate only on width
|
|
182
|
+
* change — cache hits returned a fresh array + ANSI-aware width measurement
|
|
183
|
+
* per line on every frame. `cachedLines` stays raw (unclipped) so a width
|
|
184
|
+
* change can re-clip without a full rebuild; -1 marks "not truncated yet".
|
|
185
|
+
*/
|
|
186
|
+
private truncatedLines: string[] = [];
|
|
187
|
+
private lastTruncateWidth = -1;
|
|
169
188
|
private cachedTheme: CrewTheme;
|
|
170
189
|
private readonly tui: unknown;
|
|
171
190
|
private readonly unsubscribeTheme: () => void;
|
|
191
|
+
private readonly unsubscribePanel: () => void;
|
|
172
192
|
private readonly schedulerHandle: OverlaySchedulerHandle;
|
|
173
193
|
|
|
174
|
-
constructor(model: CrewWidgetModel, themeLike: unknown, tui?: unknown) {
|
|
194
|
+
constructor(model: CrewWidgetModel, themeLike: unknown, tui?: unknown, variant: "dock" | "tasks" = "dock") {
|
|
175
195
|
this.model = model;
|
|
196
|
+
this.variant = variant;
|
|
176
197
|
this.theme = asCrewTheme(themeLike);
|
|
177
198
|
this.cachedTheme = this.theme;
|
|
178
199
|
this.tui = tui;
|
|
@@ -180,9 +201,16 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
180
201
|
// terminal-resize listener is installed. On a resize the cached width
|
|
181
202
|
// goes stale; busting the cache + requesting a repaint refreshes the
|
|
182
203
|
// widget at the new width without waiting for the next event tick (T-2).
|
|
183
|
-
|
|
204
|
+
activeResizeTargets.add(this);
|
|
184
205
|
installResizeListener();
|
|
185
206
|
this.unsubscribeTheme = subscribeThemeChange(themeLike, () => this.invalidate());
|
|
207
|
+
// Cursor movement is a keypress, not a run event, so it never reaches the
|
|
208
|
+
// shared scheduler. Repaint directly instead of waiting for the next host
|
|
209
|
+
// tick — a lagging cursor reads as a dropped keystroke.
|
|
210
|
+
this.unsubscribePanel = subscribePanelChange(() => {
|
|
211
|
+
this.invalidate();
|
|
212
|
+
this.requestRepaint();
|
|
213
|
+
});
|
|
186
214
|
// 1.10 (UI-P1-1): route run:state / worker:lifecycle / ui:invalidate
|
|
187
215
|
// through a RenderScheduler (debounce + fallback) instead of three
|
|
188
216
|
// direct runEventBus.onChannel subscriptions. With 3 overlays
|
|
@@ -254,10 +282,23 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
254
282
|
this.cacheSignature = "";
|
|
255
283
|
this.cachedBaseLines = [];
|
|
256
284
|
this.cachedLines = [];
|
|
285
|
+
this.truncatedLines = [];
|
|
286
|
+
this.lastTruncateWidth = -1;
|
|
257
287
|
this.cachedBuildSignature = "";
|
|
258
288
|
this.cachedBuildSignatureAt = 0;
|
|
259
289
|
}
|
|
260
290
|
|
|
291
|
+
/**
|
|
292
|
+
* PERF (2026-08-24): clip `cachedLines` to `width` only when the width
|
|
293
|
+
* changed since the last clip — the raw lines are stored unclipped so a
|
|
294
|
+
* resize re-clips them without rebuilding the whole cache.
|
|
295
|
+
*/
|
|
296
|
+
private ensureTruncated(width: number): void {
|
|
297
|
+
if (width === this.lastTruncateWidth) return;
|
|
298
|
+
this.truncatedLines = this.cachedLines.map((line) => truncate(line, width));
|
|
299
|
+
this.lastTruncateWidth = width;
|
|
300
|
+
}
|
|
301
|
+
|
|
261
302
|
/** Poke the host TUI to repaint immediately (defensive: no-op if unavailable). */
|
|
262
303
|
requestRepaint(): void {
|
|
263
304
|
requestRenderTarget(this.tui);
|
|
@@ -265,8 +306,9 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
265
306
|
|
|
266
307
|
dispose(): void {
|
|
267
308
|
this.unsubscribeTheme();
|
|
309
|
+
this.unsubscribePanel();
|
|
268
310
|
this.schedulerHandle.dispose();
|
|
269
|
-
|
|
311
|
+
activeResizeTargets.delete(this);
|
|
270
312
|
}
|
|
271
313
|
|
|
272
314
|
render(width: number): string[] {
|
|
@@ -295,7 +337,37 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
295
337
|
const signature = `${sigBase}:${this.model.notificationCount ?? 0}`;
|
|
296
338
|
const runningGlyph = spinnerFrame("widget-header");
|
|
297
339
|
|
|
298
|
-
|
|
340
|
+
// Task-list variant: the run's plan above the editor (task-list.ts).
|
|
341
|
+
// No panel state, no spinner glyph — the list changes only on task
|
|
342
|
+
// transitions, which the run signature already covers.
|
|
343
|
+
if (this.variant === "tasks") {
|
|
344
|
+
if (this.cacheSignature !== signature || width !== this.cachedWidth || this.cachedTheme !== this.theme) {
|
|
345
|
+
this.cachedBaseLines = buildTaskListLines(runs, width);
|
|
346
|
+
this.cachedLines = this.colorize(this.cachedBaseLines, width);
|
|
347
|
+
this.cachedWidth = width;
|
|
348
|
+
this.cachedTheme = this.theme;
|
|
349
|
+
this.cacheSignature = signature;
|
|
350
|
+
this.lastTruncateWidth = -1;
|
|
351
|
+
}
|
|
352
|
+
if (runs.length === 0) {
|
|
353
|
+
this.invalidate();
|
|
354
|
+
return [];
|
|
355
|
+
}
|
|
356
|
+
this.ensureTruncated(width);
|
|
357
|
+
return this.truncatedLines;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Panel cursor/pane state is part of the rendered output, so it belongs in
|
|
361
|
+
// the cache key — otherwise moving the cursor would not repaint.
|
|
362
|
+
const panel = panelDisplayState();
|
|
363
|
+
const signatureWithPanel = `${signature}|panel:${panel.selectedTaskId ?? ""}/${panel.viewedTaskId ?? ""}/${panel.focused ? 1 : 0}`;
|
|
364
|
+
|
|
365
|
+
// The spinner-frame swap only belongs on the LEGACY header, whose line 0
|
|
366
|
+
// already starts with a glyph position (`<frame> Crew agents …`). The
|
|
367
|
+
// compact dock's line 0 is the HINT text ("agents (N) — ↓ to select"):
|
|
368
|
+
// swapping would visibly eat its first character on every frame.
|
|
369
|
+
const compactDock = this.model.rowStyle === "compact";
|
|
370
|
+
if (this.cacheSignature !== signatureWithPanel || width !== this.cachedWidth || this.cachedTheme !== this.theme) {
|
|
299
371
|
this.cachedBaseLines = buildWidgetLines(
|
|
300
372
|
this.model.cwd,
|
|
301
373
|
0,
|
|
@@ -303,14 +375,16 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
303
375
|
runs,
|
|
304
376
|
this.model.notificationCount ?? 0,
|
|
305
377
|
width,
|
|
378
|
+
{ rowStyle: this.model.rowStyle, ...panel },
|
|
306
379
|
).map((line, index) => {
|
|
307
|
-
if (index === 0 && line.length > 0) return `${runningGlyph}${line.slice(1)}`;
|
|
380
|
+
if (!compactDock && index === 0 && line.length > 0) return `${runningGlyph}${line.slice(1)}`;
|
|
308
381
|
return line;
|
|
309
382
|
});
|
|
310
383
|
this.cachedLines = this.colorize(this.cachedBaseLines, width);
|
|
311
384
|
this.cachedWidth = width;
|
|
312
385
|
this.cachedTheme = this.theme;
|
|
313
|
-
this.cacheSignature =
|
|
386
|
+
this.cacheSignature = signatureWithPanel;
|
|
387
|
+
this.lastTruncateWidth = -1;
|
|
314
388
|
}
|
|
315
389
|
|
|
316
390
|
if (runs.length === 0) {
|
|
@@ -323,9 +397,42 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
323
397
|
return [];
|
|
324
398
|
}
|
|
325
399
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
400
|
+
this.ensureTruncated(width);
|
|
401
|
+
if (!compactDock) {
|
|
402
|
+
const updatedHeader = `${runningGlyph}${this.cachedBaseLines[0]?.slice(1) ?? ""}`;
|
|
403
|
+
this.cachedLines[0] = truncate(colorWidgetLine(updatedHeader, 0, this.theme), width);
|
|
404
|
+
this.truncatedLines[0] = truncate(this.cachedLines[0], width);
|
|
405
|
+
}
|
|
406
|
+
return this.truncatedLines;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Footer dock host (widgetPlacement: "bottom") ──────────────────────
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Dock host for `widgetPlacement: "bottom"`. Keeps a single CrewWidgetComponent
|
|
414
|
+
* (theme-less → raw lines, no ANSI) per session and feeds its render output to
|
|
415
|
+
* the crew-vibes footer through the dock-footer registry. The footer colors the
|
|
416
|
+
* lines with ITS OWN theme so the dock matches the footer context. All caching,
|
|
417
|
+
* event wiring (panel changes, run events, resize) lives in the wrapped
|
|
418
|
+
* component; the footer is re-rendered by pi on every host repaint.
|
|
419
|
+
*/
|
|
420
|
+
class FooterDockHost {
|
|
421
|
+
private component: CrewWidgetComponent | undefined;
|
|
422
|
+
private readonly model: CrewWidgetModel;
|
|
423
|
+
|
|
424
|
+
constructor(model: CrewWidgetModel) {
|
|
425
|
+
this.model = model;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
render(width: number): string[] {
|
|
429
|
+
if (!this.component) this.component = new CrewWidgetComponent(this.model, undefined, undefined);
|
|
430
|
+
return this.component.render(width);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
dispose(): void {
|
|
434
|
+
this.component?.dispose();
|
|
435
|
+
this.component = undefined;
|
|
329
436
|
}
|
|
330
437
|
}
|
|
331
438
|
|
|
@@ -355,20 +462,37 @@ export function updateCrewWidget(
|
|
|
355
462
|
}
|
|
356
463
|
|
|
357
464
|
const runs = activeWidgetRuns(ctx.cwd, manifestCache, snapshotCache, preloadedManifests, workspaceId);
|
|
358
|
-
const
|
|
465
|
+
const rowStyle = config?.widgetRowStyle ?? DEFAULT_UI.widgetRowStyle;
|
|
466
|
+
// The inline panel navigates the same run list the widget paints, and this is
|
|
467
|
+
// the only place already holding the manifest/snapshot caches — so the row
|
|
468
|
+
// projection is registered here instead of re-reading state on every keypress.
|
|
469
|
+
setPanelRowsProvider(() => panelRowsFromRuns(activeWidgetRuns(ctx.cwd, manifestCache, snapshotCache, preloadedManifests, workspaceId)));
|
|
359
470
|
const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
|
|
471
|
+
// `bottom` is not a pi widget slot: the dock then renders inside the
|
|
472
|
+
// crew-vibes footer (dock-footer registry). pi's slot calls always use a
|
|
473
|
+
// real slot so legacy-clear/installs stay on maps pi understands.
|
|
474
|
+
const bottomMode = placement === "bottom";
|
|
475
|
+
const dockInFooter = bottomMode && isFooterDockSinkActive();
|
|
476
|
+
const piPlacement: "aboveEditor" | "belowEditor" = bottomMode ? "belowEditor" : placement;
|
|
360
477
|
|
|
361
|
-
|
|
478
|
+
// PERF (2026-08-24): the persistent CrewWidgetComponent renders itself from
|
|
479
|
+
// state.frame; the built line set was only ever used for its .length.
|
|
480
|
+
ctx.ui.setStatus(STATUS_KEY, runs.length > 0 ? statusSummary(runs) : undefined);
|
|
362
481
|
|
|
363
482
|
const shouldClearLegacy = state.legacyCleared !== true || state.lastPlacement !== placement;
|
|
364
483
|
if (shouldClearLegacy) {
|
|
365
|
-
setExtensionWidget(ctx, LEGACY_WIDGET_KEY, undefined, { placement });
|
|
484
|
+
setExtensionWidget(ctx, LEGACY_WIDGET_KEY, undefined, { placement: piPlacement });
|
|
366
485
|
state.legacyCleared = true;
|
|
367
486
|
}
|
|
368
487
|
|
|
369
|
-
if (
|
|
488
|
+
if (runs.length === 0) {
|
|
370
489
|
if (state.lastVisibility !== "hidden" || state.lastPlacement !== placement) {
|
|
371
|
-
setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement });
|
|
490
|
+
setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement: piPlacement });
|
|
491
|
+
setExtensionWidget(ctx, TASKS_WIDGET_KEY, undefined, { placement: "aboveEditor" });
|
|
492
|
+
state.lastTasksVisibility = "hidden";
|
|
493
|
+
state.footerDock?.dispose();
|
|
494
|
+
state.footerDock = undefined;
|
|
495
|
+
setFooterDockProvider(undefined);
|
|
372
496
|
state.lastVisibility = "hidden";
|
|
373
497
|
state.lastPlacement = placement;
|
|
374
498
|
state.lastKey = WIDGET_KEY;
|
|
@@ -398,6 +522,7 @@ export function updateCrewWidget(
|
|
|
398
522
|
snapshotCache,
|
|
399
523
|
preloadManifests: preloadedManifests,
|
|
400
524
|
workspaceId,
|
|
525
|
+
rowStyle,
|
|
401
526
|
};
|
|
402
527
|
else {
|
|
403
528
|
state.model.cwd = ctx.cwd;
|
|
@@ -408,12 +533,33 @@ export function updateCrewWidget(
|
|
|
408
533
|
state.model.snapshotCache = snapshotCache;
|
|
409
534
|
state.model.preloadManifests = preloadedManifests;
|
|
410
535
|
state.model.workspaceId = workspaceId;
|
|
536
|
+
state.model.rowStyle = rowStyle;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (dockInFooter) {
|
|
540
|
+
// Keep pi's widget slot free: the crew-vibes footer paints the dock at
|
|
541
|
+
// the very bottom, below the quota/meter lines. A widget-slot install
|
|
542
|
+
// from a PREVIOUS placement (or a sink that was just enabled) must be
|
|
543
|
+
// removed first.
|
|
544
|
+
if (needsWidgetInstall && state.lastKey === WIDGET_KEY) {
|
|
545
|
+
setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement: piPlacement });
|
|
546
|
+
}
|
|
547
|
+
if (!state.footerDock) state.footerDock = new FooterDockHost(state.model);
|
|
548
|
+
setFooterDockProvider((width) => state.footerDock!.render(width));
|
|
549
|
+
} else {
|
|
550
|
+
// Widget-slot path (aboveEditor/belowEditor, or no footer sink for
|
|
551
|
+
// "bottom"): ensure any stale footer dock is detached first.
|
|
552
|
+
if (state.footerDock) {
|
|
553
|
+
state.footerDock.dispose();
|
|
554
|
+
state.footerDock = undefined;
|
|
555
|
+
}
|
|
556
|
+
setFooterDockProvider(undefined);
|
|
411
557
|
}
|
|
412
558
|
|
|
413
|
-
if (needsWidgetInstall) {
|
|
559
|
+
if (needsWidgetInstall && !dockInFooter) {
|
|
414
560
|
const model = state.model;
|
|
415
561
|
setExtensionWidget(ctx, WIDGET_KEY, ((_tui: unknown, theme: unknown) => new CrewWidgetComponent(model, theme, _tui)) as never, {
|
|
416
|
-
placement,
|
|
562
|
+
placement: piPlacement,
|
|
417
563
|
persist: true,
|
|
418
564
|
});
|
|
419
565
|
state.lastVisibility = "visible";
|
|
@@ -421,6 +567,32 @@ export function updateCrewWidget(
|
|
|
421
567
|
state.lastKey = WIDGET_KEY;
|
|
422
568
|
state.lastMaxLines = maxLines;
|
|
423
569
|
state.lastCwd = ctx.cwd;
|
|
570
|
+
} else if (dockInFooter) {
|
|
571
|
+
state.lastVisibility = "visible";
|
|
572
|
+
state.lastPlacement = placement;
|
|
573
|
+
state.lastKey = WIDGET_KEY;
|
|
574
|
+
state.lastMaxLines = maxLines;
|
|
575
|
+
state.lastCwd = ctx.cwd;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Task list (aboveEditor): the run's plan progress — Claude Code / droid
|
|
579
|
+
// style, independent of the dock's placement (so it also shows when the
|
|
580
|
+
// dock renders in the crew-vibes footer). Installed once while any
|
|
581
|
+
// display-active run exists; the component paints nothing until a run
|
|
582
|
+
// carries a tasks slice.
|
|
583
|
+
const tasksVisible = runs.length > 0 && Boolean(state.model);
|
|
584
|
+
if (tasksVisible && state.lastTasksVisibility !== "visible") {
|
|
585
|
+
const model = state.model;
|
|
586
|
+
setExtensionWidget(
|
|
587
|
+
ctx,
|
|
588
|
+
TASKS_WIDGET_KEY,
|
|
589
|
+
((_tui: unknown, theme: unknown) => new CrewWidgetComponent(model, theme, _tui, "tasks")) as never,
|
|
590
|
+
{ placement: "aboveEditor" },
|
|
591
|
+
);
|
|
592
|
+
state.lastTasksVisibility = "visible";
|
|
593
|
+
} else if (!tasksVisible && state.lastTasksVisibility === "visible") {
|
|
594
|
+
setExtensionWidget(ctx, TASKS_WIDGET_KEY, undefined, { placement: "aboveEditor" });
|
|
595
|
+
state.lastTasksVisibility = "hidden";
|
|
424
596
|
}
|
|
425
597
|
|
|
426
598
|
requestRender(ctx);
|
|
@@ -436,9 +608,15 @@ export function stopCrewWidget(
|
|
|
436
608
|
uninstallResizeListener();
|
|
437
609
|
if (ctx?.hasUI) {
|
|
438
610
|
const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
|
|
611
|
+
const piPlacement: "aboveEditor" | "belowEditor" = placement === "bottom" ? "belowEditor" : placement;
|
|
439
612
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
440
|
-
setExtensionWidget(ctx, LEGACY_WIDGET_KEY, undefined, { placement });
|
|
441
|
-
setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement });
|
|
613
|
+
setExtensionWidget(ctx, LEGACY_WIDGET_KEY, undefined, { placement: piPlacement });
|
|
614
|
+
setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement: piPlacement });
|
|
615
|
+
setExtensionWidget(ctx, TASKS_WIDGET_KEY, undefined, { placement: "aboveEditor" });
|
|
616
|
+
state.lastTasksVisibility = "hidden";
|
|
617
|
+
state.footerDock?.dispose();
|
|
618
|
+
state.footerDock = undefined;
|
|
619
|
+
setFooterDockProvider(undefined);
|
|
442
620
|
state.lastVisibility = "hidden";
|
|
443
621
|
state.lastPlacement = placement;
|
|
444
622
|
state.lastKey = WIDGET_KEY;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-list.ts — the run's plan, painted above the editor (pi-tasks /
|
|
3
|
+
* Claude Code style): a `● N tasks (…)` header, then one row per task in
|
|
4
|
+
* PLAN ORDER with task numbers (#1, #2, …) instead of technical ids.
|
|
5
|
+
* Completed rows are dimmed and struck through; the task actively executing
|
|
6
|
+
* carries a spinner frame with elapsed time and token counts; queued rows
|
|
7
|
+
* name the dependencies they wait on.
|
|
8
|
+
*
|
|
9
|
+
* Example:
|
|
10
|
+
* ```
|
|
11
|
+
* ● 4 tasks (1 done, 1 in progress, 2 open)
|
|
12
|
+
* ✔ #1 Design the flux capacitor
|
|
13
|
+
* ⠙ #2 Wire the overlay scroll (2m 49s · ↑ 4.1k ↓ 1.2k)
|
|
14
|
+
* ◻ #3 Run the suite › blocked by #2
|
|
15
|
+
* ◻ #4 Report the result
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Pure plan surface: which worker/agent/model runs a task is the dock's
|
|
19
|
+
* story below the editor, never the plan's. Pure display — the interactive
|
|
20
|
+
* surface stays that dock. Rows come from the run snapshot's `tasks` slice
|
|
21
|
+
* (plan order), so the list repaints on task transitions only; the running
|
|
22
|
+
* row's spinner/elapsed/token suffix rides the render signature's spinner
|
|
23
|
+
* bucket while any worker is alive.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { TeamTaskState } from "../../state/types.ts";
|
|
27
|
+
import { truncate } from "../../utils/visual.ts";
|
|
28
|
+
import { spinnerFrame } from "../spinner.ts";
|
|
29
|
+
import { shortRunLabel } from "./widget-model.ts";
|
|
30
|
+
import type { WidgetRun } from "./widget-types.ts";
|
|
31
|
+
|
|
32
|
+
/** Task rows painted before the "… and N more" overflow line (pi-tasks
|
|
33
|
+
* defaults its `maxVisible` to 10). */
|
|
34
|
+
const MAX_TASK_ROWS = 10;
|
|
35
|
+
|
|
36
|
+
const ACTIVE_TASK_STATUSES = new Set(["running", "queued", "waiting", "needs_attention"]);
|
|
37
|
+
|
|
38
|
+
// pi-tasks-style strikethrough + dim for completed rows: raw SGR because the
|
|
39
|
+
// theme adapter has no strike; the truncate/colorize paths are ANSI-aware.
|
|
40
|
+
const DIM_ON = "\x1b[2m";
|
|
41
|
+
const DIM_OFF = "\x1b[22m";
|
|
42
|
+
const STRIKE_ON = "\x1b[9m";
|
|
43
|
+
const STRIKE_OFF = "\x1b[29m";
|
|
44
|
+
|
|
45
|
+
function isDoneStatus(status: string): boolean {
|
|
46
|
+
return status === "completed" || status === "failed" || status === "cancelled" || status === "skipped";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function taskTitle(task: TeamTaskState): string {
|
|
50
|
+
return (task.displayName ?? task.title ?? "").replace(/\s+/g, " ").trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** pi-tasks status glyph per row: ✔ done (struck through), spinner for the
|
|
54
|
+
* actively executing task, ◼ parked/needs input, ◻ queued, ✗ dead. */
|
|
55
|
+
function taskStatusIcon(task: TeamTaskState): string {
|
|
56
|
+
switch (task.status) {
|
|
57
|
+
case "running":
|
|
58
|
+
return spinnerFrame("crew-task-list");
|
|
59
|
+
case "queued":
|
|
60
|
+
return "◻";
|
|
61
|
+
case "waiting":
|
|
62
|
+
case "needs_attention":
|
|
63
|
+
return "◼";
|
|
64
|
+
case "completed":
|
|
65
|
+
return "✔";
|
|
66
|
+
case "failed":
|
|
67
|
+
case "cancelled":
|
|
68
|
+
case "skipped":
|
|
69
|
+
return "✗";
|
|
70
|
+
default:
|
|
71
|
+
return "?";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `45s` / `2m 49s` / `1h 03m`. */
|
|
76
|
+
function formatDuration(ms: number): string {
|
|
77
|
+
if (!Number.isFinite(ms) || ms < 0) return "";
|
|
78
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
79
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
80
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
81
|
+
if (minutes < 60) return `${minutes}m ${String(totalSeconds % 60).padStart(2, "0")}s`;
|
|
82
|
+
const hours = Math.floor(minutes / 60);
|
|
83
|
+
return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `4.1k` / `1.2M` / `918` — no unit suffix; the arrow pair carries it. */
|
|
87
|
+
function compactTokens(count: number): string {
|
|
88
|
+
if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) return "";
|
|
89
|
+
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
90
|
+
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
91
|
+
return `${count}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `(2m 49s · ↑ 4.1k ↓ 1.2k)` — elapsed plus tokens, pi-tasks style. Live
|
|
95
|
+
* totals come from agentProgress until the final usage lands. */
|
|
96
|
+
function runningSuffix(task: TeamTaskState): string {
|
|
97
|
+
const parts: string[] = [];
|
|
98
|
+
const start = Date.parse(task.startedAt ?? "");
|
|
99
|
+
if (Number.isFinite(start) && start > 0) {
|
|
100
|
+
const end = task.finishedAt ? Date.parse(task.finishedAt) : Date.now();
|
|
101
|
+
const elapsed = formatDuration(end - start);
|
|
102
|
+
if (elapsed) parts.push(elapsed);
|
|
103
|
+
}
|
|
104
|
+
const input = task.usage?.input ?? 0;
|
|
105
|
+
const output = task.usage?.output ?? 0;
|
|
106
|
+
if (input > 0 || output > 0) {
|
|
107
|
+
parts.push(`↑ ${compactTokens(input) || "0"} ↓ ${compactTokens(output) || "0"}`);
|
|
108
|
+
} else {
|
|
109
|
+
const live = compactTokens(task.agentProgress?.tokens ?? 0);
|
|
110
|
+
if (live) parts.push(live);
|
|
111
|
+
}
|
|
112
|
+
return parts.length ? ` (${parts.join(" · ")})` : "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `› blocked by #2, #3` for queued tasks — open (non-completed)
|
|
116
|
+
* dependencies only, named by task number (pi-tasks display rule). */
|
|
117
|
+
function blockedSuffix(task: TeamTaskState, tasks: readonly TeamTaskState[], numberById: Map<string, number>): string {
|
|
118
|
+
if (task.status !== "queued") return "";
|
|
119
|
+
const openDeps = (task.dependsOn ?? [])
|
|
120
|
+
.map((depId) => tasks.find((entry) => entry.id === depId))
|
|
121
|
+
.filter((dep): dep is TeamTaskState => dep !== undefined && dep.status !== "completed");
|
|
122
|
+
if (openDeps.length === 0) return "";
|
|
123
|
+
const labels = openDeps.map((dep) => `#${numberById.get(dep.id) ?? "?"}`);
|
|
124
|
+
return ` › blocked by ${labels.join(", ")}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One line per task: `#n title` behind its status glyph — pi-tasks style.
|
|
128
|
+
* Completed titles are dimmed and struck through. */
|
|
129
|
+
function taskRow(
|
|
130
|
+
task: TeamTaskState,
|
|
131
|
+
taskNumber: number,
|
|
132
|
+
tasks: readonly TeamTaskState[],
|
|
133
|
+
numberById: Map<string, number>,
|
|
134
|
+
width: number,
|
|
135
|
+
): string {
|
|
136
|
+
const title = taskTitle(task);
|
|
137
|
+
const suffix = task.status === "running" ? runningSuffix(task) : blockedSuffix(task, tasks, numberById);
|
|
138
|
+
if (task.status === "completed") {
|
|
139
|
+
const struck = `${STRIKE_ON}${DIM_ON}#${taskNumber} ${title}${DIM_OFF}${STRIKE_OFF}`;
|
|
140
|
+
return truncate(` ✔ ${struck}`.trimEnd(), width);
|
|
141
|
+
}
|
|
142
|
+
return truncate(` ${taskStatusIcon(task)} #${taskNumber} ${title}${suffix}`.trimEnd(), width);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The run whose plan is shown: the first with unfinished work, else the first. */
|
|
146
|
+
function primaryRun(runs: readonly WidgetRun[]): WidgetRun | undefined {
|
|
147
|
+
return runs.find((entry) => (entry.snapshot?.tasks ?? []).some((task) => ACTIVE_TASK_STATUSES.has(task.status))) ?? runs[0];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Which rows fit the cap: plan order, but never drop an unfinished task —
|
|
151
|
+
* if active work falls outside the first MAX_TASK_ROWS rows it stays and
|
|
152
|
+
* finished rows give way instead. */
|
|
153
|
+
function visibleTasks(tasks: readonly TeamTaskState[]): TeamTaskState[] {
|
|
154
|
+
if (tasks.length <= MAX_TASK_ROWS) return [...tasks];
|
|
155
|
+
const active = tasks.filter((task) => ACTIVE_TASK_STATUSES.has(task.status));
|
|
156
|
+
if (active.length >= MAX_TASK_ROWS) return active.slice(0, MAX_TASK_ROWS);
|
|
157
|
+
const keep = new Set(active.map((task) => task.id));
|
|
158
|
+
for (const task of tasks) {
|
|
159
|
+
if (keep.size >= MAX_TASK_ROWS) break;
|
|
160
|
+
keep.add(task.id);
|
|
161
|
+
}
|
|
162
|
+
return tasks.filter((task) => keep.has(task.id));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Build the task-list lines for the current runs. Empty when no run carries a
|
|
167
|
+
* tasks slice (nothing to say — the dock alone stays).
|
|
168
|
+
*/
|
|
169
|
+
export function buildTaskListLines(runs: readonly WidgetRun[], width: number): string[] {
|
|
170
|
+
const entry = primaryRun(runs);
|
|
171
|
+
if (!entry) return [];
|
|
172
|
+
const tasks = entry.snapshot?.tasks ?? [];
|
|
173
|
+
if (tasks.length === 0) return [];
|
|
174
|
+
|
|
175
|
+
const numberById = new Map(tasks.map((task, index) => [task.id, index + 1]));
|
|
176
|
+
const done = tasks.filter((task) => task.status === "completed").length;
|
|
177
|
+
const dead = tasks.filter((task) => isDoneStatus(task.status) && task.status !== "completed").length;
|
|
178
|
+
const inProgress = tasks.filter(
|
|
179
|
+
(task) => task.status === "running" || task.status === "waiting" || task.status === "needs_attention",
|
|
180
|
+
).length;
|
|
181
|
+
const open = tasks.filter((task) => task.status === "queued").length;
|
|
182
|
+
|
|
183
|
+
const counts = [`${done} done`];
|
|
184
|
+
if (dead) counts.push(`${dead} failed`);
|
|
185
|
+
counts.push(`${inProgress} in progress`, `${open} open`);
|
|
186
|
+
const headerParts = [`● ${tasks.length} tasks (${counts.join(", ")})`];
|
|
187
|
+
if (runs.length > 1) headerParts.push(shortRunLabel(entry.run));
|
|
188
|
+
const lines = [truncate(headerParts.join(" · "), width)];
|
|
189
|
+
|
|
190
|
+
const visible = visibleTasks(tasks);
|
|
191
|
+
for (const task of visible) {
|
|
192
|
+
lines.push(taskRow(task, numberById.get(task.id) ?? 0, tasks, numberById, width));
|
|
193
|
+
}
|
|
194
|
+
if (tasks.length > visible.length) {
|
|
195
|
+
lines.push(truncate(` … and ${tasks.length - visible.length} more`, width));
|
|
196
|
+
}
|
|
197
|
+
return lines;
|
|
198
|
+
}
|