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
|
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import type { ArtifactDescriptor } from "./types.ts";
|
|
5
5
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
6
6
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
7
|
-
import { redactSecretString } from "../utils/redaction.ts";
|
|
7
|
+
import { redactSecretString, redactSecrets } from "../utils/redaction.ts";
|
|
8
8
|
|
|
9
9
|
function hashContent(content: string): string {
|
|
10
10
|
return createHash("sha256").update(content).digest("hex");
|
|
@@ -127,7 +127,27 @@ export function writeArtifact(artifactsRoot: string, options: ArtifactWriteOptio
|
|
|
127
127
|
const filePath = resolveInside(artifactsRoot, options.relativePath);
|
|
128
128
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
129
129
|
resolveRealContainedPath(artifactsRoot, path.dirname(filePath));
|
|
130
|
-
|
|
130
|
+
let content = options.content;
|
|
131
|
+
// Structural JSON redaction first — catches quoted-JSON secrets
|
|
132
|
+
// ("api_key":"sk-...") and nested keys that flat redactSecretString misses.
|
|
133
|
+
// The flat scan below still catches free-text patterns (Bearer/JWT/Auth
|
|
134
|
+
// headers) that may live inside JSON string values. See security review M2.
|
|
135
|
+
//
|
|
136
|
+
// Formatting preservation: re-stringify with the SAME indentation as the
|
|
137
|
+
// input so pretty-printed artifacts (e.g. group-join metadata expected by
|
|
138
|
+
// test/integration/phase4-runtime.test.ts to contain `"partial": false`)
|
|
139
|
+
// keep their whitespace. Detect pretty-vs-compact from the raw input.
|
|
140
|
+
const trimmed = content.trim();
|
|
141
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
142
|
+
try {
|
|
143
|
+
const parsed: unknown = JSON.parse(content);
|
|
144
|
+
const isPretty = /\n|"\s*:\s/.test(content);
|
|
145
|
+
content = JSON.stringify(redactSecrets(parsed), null, isPretty ? 2 : undefined);
|
|
146
|
+
} catch {
|
|
147
|
+
// not valid JSON — fall through to flat redaction only
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
content = redactSecretString(content);
|
|
131
151
|
atomicWriteFile(filePath, content);
|
|
132
152
|
// Compute hash on written bytes for integrity verification.
|
|
133
153
|
// Read back the actual file content to handle atomicWrite fallback path
|
package/src/ui/crew-footer.ts
CHANGED
|
@@ -90,9 +90,9 @@ export class CrewFooter {
|
|
|
90
90
|
].join(" • ");
|
|
91
91
|
const badges = this.data.badges?.length ? this.data.badges.map((badge) => `[${badge}]`).join(" ") : "";
|
|
92
92
|
this.cacheLines = [
|
|
93
|
-
this.theme.fg("dim", pad(truncate(firstParts.join(" • "), lineWidth
|
|
94
|
-
this.theme.fg("dim", pad(truncate(usageLine, lineWidth
|
|
95
|
-
this.theme.fg("dim", pad(truncate(badges, lineWidth
|
|
93
|
+
this.theme.fg("dim", pad(truncate(firstParts.join(" • "), lineWidth), lineWidth)),
|
|
94
|
+
this.theme.fg("dim", pad(truncate(usageLine, lineWidth), lineWidth)),
|
|
95
|
+
this.theme.fg("dim", pad(truncate(badges, lineWidth), lineWidth)),
|
|
96
96
|
];
|
|
97
97
|
this.cacheKey = key;
|
|
98
98
|
this.cacheWidth = width;
|
|
@@ -86,7 +86,7 @@ export class CrewSelectList<T = string> {
|
|
|
86
86
|
const suffix = item.description ? this.theme.fg("dim", ` — ${item.description}`) : "";
|
|
87
87
|
const raw = `${prefix}${item.label}${suffix}`;
|
|
88
88
|
const line = index === this.selectedIndex ? this.theme.inverse?.(raw) ?? raw : raw;
|
|
89
|
-
lines.push(pad(truncate(line, width
|
|
89
|
+
lines.push(pad(truncate(line, width), Math.max(1, width)));
|
|
90
90
|
}
|
|
91
91
|
if (hasBottom) lines.push(this.theme.fg("muted", `↓ ${this.items.length - (this.scrollOffset + slots)} more`));
|
|
92
92
|
return lines.slice(0, maxHeight);
|
|
@@ -6,6 +6,28 @@ import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
|
|
|
6
6
|
import { formatCost } from "../../state/usage.ts";
|
|
7
7
|
import { listLiveAgents, listLiveAgentsByWorkspace, type LiveAgentHandle } from "../../runtime/live-agent-manager.ts";
|
|
8
8
|
import { computeLiveDurationMs } from "../live-duration.ts";
|
|
9
|
+
import { visibleWidth } from "../../utils/visual.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fixed visible widths for the per-agent numeric metrics (finding V-1).
|
|
13
|
+
* Right-aligning each field to a constant width stops the right edge of the
|
|
14
|
+
* stats block from jittering every tick as values change width
|
|
15
|
+
* (e.g. `9.9s`→`10.0s`, `950`→`1.0k`, `$0.9`→`$1.0`).
|
|
16
|
+
*/
|
|
17
|
+
const TOKENS_METRIC_WIDTH = 5; // `1.2k`, `12.3k`, `123`
|
|
18
|
+
const COST_METRIC_WIDTH = 7; // `$0.0500`, `$1.50`
|
|
19
|
+
const DURATION_METRIC_WIDTH = 6; // `3.0s`, `59.9s`, `120.0s`
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Width-aware right-align (padStart) for a numeric metric so its column stays
|
|
23
|
+
* stable across ticks. Uses `visibleWidth` so wide/CJK glyphs never misalign
|
|
24
|
+
* the padding. Values wider than `width` are returned verbatim (no truncation)
|
|
25
|
+
* so outliers overflow gracefully instead of crashing the render.
|
|
26
|
+
*/
|
|
27
|
+
function alignMetric(value: string, width: number): string {
|
|
28
|
+
const gap = width - visibleWidth(value);
|
|
29
|
+
return gap > 0 ? " ".repeat(gap) + value : value;
|
|
30
|
+
}
|
|
9
31
|
|
|
10
32
|
/**
|
|
11
33
|
* Returns true if this agent did real work (LLM call, tool use, or non-trivial duration).
|
|
@@ -89,12 +111,12 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
|
|
|
89
111
|
const tokenTotal = (agent.usage?.input ?? 0) + (agent.usage?.output ?? 0) + (agent.usage?.cacheRead ?? 0) + (agent.usage?.cacheWrite ?? 0);
|
|
90
112
|
if (tokenTotal > 0) {
|
|
91
113
|
const tok = tokenTotal >= 1000 ? `${(tokenTotal / 1000).toFixed(1)}k` : `${tokenTotal}`;
|
|
92
|
-
stats.push(tok);
|
|
114
|
+
stats.push(alignMetric(tok, TOKENS_METRIC_WIDTH));
|
|
93
115
|
}
|
|
94
116
|
// Per-agent cost (Round 17 BS-1): the data is already on task.usage.cost;
|
|
95
117
|
// surface it live so the user sees $ burn per agent during a run.
|
|
96
118
|
if (agent.usage?.cost && agent.usage.cost > 0) {
|
|
97
|
-
stats.push(formatCost(agent.usage.cost));
|
|
119
|
+
stats.push(alignMetric(formatCost(agent.usage.cost), COST_METRIC_WIDTH));
|
|
98
120
|
}
|
|
99
121
|
if (liveHandle) {
|
|
100
122
|
// Round 23 (BUG 1): the duration math here was naive —
|
|
@@ -104,13 +126,13 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
|
|
|
104
126
|
// fired for EVERY running live agent in the dashboard. Use the shared,
|
|
105
127
|
// validated computeLiveDurationMs (mirrors widget-formatters.ts).
|
|
106
128
|
const ms = computeLiveDurationMs(liveHandle.activity);
|
|
107
|
-
stats.push(`${(ms / 1000).toFixed(1)}s
|
|
129
|
+
stats.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
|
|
108
130
|
if (options.showModel !== false && liveHandle.modelName && liveHandle.modelName !== "default") {
|
|
109
131
|
stats.push(liveHandle.modelName);
|
|
110
132
|
}
|
|
111
133
|
} else if (agent.startedAt) {
|
|
112
134
|
const ms = Date.now() - new Date(agent.startedAt).getTime();
|
|
113
|
-
if (Number.isFinite(ms)) stats.push(`${(ms / 1000).toFixed(1)}s
|
|
135
|
+
if (Number.isFinite(ms)) stats.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
|
|
114
136
|
}
|
|
115
137
|
|
|
116
138
|
const statsStr = stats.length ? ` · ${stats.join(" ")}` : "";
|
|
@@ -40,4 +40,27 @@ export function renderCancellationPane(manifest: TeamRunManifest, tasks: TeamTas
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
return lines;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* D-1 / L-2 — a one-line terminal-run reason for the dashboard detail row.
|
|
47
|
+
*
|
|
48
|
+
* Surfaces *why* a failed/cancelled/stopped run ended without forcing the
|
|
49
|
+
* user to switch panes. This also gives this module a real consumer (it was
|
|
50
|
+
* previously zero-importer dead code per the UI/UX review), wiring it in as
|
|
51
|
+
* the natural home for cancellation/failure reason display.
|
|
52
|
+
*
|
|
53
|
+
* Resolution order: structured `run.cancelled` event reason → first failed
|
|
54
|
+
* task's error → first policy-decision message.
|
|
55
|
+
*/
|
|
56
|
+
export function summarizeTerminalReason(
|
|
57
|
+
manifest: TeamRunManifest,
|
|
58
|
+
tasks: TeamTaskState[],
|
|
59
|
+
cancellationReason?: string,
|
|
60
|
+
): string | undefined {
|
|
61
|
+
if (cancellationReason) return cancellationReason;
|
|
62
|
+
const failed = tasks.find((task) => task.status === "failed" && task.error);
|
|
63
|
+
if (failed?.error) return failed.error;
|
|
64
|
+
if (manifest.policyDecisions?.length) return manifest.policyDecisions[0]?.message;
|
|
65
|
+
return undefined;
|
|
43
66
|
}
|
package/src/ui/keybinding-map.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
export const DASHBOARD_KEYS = {
|
|
27
27
|
close: ["q", "\u001b"],
|
|
28
28
|
select: ["\r", "\n", "s"],
|
|
29
|
+
help: ["?"],
|
|
29
30
|
root: {
|
|
30
31
|
summary: ["u"],
|
|
31
32
|
artifacts: ["a"],
|
|
@@ -67,6 +68,7 @@ export interface KeyBinding {
|
|
|
67
68
|
|
|
68
69
|
export type DashboardKeyAction =
|
|
69
70
|
| "close"
|
|
71
|
+
| "help"
|
|
70
72
|
| "select"
|
|
71
73
|
| "summary"
|
|
72
74
|
| "artifacts"
|
|
@@ -112,6 +114,7 @@ export type DashboardKeyAction =
|
|
|
112
114
|
*/
|
|
113
115
|
const BINDINGS: readonly KeyBinding[] = [
|
|
114
116
|
{ keys: DASHBOARD_KEYS.close, action: "close" },
|
|
117
|
+
{ keys: DASHBOARD_KEYS.help, action: "help" },
|
|
115
118
|
{ keys: DASHBOARD_KEYS.mailbox.openDetail, action: "mailbox-detail", pane: "mailbox" },
|
|
116
119
|
{ keys: DASHBOARD_KEYS.health.recovery, action: "health-recovery", pane: "health" },
|
|
117
120
|
{ keys: DASHBOARD_KEYS.health.killStale, action: "health-kill-stale", pane: "health" },
|
|
@@ -145,13 +148,14 @@ const BINDINGS: readonly KeyBinding[] = [
|
|
|
145
148
|
* overlays. Derived from `DASHBOARD_KEYS` (the full key set) rather than from
|
|
146
149
|
* `BINDINGS` (the dispatched subset) so overlay-handled keys stay reserved.
|
|
147
150
|
*
|
|
148
|
-
* @internal
|
|
149
|
-
*
|
|
150
|
-
* dashboard ecosystem owns.
|
|
151
|
+
* @internal Consumed by `test/unit/keybinding-map.parity.test.ts` (asserts
|
|
152
|
+
* reserved-key membership) and the L2 dispatch smoke script. It is the
|
|
153
|
+
* canonical "keys the dashboard ecosystem owns" set — NOT dead code.
|
|
151
154
|
*/
|
|
152
155
|
const KEY_RESERVED = new Set<string>([
|
|
153
156
|
...DASHBOARD_KEYS.close,
|
|
154
157
|
...DASHBOARD_KEYS.select,
|
|
158
|
+
...DASHBOARD_KEYS.help,
|
|
155
159
|
...Object.values(DASHBOARD_KEYS.root).flat(),
|
|
156
160
|
...Object.values(DASHBOARD_KEYS.pane).flat(),
|
|
157
161
|
...Object.values(DASHBOARD_KEYS.navigation).flat(),
|
|
@@ -8,6 +8,7 @@ import type { LiveAgentHandle } from "../runtime/live-agent-manager.ts";
|
|
|
8
8
|
import { iconForStatus } from "./status-colors.ts";
|
|
9
9
|
import { spinnerFrame } from "./spinner.ts";
|
|
10
10
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
11
|
+
import { pad, truncate } from "../utils/visual.ts";
|
|
11
12
|
|
|
12
13
|
const CHROME_LINES = 6;
|
|
13
14
|
const MIN_VIEWPORT = 3;
|
|
@@ -119,9 +120,8 @@ export class LiveConversationOverlay {
|
|
|
119
120
|
if (w < 6) return [];
|
|
120
121
|
const th = this.theme;
|
|
121
122
|
const innerW = w - 4;
|
|
122
|
-
const pad = (s: string, len: number) => s + " ".repeat(Math.max(0, len - s.length));
|
|
123
123
|
const row = (content: string) =>
|
|
124
|
-
th.fg("border", "│") + " " + pad(content
|
|
124
|
+
th.fg("border", "│") + " " + pad(truncate(content, innerW), innerW) + " " + th.fg("border", "│");
|
|
125
125
|
const hrTop = th.fg("border", `╭${"─".repeat(w - 2)}╮`);
|
|
126
126
|
const hrBot = th.fg("border", `╰${"─".repeat(w - 2)}╯`);
|
|
127
127
|
const hrMid = row(th.fg("dim", "─".repeat(innerW)));
|
|
@@ -8,7 +8,7 @@ import { aggregateUsage, formatUsage } from "../state/usage.ts";
|
|
|
8
8
|
import type { TeamTaskState } from "../state/types.ts";
|
|
9
9
|
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
|
|
10
10
|
import { pad, truncate } from "../utils/visual.ts";
|
|
11
|
-
import { iconForStatus } from "./status-colors.ts";
|
|
11
|
+
import { colorizeStatusGlyphs, iconForStatus } from "./status-colors.ts";
|
|
12
12
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
13
13
|
import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
|
|
14
14
|
import { Box, Text } from "./layout-primitives.ts";
|
|
@@ -93,13 +93,11 @@ export class LiveRunSidebar {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
private colorLine(line: string): string {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
};
|
|
102
|
-
return line.replace(/[✓✗■⏸◦·▶]/g, (icon) => this.theme.fg(iconColor(icon), icon));
|
|
96
|
+
// F-1 / V-3: delegate to the shared glyph colorizer so ⏳ (waiting),
|
|
97
|
+
// ⚠ (needs_attention) and the braille spinner frames are colored
|
|
98
|
+
// consistently with the rest of the UI. The previous local map/regex
|
|
99
|
+
// omitted all three, leaving the most attention-demanding states uncolored.
|
|
100
|
+
return colorizeStatusGlyphs(line, this.theme);
|
|
103
101
|
}
|
|
104
102
|
|
|
105
103
|
invalidate(): void {
|
|
@@ -150,10 +148,17 @@ export class LiveRunSidebar {
|
|
|
150
148
|
const waiting = tasks.filter((task) => task.status === "queued");
|
|
151
149
|
const signature = this.buildSignature(run.updatedAt, tasks, agents, waiting.length, snapshot);
|
|
152
150
|
if (signature !== this.cachedSignature || w !== this.cachedWidth) {
|
|
151
|
+
// L-2: surface the cancellation/failure reason for terminal runs so the
|
|
152
|
+
// user sees *why* a run ended without having to switch panes. The reason
|
|
153
|
+
// is already computed on the consumed snapshot (cancellationReason).
|
|
154
|
+
const TERMINAL_WITH_REASON = ["failed", "cancelled", "stopped"];
|
|
155
|
+
const reasonSuffix = TERMINAL_WITH_REASON.includes(run.status) && snapshot?.cancellationReason
|
|
156
|
+
? ` · ${truncate(snapshot.cancellationReason, 40)}`
|
|
157
|
+
: "";
|
|
153
158
|
const lines: string[] = [
|
|
154
159
|
border("╭", "─", "╮", w),
|
|
155
160
|
line(`${this.theme.fg("accent", "▐")} ${this.theme.bold("pi-crew live sidebar")}`, w),
|
|
156
|
-
line(`${run.runId.slice(-12)} · ${run.status} · right default`, w),
|
|
161
|
+
line(`${run.runId.slice(-12)} · ${run.status}${reasonSuffix} · right default`, w),
|
|
157
162
|
line(`${run.team}/${run.workflow ?? "none"} · ${shortUsage(tasks)}`, w),
|
|
158
163
|
border("├", "─", "┤", w),
|
|
159
164
|
line(`Active agents (${active.length})`, w),
|
|
@@ -180,7 +185,9 @@ export class LiveRunSidebar {
|
|
|
180
185
|
if (completed.length === 0) lines.push(line("- none", w));
|
|
181
186
|
lines.push(border("├", "─", "┤", w));
|
|
182
187
|
for (const entry of formatTaskGraphLines(tasks).slice(0, 6)) lines.push(line(entry, w));
|
|
183
|
-
lines.push(line("q close · /team-dashboard details", w)
|
|
188
|
+
lines.push(line("q close · /team-dashboard details", w));
|
|
189
|
+
// F-6: compute the auto-close countdown BEFORE pushing the bottom border
|
|
190
|
+
// so the countdown renders inside the bordered box rather than below it.
|
|
184
191
|
// Auto-close logic: if run is terminal and no active agents, close after delay
|
|
185
192
|
const isTerminal = ["completed", "failed", "cancelled", "blocked"].includes(run.status);
|
|
186
193
|
const hasActiveAgents = agents.some((a) => a.status === "running");
|
|
@@ -199,6 +206,7 @@ export class LiveRunSidebar {
|
|
|
199
206
|
clearTimeout(this.autoCloseTimeout);
|
|
200
207
|
this.autoCloseTimeout = undefined;
|
|
201
208
|
}
|
|
209
|
+
lines.push(border("╰", "─", "╯", w));
|
|
202
210
|
this.cachedLines = renderLines(lines.map((entry) => this.colorLine(entry)), w);
|
|
203
211
|
this.cachedSignature = signature;
|
|
204
212
|
this.cachedWidth = w;
|
|
@@ -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
|
+
}
|