pi-crew 0.9.11 → 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 +38 -0
- package/package.json +1 -1
- package/src/extension/crew-shortcuts.ts +29 -2
- package/src/extension/registration/commands.ts +61 -34
- package/src/runtime/process-status.ts +7 -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/visual.ts +3 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* K-1 — dashboard keybinding cheatsheet overlay.
|
|
3
|
+
*
|
|
4
|
+
* Toggled by `?` (bound in keybinding-map.ts). Renders the dashboard's
|
|
5
|
+
* keybindings grouped by scope (general / navigation / panes / run actions /
|
|
6
|
+
* mailbox / health) directly from `DASHBOARD_KEYS`, so adding a key in one
|
|
7
|
+
* place is reflected here automatically.
|
|
8
|
+
*
|
|
9
|
+
* Uses the same `innerWidth = max(20, width - 4)` formula as `run-dashboard.ts`
|
|
10
|
+
* so the overlay's border column aligns with the dashboard's stable-height
|
|
11
|
+
* blank-padding rows (preserves the "stable overlay height" strength — the
|
|
12
|
+
* blank rows injected by the dashboard's pad/trim must land in the same
|
|
13
|
+
* column as this overlay's `│` borders).
|
|
14
|
+
*
|
|
15
|
+
* Template origin: `confirm-overlay.ts`.
|
|
16
|
+
*/
|
|
17
|
+
import { Box, Text } from "../layout-primitives.ts";
|
|
18
|
+
import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
|
|
19
|
+
import { pad, truncate } from "../../utils/visual.ts";
|
|
20
|
+
import { DASHBOARD_KEYS } from "../keybinding-map.ts";
|
|
21
|
+
|
|
22
|
+
/** Translate a raw key sequence into a readable token for the cheatsheet. */
|
|
23
|
+
function keyToken(key: string): string {
|
|
24
|
+
switch (key) {
|
|
25
|
+
case "\u001b":
|
|
26
|
+
return "Esc";
|
|
27
|
+
case "\u001b[A":
|
|
28
|
+
return "↑";
|
|
29
|
+
case "\u001b[B":
|
|
30
|
+
return "↓";
|
|
31
|
+
case "\r":
|
|
32
|
+
case "\n":
|
|
33
|
+
return "Enter";
|
|
34
|
+
default:
|
|
35
|
+
return key;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function keyList(keys: readonly string[]): string {
|
|
40
|
+
return [...keys].map(keyToken).join("/");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface HelpEntry {
|
|
44
|
+
readonly keys: string;
|
|
45
|
+
readonly label: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface HelpGroup {
|
|
49
|
+
readonly title: string;
|
|
50
|
+
readonly entries: readonly HelpEntry[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ROOT_LABELS: Record<string, string> = {
|
|
54
|
+
summary: "summary",
|
|
55
|
+
artifacts: "artifacts",
|
|
56
|
+
api: "api",
|
|
57
|
+
agents: "agents",
|
|
58
|
+
mailbox: "mailbox",
|
|
59
|
+
events: "events",
|
|
60
|
+
output: "output",
|
|
61
|
+
transcript: "transcript",
|
|
62
|
+
liveConversation: "live conv",
|
|
63
|
+
reload: "reload",
|
|
64
|
+
progressToggle: "progress",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const MAILBOX_LABELS: Record<string, string> = {
|
|
68
|
+
ack: "ack",
|
|
69
|
+
nudge: "nudge",
|
|
70
|
+
compose: "compose",
|
|
71
|
+
preview: "preview",
|
|
72
|
+
ackAll: "ack all",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const HEALTH_LABELS: Record<string, string> = {
|
|
76
|
+
recovery: "recover",
|
|
77
|
+
killStale: "kill stale",
|
|
78
|
+
diagnosticExport: "diag export",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function buildHelpGroups(): HelpGroup[] {
|
|
82
|
+
const paneEntries: HelpEntry[] = Object.entries(DASHBOARD_KEYS.pane).map(([name, keys]) => ({
|
|
83
|
+
keys: keyList(keys),
|
|
84
|
+
label: name,
|
|
85
|
+
}));
|
|
86
|
+
const rootEntries: HelpEntry[] = Object.entries(DASHBOARD_KEYS.root).map(([name, keys]) => ({
|
|
87
|
+
keys: keyList(keys),
|
|
88
|
+
label: ROOT_LABELS[name] ?? name,
|
|
89
|
+
}));
|
|
90
|
+
const mailboxEntries: HelpEntry[] = Object.entries(DASHBOARD_KEYS.mailbox)
|
|
91
|
+
.filter(([name]) => name !== "openDetail")
|
|
92
|
+
.map(([name, keys]) => ({ keys: keyList(keys), label: MAILBOX_LABELS[name] ?? name }));
|
|
93
|
+
const healthEntries: HelpEntry[] = Object.entries(DASHBOARD_KEYS.health).map(([name, keys]) => ({
|
|
94
|
+
keys: keyList(keys),
|
|
95
|
+
label: HEALTH_LABELS[name] ?? name,
|
|
96
|
+
}));
|
|
97
|
+
return [
|
|
98
|
+
{
|
|
99
|
+
title: "General",
|
|
100
|
+
entries: [
|
|
101
|
+
{ keys: keyList(DASHBOARD_KEYS.close), label: "close dashboard" },
|
|
102
|
+
{ keys: keyList(DASHBOARD_KEYS.select), label: "open run status" },
|
|
103
|
+
{ keys: "?", label: "toggle this help" },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
title: "Navigation",
|
|
108
|
+
entries: [
|
|
109
|
+
{ keys: `${keyList(DASHBOARD_KEYS.navigation.up)}/${keyList(DASHBOARD_KEYS.navigation.down)}`, label: "move selection" },
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
{ title: "Panes", entries: paneEntries },
|
|
113
|
+
{ title: "Run actions", entries: rootEntries },
|
|
114
|
+
{ title: "Mailbox (pane 3)", entries: mailboxEntries },
|
|
115
|
+
{
|
|
116
|
+
title: "Health & notifications",
|
|
117
|
+
entries: [
|
|
118
|
+
...healthEntries,
|
|
119
|
+
{ keys: keyList(DASHBOARD_KEYS.notification.dismissAll), label: "dismiss notifs" },
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class HelpOverlay {
|
|
126
|
+
private readonly theme: CrewTheme;
|
|
127
|
+
|
|
128
|
+
constructor(theme: unknown = {}) {
|
|
129
|
+
this.theme = asCrewTheme(theme);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
invalidate(): void {
|
|
133
|
+
// Stateless overlay.
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
render(width: number): string[] {
|
|
137
|
+
// MUST mirror run-dashboard.ts's innerWidth so the dashboard's
|
|
138
|
+
// stable-height blank-padding rows align with this overlay's borders.
|
|
139
|
+
const innerWidth = Math.max(20, width - 4);
|
|
140
|
+
const fg = (color: Parameters<CrewTheme["fg"]>[0], text: string) => this.theme.fg(color, text);
|
|
141
|
+
const row = (text: string) => `│ ${pad(truncate(text, innerWidth - 1), innerWidth - 1)}│`;
|
|
142
|
+
const bar = "─".repeat(innerWidth);
|
|
143
|
+
const top = fg("border", `╭${bar}╮`);
|
|
144
|
+
const mid = fg("border", `├${bar}┤`);
|
|
145
|
+
const bot = fg("border", `╰${bar}╯`);
|
|
146
|
+
const keyCol = 9;
|
|
147
|
+
const lines: string[] = [
|
|
148
|
+
top,
|
|
149
|
+
row(`${fg("accent", "pi-crew dashboard")} ${fg("dim", "— key reference (press ? to close)")}`),
|
|
150
|
+
mid,
|
|
151
|
+
];
|
|
152
|
+
for (const group of buildHelpGroups()) {
|
|
153
|
+
lines.push(row(fg("accent", group.title)));
|
|
154
|
+
for (let i = 0; i < group.entries.length; i += 2) {
|
|
155
|
+
const pair = group.entries.slice(i, i + 2);
|
|
156
|
+
const cell = (entry: HelpEntry) =>
|
|
157
|
+
`${this.theme.bold(pad(entry.keys, keyCol))}${fg("dim", truncate(entry.label, 16))}`;
|
|
158
|
+
lines.push(row(pair.map(cell).join(" ")));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
lines.push(bot);
|
|
162
|
+
const box = new Box(0, 0);
|
|
163
|
+
for (const line of lines) box.addChild(new Text(line));
|
|
164
|
+
return box.render(width);
|
|
165
|
+
}
|
|
166
|
+
}
|
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
|
+
}
|