pi-crew 0.9.10 → 0.9.12
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 +90 -0
- package/package.json +1 -1
- package/src/config/role-tools.ts +39 -6
- package/src/extension/crew-shortcuts.ts +29 -2
- package/src/extension/registration/commands.ts +61 -34
- package/src/runtime/async-runner.ts +70 -74
- package/src/runtime/background-runner.ts +13 -2
- package/src/runtime/process-status.ts +7 -2
- package/src/runtime/role-permission.ts +5 -21
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/state/artifact-store.ts +22 -2
- package/src/ui/crew-footer.ts +3 -3
- package/src/ui/crew-select-list.ts +1 -1
- package/src/ui/dashboard-panes/agents-pane.ts +26 -4
- package/src/ui/dashboard-panes/cancellation-pane.ts +23 -0
- package/src/ui/keybinding-map.ts +7 -3
- package/src/ui/live-conversation-overlay.ts +2 -2
- package/src/ui/live-run-sidebar.ts +18 -10
- package/src/ui/overlays/help-overlay.ts +166 -0
- package/src/ui/run-dashboard.ts +210 -70
- package/src/ui/status-colors.ts +45 -0
- package/src/ui/widget/index.ts +46 -3
- package/src/ui/widget/widget-formatters.ts +22 -7
- package/src/ui/widget/widget-renderer.ts +31 -27
- package/src/utils/redaction.ts +49 -31
- package/src/utils/visual.ts +3 -1
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/proces
|
|
|
7
7
|
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
|
|
8
8
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
9
9
|
import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
|
|
10
|
-
import { applyStatusColor, iconForStatus, type RunStatus } from "./status-colors.ts";
|
|
11
|
-
import { pad, truncate, sanitizeLine } from "../utils/visual.ts";
|
|
10
|
+
import { applyStatusColor, colorizeStatusGlyphs, iconForStatus, type RunStatus } from "./status-colors.ts";
|
|
11
|
+
import { pad, truncate, sanitizeLine, visibleWidth } from "../utils/visual.ts";
|
|
12
12
|
import { Box, Text } from "./layout-primitives.ts";
|
|
13
13
|
import { DynamicCrewBorder } from "./dynamic-border.ts";
|
|
14
14
|
import { aggregateUsage } from "../state/usage.ts";
|
|
@@ -19,6 +19,8 @@ import { renderProgressPane } from "./dashboard-panes/progress-pane.ts";
|
|
|
19
19
|
import { renderTranscriptPane } from "./dashboard-panes/transcript-pane.ts";
|
|
20
20
|
import { renderHealthPane } from "./dashboard-panes/health-pane.ts";
|
|
21
21
|
import { renderMetricsPane } from "./dashboard-panes/metrics-pane.ts";
|
|
22
|
+
import { summarizeTerminalReason } from "./dashboard-panes/cancellation-pane.ts";
|
|
23
|
+
import { HelpOverlay } from "./overlays/help-overlay.ts";
|
|
22
24
|
import { dashboardActionForKey } from "./keybinding-map.ts";
|
|
23
25
|
import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
|
|
24
26
|
import { spinnerBucket, spinnerFrame } from "./spinner.ts";
|
|
@@ -72,6 +74,41 @@ export interface RunDashboardSelection {
|
|
|
72
74
|
|
|
73
75
|
const TASK_READ_TTL_MS = 1000;
|
|
74
76
|
|
|
77
|
+
/** Max run rows rendered in the dashboard run-list block (L-1 window budget). */
|
|
78
|
+
const RUN_LIST_MAX = 8;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* F-4 — a live run's snapshot is considered "possibly stale" once its
|
|
82
|
+
* `fetchedAt` is this old. A progressing run rebuilds on every stamp change,
|
|
83
|
+
* so an old `fetchedAt` means the manifest read has been repeatedly flaky
|
|
84
|
+
* (the cache silently returned the previous entry).
|
|
85
|
+
*/
|
|
86
|
+
const STALE_SNAPSHOT_MS = 15_000;
|
|
87
|
+
|
|
88
|
+
/** Left-pad `value` to a fixed VISIBLE width (ANSI-aware). Used by V-1. */
|
|
89
|
+
function padVis(value: string, width: number): string {
|
|
90
|
+
const current = visibleWidth(value);
|
|
91
|
+
return current >= width ? value : `${" ".repeat(width - current)}${value}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* L-1 — compute the run-list window (visible slots + which scroll indicators
|
|
96
|
+
* render) for a given offset. Shared by `ensureRunListWindow()` and the
|
|
97
|
+
* render loop so they ALWAYS agree and the selection can never land off-screen.
|
|
98
|
+
*/
|
|
99
|
+
interface RunListWindow {
|
|
100
|
+
slots: number;
|
|
101
|
+
hasTop: boolean;
|
|
102
|
+
hasBottom: boolean;
|
|
103
|
+
}
|
|
104
|
+
function runListWindow(scrollOffset: number, count: number): RunListWindow {
|
|
105
|
+
const hasTop = scrollOffset > 0;
|
|
106
|
+
const availNoBottom = Math.max(1, RUN_LIST_MAX - (hasTop ? 1 : 0));
|
|
107
|
+
const hasBottom = scrollOffset + availNoBottom < count;
|
|
108
|
+
const slots = Math.max(1, RUN_LIST_MAX - (hasTop ? 1 : 0) - (hasBottom ? 1 : 0));
|
|
109
|
+
return { slots, hasTop, hasBottom };
|
|
110
|
+
}
|
|
111
|
+
|
|
75
112
|
function formatAge(iso: string | undefined): string | undefined {
|
|
76
113
|
if (!iso) return undefined;
|
|
77
114
|
const ms = Math.max(0, Date.now() - new Date(iso).getTime());
|
|
@@ -213,7 +250,7 @@ function agentsFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): Crew
|
|
|
213
250
|
}
|
|
214
251
|
}
|
|
215
252
|
|
|
216
|
-
function runLabel(run: TeamRunManifest, selected: boolean, snapshotCache?: RunSnapshotCache): string {
|
|
253
|
+
function runLabel(run: TeamRunManifest, selected: boolean, snapshotCache?: RunSnapshotCache, maxW?: number): string {
|
|
217
254
|
const agents = agentsFor(run, snapshotCache);
|
|
218
255
|
const stale = isLikelyOrphanedActiveRun(run, agents);
|
|
219
256
|
const running = agents.find((agent) => agent.status === "running");
|
|
@@ -221,7 +258,28 @@ function runLabel(run: TeamRunManifest, selected: boolean, snapshotCache?: RunSn
|
|
|
221
258
|
const step = stale ? "orphaned queued run" : running ? `step ${running.taskId}` : queued ? `queued ${queued.taskId}` : `agents ${agents.length}`;
|
|
222
259
|
const status: RunStatus = stale ? "stale" : (run.status as RunStatus);
|
|
223
260
|
const marker = selected ? "›" : " ";
|
|
224
|
-
|
|
261
|
+
const icon = iconForStatus(status, { runningGlyph: spinnerFrame(run.runId) });
|
|
262
|
+
// L-5: unified " · " separator (was "|").
|
|
263
|
+
// L-3: keep the GOAL (the human identifier) visible on narrow terminals —
|
|
264
|
+
// when the full line overflows, sacrifice the meta prefix (runId/status
|
|
265
|
+
// survive; team/workflow/step clip) rather than the goal. Optional `maxW`
|
|
266
|
+
// enables the goal-aware truncation; legacy 3-arg callers (dev patch
|
|
267
|
+
// scripts) get the untruncated full label.
|
|
268
|
+
const head = `${marker} ${icon} ${run.runId.slice(-8)} ${status}`;
|
|
269
|
+
const meta = `${run.team}/${run.workflow ?? "none"} · ${step}`;
|
|
270
|
+
const goal = sanitizeLine(run.goal ?? "");
|
|
271
|
+
if (maxW === undefined) return sanitizeLine(`${head} · ${meta} · ${goal}`);
|
|
272
|
+
const sepW = 3; // " · "
|
|
273
|
+
if (visibleWidth(head) + sepW + visibleWidth(meta) + sepW + visibleWidth(goal) <= maxW) {
|
|
274
|
+
return sanitizeLine(`${head} · ${meta} · ${goal}`);
|
|
275
|
+
}
|
|
276
|
+
// Not enough room: keep head + goal; clip the goal only if head+goal alone
|
|
277
|
+
// cannot fit, then shrink the prefix (meta clips first) to match.
|
|
278
|
+
const goalFits = visibleWidth(head) + sepW + visibleWidth(goal) <= maxW;
|
|
279
|
+
const goalRender = goalFits ? goal : truncate(goal, Math.max(8, maxW - visibleWidth(head) - sepW));
|
|
280
|
+
const prefixBudget = Math.max(0, maxW - visibleWidth(goalRender) - sepW);
|
|
281
|
+
const prefixRender = truncate(`${head} · ${meta}`, prefixBudget);
|
|
282
|
+
return sanitizeLine(`${prefixRender} · ${goalRender}`);
|
|
225
283
|
}
|
|
226
284
|
|
|
227
285
|
interface ResolvedRun {
|
|
@@ -266,7 +324,9 @@ function countByStatus(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache
|
|
|
266
324
|
|
|
267
325
|
export class RunDashboard implements DashboardComponent {
|
|
268
326
|
private selected = 0;
|
|
327
|
+
private runScrollOffset = 0;
|
|
269
328
|
private showFullProgress = false;
|
|
329
|
+
private showHelp = false;
|
|
270
330
|
private activePane: "agents" | "progress" | "mailbox" | "output" | "health" | "metrics" = lastActivePane;
|
|
271
331
|
private runs: TeamRunManifest[];
|
|
272
332
|
private readonly done: (selection: RunDashboardSelection | undefined) => void;
|
|
@@ -342,6 +402,30 @@ export class RunDashboard implements DashboardComponent {
|
|
|
342
402
|
}
|
|
343
403
|
}
|
|
344
404
|
|
|
405
|
+
/**
|
|
406
|
+
* L-1 — keep the run-list selection inside the rendered window so the `›`
|
|
407
|
+
* marker can never scroll off-screen (and Enter can never act on an
|
|
408
|
+
* invisible run). Uses the SAME `runListWindow()` the render loop uses and
|
|
409
|
+
* iterates to a fixed point, because the slot count depends on whether the
|
|
410
|
+
* ↑/↓ indicators render (which depends on the offset). This makes the
|
|
411
|
+
* window correct on the SAME render that processed the keypress — there is
|
|
412
|
+
* no one-frame lag where the marker is off-screen.
|
|
413
|
+
*/
|
|
414
|
+
private ensureRunListWindow(selectableCount: number): void {
|
|
415
|
+
if (selectableCount <= RUN_LIST_MAX) {
|
|
416
|
+
this.runScrollOffset = 0;
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
for (let i = 0; i < 4; i++) {
|
|
420
|
+
const { slots } = runListWindow(this.runScrollOffset, selectableCount);
|
|
421
|
+
const prev = this.runScrollOffset;
|
|
422
|
+
if (this.selected < this.runScrollOffset) this.runScrollOffset = this.selected;
|
|
423
|
+
else if (this.selected >= this.runScrollOffset + slots) this.runScrollOffset = this.selected - slots + 1;
|
|
424
|
+
this.runScrollOffset = Math.max(0, Math.min(this.runScrollOffset, Math.max(0, selectableCount - 1)));
|
|
425
|
+
if (this.runScrollOffset === prev) break;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
345
429
|
private buildSignature(): string {
|
|
346
430
|
let hasRunning = false;
|
|
347
431
|
const statuses = this.runs.map((run) => {
|
|
@@ -354,7 +438,7 @@ export class RunDashboard implements DashboardComponent {
|
|
|
354
438
|
return snapshot?.signature ?? `${displayRun.runId}:${displayRun.status}:${displayRun.updatedAt}:${status}`;
|
|
355
439
|
}).join("|");
|
|
356
440
|
const metricsSig = this.activePane === "metrics" ? `:metrics=${this.options.registry?.snapshot().length ?? 0}:${spinnerBucket()}` : "";
|
|
357
|
-
return `${this.selected}:${this.showFullProgress ? 1 : 0}:${this.activePane}:${statuses}${hasRunning ? `:spin=${spinnerBucket()}` : ""}${metricsSig}`;
|
|
441
|
+
return `${this.selected}:${this.showHelp ? 1 : 0}:${this.showFullProgress ? 1 : 0}:${this.activePane}:${statuses}${hasRunning ? `:spin=${spinnerBucket()}` : ""}${metricsSig}`;
|
|
358
442
|
}
|
|
359
443
|
|
|
360
444
|
invalidate(): void {
|
|
@@ -376,7 +460,10 @@ export class RunDashboard implements DashboardComponent {
|
|
|
376
460
|
return this.renderUnsafe(width);
|
|
377
461
|
} catch (error) {
|
|
378
462
|
logInternalError("run-dashboard.render", error);
|
|
379
|
-
return renderLines([
|
|
463
|
+
return renderLines([
|
|
464
|
+
"Dashboard error — see logs for details.",
|
|
465
|
+
"Press r to reload · Esc to close.",
|
|
466
|
+
], width);
|
|
380
467
|
}
|
|
381
468
|
}
|
|
382
469
|
|
|
@@ -392,78 +479,119 @@ export class RunDashboard implements DashboardComponent {
|
|
|
392
479
|
const row = (text: string) => `│ ${pad(truncate(text, innerWidth - 1), innerWidth - 1)}│`;
|
|
393
480
|
const sep = () => border("├", "┤");
|
|
394
481
|
|
|
395
|
-
const lines: string[] = [
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
];
|
|
400
|
-
|
|
401
|
-
if (this.runs.length === 0) {
|
|
402
|
-
lines.push(row(fg("dim", "No runs.")));
|
|
482
|
+
const lines: string[] = [];
|
|
483
|
+
if (this.showHelp) {
|
|
484
|
+
// K-1: help overlay replaces the dashboard body until dismissed.
|
|
485
|
+
lines.push(...new HelpOverlay(this.theme).render(width));
|
|
403
486
|
} else {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
: this.activePane === "progress" ? renderProgressPane(snap)
|
|
433
|
-
: this.activePane === "mailbox" ? renderMailboxPane(snap)
|
|
434
|
-
: this.activePane === "health" ? renderHealthPane(snap, { isForeground: !r.async })
|
|
435
|
-
: this.activePane === "metrics" ? renderMetricsPane(snap, { registry: this.options.registry })
|
|
436
|
-
: renderTranscriptPane(snap)
|
|
437
|
-
: [
|
|
438
|
-
...readAgentPreview(r, 4, this.options),
|
|
439
|
-
...readProgressPreview(r, 2),
|
|
440
|
-
];
|
|
441
|
-
const filteredPane = paneLines.filter(l => l && !l.includes("(none)") && l.trim() !== "");
|
|
442
|
-
if (filteredPane.length > 0) {
|
|
443
|
-
lines.push(row(fg("dim", `── ${this.activePane} ──`)));
|
|
444
|
-
for (const line of filteredPane.slice(0, 8)) {
|
|
445
|
-
lines.push(row(truncate(sanitizeLine(line), innerWidth - 2)));
|
|
487
|
+
lines.push(
|
|
488
|
+
border("╭", "╮"),
|
|
489
|
+
row(`${fg("accent", "▐")} ${this.theme.bold("pi-crew")} · ${this.runs.length} runs ${fg("dim", "1-6 pane · ↑↓ · Enter · ? help · Esc")}`),
|
|
490
|
+
sep(),
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
if (this.runs.length === 0) {
|
|
494
|
+
// F-7: actionable empty state instead of a bare "No runs.".
|
|
495
|
+
lines.push(row(fg("dim", "No runs yet.")));
|
|
496
|
+
lines.push(row(fg("dim", "Start one: team action='run' · r reload · Esc close")));
|
|
497
|
+
} else {
|
|
498
|
+
// L-1: windowed run list so the selection can never scroll off-screen.
|
|
499
|
+
const allGrouped = groupedRuns(this.runs, this.options.snapshotCache);
|
|
500
|
+
const selectable = allGrouped.filter((rowItem) => rowItem.run);
|
|
501
|
+
const selectableCount = selectable.length;
|
|
502
|
+
if (this.selected > selectableCount - 1) this.selected = Math.max(0, selectableCount - 1);
|
|
503
|
+
this.ensureRunListWindow(selectableCount);
|
|
504
|
+
if (selectableCount <= RUN_LIST_MAX) {
|
|
505
|
+
// Common case (≤8 runs): keep the Active/Recent group headers.
|
|
506
|
+
for (const rowItem of allGrouped) {
|
|
507
|
+
if (!rowItem.run) { lines.push(row(fg("dim", `── ${rowItem.label} ──`))); continue; }
|
|
508
|
+
const idx = selectable.findIndex((c) => c.run?.runId === rowItem.run?.runId);
|
|
509
|
+
const snap = snapshotFor(rowItem.run, this.options.snapshotCache);
|
|
510
|
+
const run = snap?.manifest ?? rowItem.run;
|
|
511
|
+
const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache);
|
|
512
|
+
const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus);
|
|
513
|
+
const label = runLabel(run, idx === this.selected, this.options.snapshotCache, innerWidth - 2);
|
|
514
|
+
lines.push(row(applyStatusColor(this.theme, status, label)));
|
|
446
515
|
}
|
|
516
|
+
} else {
|
|
517
|
+
// >8 runs: windowed list (group headers omitted to maximise run rows).
|
|
518
|
+
const win = runListWindow(this.runScrollOffset, selectableCount);
|
|
519
|
+
if (win.hasTop) lines.push(row(fg("dim", `↑ ${this.runScrollOffset} more above`)));
|
|
520
|
+
for (let gi = this.runScrollOffset; gi < Math.min(this.runScrollOffset + win.slots, selectableCount); gi++) {
|
|
521
|
+
const rowItem = selectable[gi];
|
|
522
|
+
if (!rowItem?.run) continue;
|
|
523
|
+
const snap = snapshotFor(rowItem.run, this.options.snapshotCache);
|
|
524
|
+
const run = snap?.manifest ?? rowItem.run;
|
|
525
|
+
const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache);
|
|
526
|
+
const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus);
|
|
527
|
+
const label = runLabel(run, gi === this.selected, this.options.snapshotCache, innerWidth - 2);
|
|
528
|
+
lines.push(row(applyStatusColor(this.theme, status, label)));
|
|
529
|
+
}
|
|
530
|
+
if (win.hasBottom) lines.push(row(fg("dim", `↓ ${selectableCount - (this.runScrollOffset + win.slots)} more below`)));
|
|
447
531
|
}
|
|
448
532
|
|
|
449
|
-
//
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
533
|
+
// Selected run detail — compact
|
|
534
|
+
const selectedRun = selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache);
|
|
535
|
+
if (selectedRun) {
|
|
536
|
+
const snap = snapshotFor(selectedRun, this.options.snapshotCache);
|
|
537
|
+
const r = snap?.manifest ?? selectedRun;
|
|
538
|
+
const agents = snap?.agents ?? agentsFor(selectedRun, this.options.snapshotCache);
|
|
539
|
+
const statusStr: RunStatus = isLikelyOrphanedActiveRun(r, agents) ? "stale" : (r.status as RunStatus);
|
|
540
|
+
const selectedTasks = snap?.tasks ?? readRunTasks(r, this.options.snapshotCache);
|
|
541
|
+
lines.push(sep());
|
|
542
|
+
lines.push(row(`${fg("accent", "▸")} ${truncate(sanitizeLine(r.goal), innerWidth - 6)}`));
|
|
543
|
+
// L-2: surface the failure/cancellation reason inline for terminal runs.
|
|
544
|
+
const isTerminal = statusStr === "failed" || statusStr === "cancelled" || statusStr === "stopped";
|
|
545
|
+
const reason = isTerminal ? summarizeTerminalReason(r, selectedTasks, snap?.cancellationReason) : undefined;
|
|
546
|
+
const reasonSuffix = reason ? ` · ${truncate(sanitizeLine(reason), 40)}` : "";
|
|
547
|
+
lines.push(row(fg("dim", sanitizeLine(` ${r.team}/${r.workflow ?? "default"} · ${statusStr} · ${r.runId.slice(-10)}${reasonSuffix}`))));
|
|
548
|
+
|
|
549
|
+
// Pane content (max 8 lines) — F-2: colorize embedded status glyphs.
|
|
550
|
+
const paneLines = snap
|
|
551
|
+
? this.activePane === "agents" ? renderAgentsPane(snap, this.options)
|
|
552
|
+
: this.activePane === "progress" ? renderProgressPane(snap)
|
|
553
|
+
: this.activePane === "mailbox" ? renderMailboxPane(snap)
|
|
554
|
+
: this.activePane === "health" ? renderHealthPane(snap, { isForeground: !r.async })
|
|
555
|
+
: this.activePane === "metrics" ? renderMetricsPane(snap, { registry: this.options.registry })
|
|
556
|
+
: renderTranscriptPane(snap)
|
|
557
|
+
: [
|
|
558
|
+
...readAgentPreview(r, 4, this.options),
|
|
559
|
+
...readProgressPreview(r, 2),
|
|
560
|
+
];
|
|
561
|
+
const filteredPane = paneLines.filter(l => l && !l.includes("(none)") && l.trim() !== "");
|
|
562
|
+
if (filteredPane.length > 0) {
|
|
563
|
+
lines.push(row(fg("dim", `── ${this.activePane} ──`)));
|
|
564
|
+
for (const line of filteredPane.slice(0, 8)) {
|
|
565
|
+
lines.push(colorizeStatusGlyphs(row(truncate(sanitizeLine(line), innerWidth - 2)), this.theme));
|
|
566
|
+
}
|
|
460
567
|
}
|
|
568
|
+
|
|
569
|
+
// F-4: warn when a live run's snapshot is likely stale (flaky read).
|
|
570
|
+
const isActiveRun = statusStr === "running" || statusStr === "queued" || statusStr === "waiting";
|
|
571
|
+
const staleData = isActiveRun && (snap ? Date.now() - snap.fetchedAt > STALE_SNAPSHOT_MS : true);
|
|
572
|
+
if (staleData) lines.push(row(fg("warning", "⚠ data may be stale (manifest read flaky)")));
|
|
573
|
+
|
|
574
|
+
// One-line footer — V-1: width-padded so the right edge doesn't jitter.
|
|
575
|
+
const usage = aggregateUsage(selectedTasks);
|
|
576
|
+
const u = usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
577
|
+
const tok = (u.input ?? 0) + (u.output ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
|
|
578
|
+
const tokStr = tok > 0 ? (tok >= 1000 ? `${(tok / 1000).toFixed(1)}k tok` : `${tok} tok`) : "";
|
|
579
|
+
let ctxPct: number | undefined;
|
|
580
|
+
for (const agent of agents) {
|
|
581
|
+
if (agent.status === "running" && agent.runtime === "live-session") {
|
|
582
|
+
const pct = getLiveAgentContextPercent(agent.taskId);
|
|
583
|
+
if (pct != null) { ctxPct = pct; break; }
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const ctxStr = ctxPct != null ? `${Math.round(ctxPct)}% ctx` : "";
|
|
587
|
+
const footerFields: string[] = [];
|
|
588
|
+
if (tokStr) footerFields.push(padVis(tokStr, 10));
|
|
589
|
+
if (ctxStr) footerFields.push(padVis(ctxStr, 9));
|
|
590
|
+
if (footerFields.length) lines.push(row(fg("dim", footerFields.join(" · "))));
|
|
461
591
|
}
|
|
462
|
-
const ctxStr = ctxPct != null ? ` · ${Math.round(ctxPct)}% ctx` : "";
|
|
463
|
-
if (tokStr || ctxStr) lines.push(row(fg("dim", `${tokStr}${ctxStr}`)));
|
|
464
592
|
}
|
|
593
|
+
lines.push(border("╰", "╯"));
|
|
465
594
|
}
|
|
466
|
-
lines.push(border("╰", "╯"));
|
|
467
595
|
|
|
468
596
|
const target = this.targetHeight();
|
|
469
597
|
if (lines.length < target) {
|
|
@@ -488,6 +616,18 @@ export class RunDashboard implements DashboardComponent {
|
|
|
488
616
|
|
|
489
617
|
handleInput(data: string): void {
|
|
490
618
|
const action = dashboardActionForKey(data, this.activePane);
|
|
619
|
+
// K-1: "?" toggles the help overlay; while it is shown, any other key
|
|
620
|
+
// (including Esc) just dismisses it first instead of acting.
|
|
621
|
+
if (action === "help") {
|
|
622
|
+
this.showHelp = !this.showHelp;
|
|
623
|
+
this.invalidateAndRender();
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (this.showHelp) {
|
|
627
|
+
this.showHelp = false;
|
|
628
|
+
this.invalidateAndRender();
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
491
631
|
const selectedRunId = this.selectedRunId();
|
|
492
632
|
if (action === "close") {
|
|
493
633
|
this.done(undefined);
|
package/src/ui/status-colors.ts
CHANGED
|
@@ -61,3 +61,48 @@ function colorForActivity(activityState: string | undefined): CrewThemeColor {
|
|
|
61
61
|
export function applyStatusColor(theme: CrewTheme, status: RunStatus, text: string): string {
|
|
62
62
|
return theme.fg(colorForStatus(status), text);
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Shared glyph→color map for status glyphs embedded in rendered lines.
|
|
67
|
+
* Consolidates the duplicated `statusGlyphColor` (widget-renderer.ts) and
|
|
68
|
+
* `iconColor` (live-run-sidebar.ts) maps into a single source of truth.
|
|
69
|
+
*
|
|
70
|
+
* Covers ALL status glyphs, including the two previously-missing ones:
|
|
71
|
+
* ⏳ (waiting) → muted, ⚠ (needs_attention) → warning
|
|
72
|
+
* and the braille spinner range U+2800–U+28FF (⠁–⣿) → accent (V-3).
|
|
73
|
+
*/
|
|
74
|
+
function glyphColor(glyph: string): CrewThemeColor {
|
|
75
|
+
// Braille Patterns block (U+2800–U+28FF) — running animation frames.
|
|
76
|
+
const code = glyph.codePointAt(0) ?? 0;
|
|
77
|
+
if (code >= 0x2800 && code <= 0x28ff) return "accent";
|
|
78
|
+
switch (glyph) {
|
|
79
|
+
case "✓":
|
|
80
|
+
return "success";
|
|
81
|
+
case "✗":
|
|
82
|
+
return "error";
|
|
83
|
+
case "■":
|
|
84
|
+
case "⏸":
|
|
85
|
+
case "⚠":
|
|
86
|
+
return "warning";
|
|
87
|
+
case "⏳":
|
|
88
|
+
return "muted";
|
|
89
|
+
case "◦":
|
|
90
|
+
case "·":
|
|
91
|
+
return "dim";
|
|
92
|
+
case "▶":
|
|
93
|
+
default:
|
|
94
|
+
return "accent";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Colorize status glyphs embedded in a rendered line. Wraps every status glyph
|
|
100
|
+
* (✓ ✗ ■ ⏸ ⏳ ⚠ ◦ · ▶ and braille spinner frames ⠁–⣿) in the appropriate theme
|
|
101
|
+
* color, leaving surrounding text untouched.
|
|
102
|
+
*
|
|
103
|
+
* Replaces the duplicated per-module glyph colorizers in widget-renderer.ts
|
|
104
|
+
* and live-run-sidebar.ts with a single shared helper (F-1, F-2, V-3).
|
|
105
|
+
*/
|
|
106
|
+
export function colorizeStatusGlyphs(line: string, theme: CrewTheme): string {
|
|
107
|
+
return line.replace(/[✓✗■⏸◦·▶⏳⚠]|[\u2800-\u28FF]/g, (glyph) => theme.fg(glyphColor(glyph), glyph));
|
|
108
|
+
}
|
package/src/ui/widget/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { RunSnapshotCache, RunUiSnapshot } from "../snapshot-types.ts";
|
|
|
13
13
|
import type { CrewTheme } from "../theme-adapter.ts";
|
|
14
14
|
import { asCrewTheme, subscribeThemeChange } from "../theme-adapter.ts";
|
|
15
15
|
import { truncate } from "../../utils/visual.ts";
|
|
16
|
-
import { requestRender, setExtensionWidget } from "../pi-ui-compat.ts";
|
|
16
|
+
import { requestRender, requestRenderTarget, setExtensionWidget } from "../pi-ui-compat.ts";
|
|
17
17
|
import { spinnerBucket, spinnerFrame } from "../spinner.ts";
|
|
18
18
|
import { runEventBus } from "../run-event-bus.ts";
|
|
19
19
|
import { DEFAULT_UI } from "../../config/defaults.ts";
|
|
@@ -51,6 +51,35 @@ const LEGACY_WIDGET_KEY = "pi-crew";
|
|
|
51
51
|
const WIDGET_KEY = "pi-crew-active";
|
|
52
52
|
const STATUS_KEY = "pi-crew";
|
|
53
53
|
|
|
54
|
+
// ── Terminal resize handling (T-2) ────────────────────────────────────
|
|
55
|
+
// On a terminal resize the widget's cached render width goes stale until the
|
|
56
|
+
// next invalidate; a mid-run resize could briefly paint a frame at the old
|
|
57
|
+
// width. We register ONE debounced process-level listener (guarded so it never
|
|
58
|
+
// accumulates across widget reinstalls) that busts the active widget's cache
|
|
59
|
+
// and pokes Pi to repaint at the new width. The listener references a
|
|
60
|
+
// module-level `activeResizeTarget` (the most-recently-mounted widget) rather
|
|
61
|
+
// than a specific instance, so replaced widgets do not leak listeners.
|
|
62
|
+
let resizeListenerInstalled = false;
|
|
63
|
+
let activeResizeTarget: { invalidate(): void; requestRepaint(): void } | undefined;
|
|
64
|
+
|
|
65
|
+
function installResizeListener(): void {
|
|
66
|
+
if (resizeListenerInstalled) return;
|
|
67
|
+
resizeListenerInstalled = true;
|
|
68
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
69
|
+
const onResize = (): void => {
|
|
70
|
+
if (timer) clearTimeout(timer);
|
|
71
|
+
// Debounce (~120ms) so a drag-resize doesn't thrash renders.
|
|
72
|
+
timer = setTimeout(() => {
|
|
73
|
+
timer = undefined;
|
|
74
|
+
activeResizeTarget?.invalidate();
|
|
75
|
+
activeResizeTarget?.requestRepaint();
|
|
76
|
+
}, 120);
|
|
77
|
+
};
|
|
78
|
+
process.on("SIGWINCH", onResize);
|
|
79
|
+
// Windows has no SIGWINCH; Node emits "resize" on stdout instead.
|
|
80
|
+
if (typeof process.stdout?.on === "function") process.stdout.on("resize", onResize);
|
|
81
|
+
}
|
|
82
|
+
|
|
54
83
|
// ── Widget Component ──────────────────────────────────────────────────
|
|
55
84
|
|
|
56
85
|
interface WidgetComponent {
|
|
@@ -66,13 +95,21 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
66
95
|
private cachedLines: string[] = [];
|
|
67
96
|
private cachedBaseLines: string[] = [];
|
|
68
97
|
private cachedTheme: CrewTheme;
|
|
98
|
+
private readonly tui: unknown;
|
|
69
99
|
private readonly unsubscribeTheme: () => void;
|
|
70
100
|
private readonly unsubscribeEventBus: () => void;
|
|
71
101
|
|
|
72
|
-
constructor(model: CrewWidgetModel, themeLike: unknown) {
|
|
102
|
+
constructor(model: CrewWidgetModel, themeLike: unknown, tui?: unknown) {
|
|
73
103
|
this.model = model;
|
|
74
104
|
this.theme = asCrewTheme(themeLike);
|
|
75
105
|
this.cachedTheme = this.theme;
|
|
106
|
+
this.tui = tui;
|
|
107
|
+
// Register as the active resize target and ensure the single, guarded
|
|
108
|
+
// terminal-resize listener is installed. On a resize the cached width
|
|
109
|
+
// goes stale; busting the cache + requesting a repaint refreshes the
|
|
110
|
+
// widget at the new width without waiting for the next event tick (T-2).
|
|
111
|
+
activeResizeTarget = this;
|
|
112
|
+
installResizeListener();
|
|
76
113
|
this.unsubscribeTheme = subscribeThemeChange(themeLike, () => this.invalidate());
|
|
77
114
|
this.unsubscribeEventBus = (() => {
|
|
78
115
|
const unsub1 = runEventBus.onChannel("run:state", () => this.invalidate());
|
|
@@ -114,9 +151,15 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
114
151
|
this.cachedLines = [];
|
|
115
152
|
}
|
|
116
153
|
|
|
154
|
+
/** Poke the host TUI to repaint immediately (defensive: no-op if unavailable). */
|
|
155
|
+
requestRepaint(): void {
|
|
156
|
+
requestRenderTarget(this.tui);
|
|
157
|
+
}
|
|
158
|
+
|
|
117
159
|
dispose(): void {
|
|
118
160
|
this.unsubscribeTheme();
|
|
119
161
|
this.unsubscribeEventBus();
|
|
162
|
+
if (activeResizeTarget === this) activeResizeTarget = undefined;
|
|
120
163
|
}
|
|
121
164
|
|
|
122
165
|
render(width: number): string[] {
|
|
@@ -215,7 +258,7 @@ export function updateCrewWidget(
|
|
|
215
258
|
setExtensionWidget(
|
|
216
259
|
ctx,
|
|
217
260
|
WIDGET_KEY,
|
|
218
|
-
((_tui: unknown, theme: unknown) => new CrewWidgetComponent(model, theme)) as never,
|
|
261
|
+
((_tui: unknown, theme: unknown) => new CrewWidgetComponent(model, theme, _tui)) as never,
|
|
219
262
|
{ placement, persist: true },
|
|
220
263
|
);
|
|
221
264
|
state.lastVisibility = "visible";
|
|
@@ -8,9 +8,24 @@ import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
|
|
|
8
8
|
import type { LiveAgentHandle } from "../../runtime/live-agent-manager.ts";
|
|
9
9
|
import { getTaskUsage } from "../../runtime/usage-tracker.ts";
|
|
10
10
|
import { computeLiveDurationMs } from "../live-duration.ts";
|
|
11
|
+
import { visibleWidth } from "../../utils/visual.ts";
|
|
11
12
|
|
|
12
13
|
// ── Token formatting ──────────────────────────────────────────────────
|
|
13
14
|
|
|
15
|
+
// V-1: fixed visible widths for per-agent numeric metrics so columns don't
|
|
16
|
+
// jitter every tick as values change width (e.g. 9.9s→10.0s, 950→1.0k).
|
|
17
|
+
// alignMetric right-aligns to the width; values wider than the width overflow
|
|
18
|
+
// verbatim (no truncation/crash) — these are rare one-off states, not jitter.
|
|
19
|
+
const TOOLS_METRIC_WIDTH = 8; // "127 tools"
|
|
20
|
+
const TOKENS_METRIC_WIDTH = 10; // "1.2k tok", "12.3M tok"
|
|
21
|
+
const CTX_METRIC_WIDTH = 7; // "100% ctx"
|
|
22
|
+
const DURATION_METRIC_WIDTH = 6; // "120.0s"
|
|
23
|
+
|
|
24
|
+
function alignMetric(value: string, width: number): string {
|
|
25
|
+
const pad = Math.max(0, width - visibleWidth(value));
|
|
26
|
+
return " ".repeat(pad) + value;
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
export function formatTokensCompact(count: number): string {
|
|
15
30
|
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M tok`;
|
|
16
31
|
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k tok`;
|
|
@@ -107,22 +122,22 @@ export function agentStats(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle)
|
|
|
107
122
|
const parts: string[] = [];
|
|
108
123
|
if (liveHandle) {
|
|
109
124
|
const act = liveHandle.activity;
|
|
110
|
-
if (act.toolUses > 0) parts.push(`${act.toolUses} tools
|
|
125
|
+
if (act.toolUses > 0) parts.push(alignMetric(`${act.toolUses} tools`, TOOLS_METRIC_WIDTH));
|
|
111
126
|
const usage = getTaskUsage(liveHandle.taskId);
|
|
112
127
|
const total = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheWrite ?? 0);
|
|
113
|
-
if (total > 0) parts.push(formatTokensCompact(total));
|
|
128
|
+
if (total > 0) parts.push(alignMetric(formatTokensCompact(total), TOKENS_METRIC_WIDTH));
|
|
114
129
|
try {
|
|
115
130
|
const stats = liveHandle.session.getSessionStats?.();
|
|
116
131
|
const ctxPct = stats?.contextUsage?.percent;
|
|
117
|
-
if (ctxPct != null) parts.push(`${Math.round(ctxPct)}% ctx
|
|
132
|
+
if (ctxPct != null) parts.push(alignMetric(`${Math.round(ctxPct)}% ctx`, CTX_METRIC_WIDTH));
|
|
118
133
|
} catch { /* ignore */ }
|
|
119
134
|
const ms = computeLiveDurationMs(act);
|
|
120
|
-
parts.push(`${(ms / 1000).toFixed(1)}s
|
|
135
|
+
parts.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
|
|
121
136
|
} else {
|
|
122
|
-
if (agent.toolUses) parts.push(`${agent.toolUses} tools
|
|
123
|
-
if (agent.progress?.tokens) parts.push(formatTokensCompact(agent.progress.tokens));
|
|
137
|
+
if (agent.toolUses) parts.push(alignMetric(`${agent.toolUses} tools`, TOOLS_METRIC_WIDTH));
|
|
138
|
+
if (agent.progress?.tokens) parts.push(alignMetric(formatTokensCompact(agent.progress.tokens), TOKENS_METRIC_WIDTH));
|
|
124
139
|
const age = elapsed(agent.completedAt ?? agent.startedAt);
|
|
125
|
-
if (age) parts.push(age);
|
|
140
|
+
if (age) parts.push(alignMetric(age, DURATION_METRIC_WIDTH));
|
|
126
141
|
}
|
|
127
142
|
return parts.join(" · ");
|
|
128
143
|
}
|