pi-crew 0.1.32 → 0.1.34
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/docs/architecture.md +3 -3
- package/docs/research-phase8-operator-experience-plan.md +819 -0
- package/docs/research-phase9-observability-reliability-plan.md +1190 -0
- package/docs/research-ui-optimization-plan.md +480 -0
- package/package.json +1 -1
- package/schema.json +14 -0
- package/src/config/config.ts +69 -0
- package/src/config/defaults.ts +7 -0
- package/src/extension/autonomous-policy.ts +56 -2
- package/src/extension/notification-router.ts +116 -0
- package/src/extension/notification-sink.ts +51 -0
- package/src/extension/register.ts +133 -35
- package/src/extension/registration/commands.ts +110 -3
- package/src/extension/registration/team-tool.ts +5 -2
- package/src/extension/registration/viewers.ts +3 -1
- package/src/extension/team-recommendation.ts +16 -8
- package/src/runtime/child-pi.ts +1 -0
- package/src/runtime/diagnostic-export.ts +107 -0
- package/src/runtime/pi-spawn.ts +4 -1
- package/src/runtime/task-packet.ts +11 -2
- package/src/runtime/task-runner/prompt-builder.ts +3 -0
- package/src/schema/config-schema.ts +11 -0
- package/src/ui/crew-widget.ts +350 -285
- package/src/ui/dashboard-panes/agents-pane.ts +25 -0
- package/src/ui/dashboard-panes/health-pane.ts +30 -0
- package/src/ui/dashboard-panes/mailbox-pane.ts +10 -0
- package/src/ui/dashboard-panes/progress-pane.ts +14 -0
- package/src/ui/dashboard-panes/transcript-pane.ts +10 -0
- package/src/ui/heartbeat-aggregator.ts +53 -0
- package/src/ui/keybinding-map.ts +92 -0
- package/src/ui/live-run-sidebar.ts +20 -8
- package/src/ui/overlays/agent-picker-overlay.ts +57 -0
- package/src/ui/overlays/confirm-overlay.ts +58 -0
- package/src/ui/overlays/mailbox-compose-overlay.ts +144 -0
- package/src/ui/overlays/mailbox-compose-preview.ts +63 -0
- package/src/ui/overlays/mailbox-detail-overlay.ts +122 -0
- package/src/ui/pi-ui-compat.ts +57 -0
- package/src/ui/powerbar-publisher.ts +128 -94
- package/src/ui/render-scheduler.ts +103 -0
- package/src/ui/run-action-dispatcher.ts +107 -0
- package/src/ui/run-dashboard.ts +418 -372
- package/src/ui/run-snapshot-cache.ts +359 -0
- package/src/ui/snapshot-types.ts +47 -0
- package/src/ui/transcript-cache.ts +94 -0
- package/src/ui/transcript-viewer.ts +316 -302
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RunDashboardOptions } from "../run-dashboard.ts";
|
|
2
|
+
import type { RunUiSnapshot } from "../snapshot-types.ts";
|
|
3
|
+
|
|
4
|
+
function tokens(agent: RunUiSnapshot["agents"][number]): string {
|
|
5
|
+
const total = (agent.usage?.input ?? 0) + (agent.usage?.output ?? agent.progress?.tokens ?? 0) + (agent.usage?.cacheRead ?? 0) + (agent.usage?.cacheWrite ?? 0);
|
|
6
|
+
return total ? `${total} tok` : "tok pending";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: RunDashboardOptions = {}): string[] {
|
|
10
|
+
if (!snapshot) return ["Agents pane: snapshot unavailable"];
|
|
11
|
+
if (!snapshot.agents.length) return ["Agents pane: no agents"];
|
|
12
|
+
return [
|
|
13
|
+
`Agents pane: ${snapshot.agents.length} agents · ${snapshot.progress.completed}/${snapshot.progress.total} tasks done`,
|
|
14
|
+
...snapshot.agents.slice(0, 12).map((agent) => {
|
|
15
|
+
const parts = [
|
|
16
|
+
agent.status,
|
|
17
|
+
options.showTools !== false && agent.progress?.currentTool ? `tool=${agent.progress.currentTool}` : undefined,
|
|
18
|
+
options.showTools !== false ? `${agent.toolUses ?? agent.progress?.toolCount ?? 0} tools` : undefined,
|
|
19
|
+
options.showTokens !== false ? tokens(agent) : undefined,
|
|
20
|
+
options.showModel !== false ? (agent.model ? `model=${agent.model}` : undefined) : undefined,
|
|
21
|
+
].filter((part): part is string => Boolean(part));
|
|
22
|
+
return `${agent.taskId} ${agent.role}->${agent.agent} · ${parts.join(" · ")}`;
|
|
23
|
+
}),
|
|
24
|
+
];
|
|
25
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { summarizeHeartbeats } from "../heartbeat-aggregator.ts";
|
|
2
|
+
import type { RunUiSnapshot } from "../snapshot-types.ts";
|
|
3
|
+
|
|
4
|
+
export interface HealthPaneOptions {
|
|
5
|
+
staleMs?: number;
|
|
6
|
+
deadMs?: number;
|
|
7
|
+
isForeground?: boolean;
|
|
8
|
+
now?: number | Date;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function seconds(ms: number): string {
|
|
12
|
+
return `${Math.round(ms / 1000)}s`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function renderHealthPane(snapshot: RunUiSnapshot | undefined, opts: HealthPaneOptions = {}): string[] {
|
|
16
|
+
if (!snapshot) return ["Health pane: snapshot unavailable"];
|
|
17
|
+
const summary = summarizeHeartbeats(snapshot, opts);
|
|
18
|
+
const lines = [
|
|
19
|
+
`Health pane: ${summary.healthy}/${summary.totalTasks} healthy · stale=${summary.stale} · dead=${summary.dead} · missing=${summary.missing}`,
|
|
20
|
+
];
|
|
21
|
+
if (summary.worstStaleMs > 0) lines.push(`Worst stale: ${seconds(summary.worstStaleMs)} ago`);
|
|
22
|
+
const hints: string[] = [];
|
|
23
|
+
const foreground = opts.isForeground !== false;
|
|
24
|
+
if ((summary.dead > 0 || summary.missing > 0) && foreground) hints.push("R recovery");
|
|
25
|
+
if ((summary.dead > 0 || summary.stale > 0) && foreground) hints.push("K kill stale");
|
|
26
|
+
hints.push("D diagnostic export");
|
|
27
|
+
lines.push(`Actions: ${hints.join(" · ")}`);
|
|
28
|
+
if (!foreground && (summary.dead > 0 || summary.missing > 0 || summary.stale > 0)) lines.push("Async run: R/K disabled — inspect process manually or use /team-api.");
|
|
29
|
+
return lines;
|
|
30
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RunUiSnapshot } from "../snapshot-types.ts";
|
|
2
|
+
|
|
3
|
+
export function renderMailboxPane(snapshot: RunUiSnapshot | undefined): string[] {
|
|
4
|
+
if (!snapshot) return ["Mailbox pane: snapshot unavailable"];
|
|
5
|
+
const mailbox = snapshot.mailbox;
|
|
6
|
+
return [
|
|
7
|
+
`Mailbox pane: inbox unread=${mailbox.inboxUnread} · outbox pending=${mailbox.outboxPending} · attention=${mailbox.needsAttention}`,
|
|
8
|
+
mailbox.needsAttention > 0 ? "Needs attention: press Enter for detail · A ack · N nudge · C compose · X ack all." : "No mailbox items need attention. Press Enter for detail or C compose.",
|
|
9
|
+
];
|
|
10
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RunUiSnapshot } from "../snapshot-types.ts";
|
|
2
|
+
|
|
3
|
+
export function renderProgressPane(snapshot: RunUiSnapshot | undefined): string[] {
|
|
4
|
+
if (!snapshot) return ["Progress pane: snapshot unavailable"];
|
|
5
|
+
const progress = snapshot.progress;
|
|
6
|
+
return [
|
|
7
|
+
`Progress pane: ${progress.completed}/${progress.total} completed · running=${progress.running} queued=${progress.queued} failed=${progress.failed}`,
|
|
8
|
+
...snapshot.recentEvents.slice(-10).map((event) => {
|
|
9
|
+
const seq = event.metadata?.seq !== undefined ? `#${event.metadata.seq}` : "#?";
|
|
10
|
+
return `${seq} ${event.time} ${event.type}${event.taskId ? ` ${event.taskId}` : ""}${event.message ? ` · ${event.message}` : ""}`;
|
|
11
|
+
}),
|
|
12
|
+
...(snapshot.recentEvents.length ? [] : ["No recent events"]),
|
|
13
|
+
];
|
|
14
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RunUiSnapshot } from "../snapshot-types.ts";
|
|
2
|
+
|
|
3
|
+
export function renderTranscriptPane(snapshot: RunUiSnapshot | undefined): string[] {
|
|
4
|
+
if (!snapshot) return ["Output pane: snapshot unavailable"];
|
|
5
|
+
return [
|
|
6
|
+
`Output pane: ${snapshot.recentOutputLines.length} recent lines · press v for transcript viewer · o for raw output`,
|
|
7
|
+
...snapshot.recentOutputLines.slice(-12).map((line) => `⎿ ${line}`),
|
|
8
|
+
...(snapshot.recentOutputLines.length ? [] : ["No recent output"]),
|
|
9
|
+
];
|
|
10
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { TeamTaskState } from "../state/types.ts";
|
|
2
|
+
import type { RunUiSnapshot } from "./snapshot-types.ts";
|
|
3
|
+
|
|
4
|
+
export interface HeartbeatSummary {
|
|
5
|
+
runId: string;
|
|
6
|
+
totalTasks: number;
|
|
7
|
+
healthy: number;
|
|
8
|
+
stale: number;
|
|
9
|
+
dead: number;
|
|
10
|
+
missing: number;
|
|
11
|
+
worstStaleMs: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface HeartbeatSummaryOptions {
|
|
15
|
+
staleMs?: number;
|
|
16
|
+
deadMs?: number;
|
|
17
|
+
now?: number | Date;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function nowMs(now: number | Date | undefined): number {
|
|
21
|
+
if (typeof now === "number") return now;
|
|
22
|
+
if (now instanceof Date) return now.getTime();
|
|
23
|
+
return Date.now();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isActiveTask(task: TeamTaskState): boolean {
|
|
27
|
+
return task.status === "running" || task.status === "queued";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function summarizeHeartbeats(snapshot: RunUiSnapshot, opts: HeartbeatSummaryOptions = {}): HeartbeatSummary {
|
|
31
|
+
const staleMs = opts.staleMs ?? 60_000;
|
|
32
|
+
const deadMs = opts.deadMs ?? 5 * 60_000;
|
|
33
|
+
const current = nowMs(opts.now);
|
|
34
|
+
const summary: HeartbeatSummary = { runId: snapshot.runId, totalTasks: snapshot.tasks.length, healthy: 0, stale: 0, dead: 0, missing: 0, worstStaleMs: 0 };
|
|
35
|
+
for (const task of snapshot.tasks) {
|
|
36
|
+
if (!isActiveTask(task)) continue;
|
|
37
|
+
const heartbeat = task.heartbeat;
|
|
38
|
+
if (!heartbeat) {
|
|
39
|
+
summary.missing += 1;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const age = Math.max(0, current - Date.parse(heartbeat.lastSeenAt));
|
|
43
|
+
if (!Number.isFinite(age)) {
|
|
44
|
+
summary.missing += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
summary.worstStaleMs = Math.max(summary.worstStaleMs, age);
|
|
48
|
+
if (heartbeat.alive === false || age > deadMs) summary.dead += 1;
|
|
49
|
+
else if (age > staleMs) summary.stale += 1;
|
|
50
|
+
else summary.healthy += 1;
|
|
51
|
+
}
|
|
52
|
+
return summary;
|
|
53
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export const DASHBOARD_KEYS = {
|
|
2
|
+
close: ["q", "\u001b"],
|
|
3
|
+
select: ["\r", "\n", "s"],
|
|
4
|
+
root: {
|
|
5
|
+
summary: ["u"],
|
|
6
|
+
artifacts: ["a"],
|
|
7
|
+
api: ["i"],
|
|
8
|
+
agents: ["d"],
|
|
9
|
+
mailbox: ["m"],
|
|
10
|
+
events: ["e"],
|
|
11
|
+
output: ["o"],
|
|
12
|
+
transcript: ["v"],
|
|
13
|
+
reload: ["r"],
|
|
14
|
+
progressToggle: ["p"],
|
|
15
|
+
},
|
|
16
|
+
pane: { agents: ["1"], progress: ["2"], mailbox: ["3"], output: ["4"], health: ["5"] },
|
|
17
|
+
navigation: { up: ["k", "\u001b[A"], down: ["j", "\u001b[B"] },
|
|
18
|
+
mailbox: { ack: ["A"], nudge: ["N"], compose: ["C"], preview: ["P"], ackAll: ["X"], openDetail: ["\r", "\n"] },
|
|
19
|
+
health: { recovery: ["R"], killStale: ["K"], diagnosticExport: ["D"] },
|
|
20
|
+
notification: { dismissAll: ["H"] },
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export const KEY_RESERVED = new Set<string>([
|
|
24
|
+
...DASHBOARD_KEYS.close,
|
|
25
|
+
...DASHBOARD_KEYS.select,
|
|
26
|
+
...Object.values(DASHBOARD_KEYS.root).flat(),
|
|
27
|
+
...Object.values(DASHBOARD_KEYS.pane).flat(),
|
|
28
|
+
...Object.values(DASHBOARD_KEYS.navigation).flat(),
|
|
29
|
+
...Object.values(DASHBOARD_KEYS.mailbox).flat(),
|
|
30
|
+
...Object.values(DASHBOARD_KEYS.health).flat(),
|
|
31
|
+
...Object.values(DASHBOARD_KEYS.notification).flat(),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function includes(values: readonly string[], data: string): boolean {
|
|
35
|
+
return values.includes(data);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type DashboardKeyAction =
|
|
39
|
+
| "close"
|
|
40
|
+
| "select"
|
|
41
|
+
| "summary"
|
|
42
|
+
| "artifacts"
|
|
43
|
+
| "api"
|
|
44
|
+
| "agents"
|
|
45
|
+
| "mailbox"
|
|
46
|
+
| "events"
|
|
47
|
+
| "output"
|
|
48
|
+
| "transcript"
|
|
49
|
+
| "reload"
|
|
50
|
+
| "progressToggle"
|
|
51
|
+
| "pane-agents"
|
|
52
|
+
| "pane-progress"
|
|
53
|
+
| "pane-mailbox"
|
|
54
|
+
| "pane-output"
|
|
55
|
+
| "pane-health"
|
|
56
|
+
| "up"
|
|
57
|
+
| "down"
|
|
58
|
+
| "mailbox-detail"
|
|
59
|
+
| "health-recovery"
|
|
60
|
+
| "health-kill-stale"
|
|
61
|
+
| "health-diagnostic-export"
|
|
62
|
+
| "notifications-dismiss";
|
|
63
|
+
|
|
64
|
+
export function dashboardActionForKey(data: string, activePane?: "agents" | "progress" | "mailbox" | "output" | "health"): DashboardKeyAction | undefined {
|
|
65
|
+
if (includes(DASHBOARD_KEYS.close, data)) return "close";
|
|
66
|
+
if (activePane === "mailbox" && includes(DASHBOARD_KEYS.mailbox.openDetail, data)) return "mailbox-detail";
|
|
67
|
+
if (activePane === "health") {
|
|
68
|
+
if (includes(DASHBOARD_KEYS.health.recovery, data)) return "health-recovery";
|
|
69
|
+
if (includes(DASHBOARD_KEYS.health.killStale, data)) return "health-kill-stale";
|
|
70
|
+
if (includes(DASHBOARD_KEYS.health.diagnosticExport, data)) return "health-diagnostic-export";
|
|
71
|
+
}
|
|
72
|
+
if (includes(DASHBOARD_KEYS.notification.dismissAll, data)) return "notifications-dismiss";
|
|
73
|
+
if (includes(DASHBOARD_KEYS.select, data)) return "select";
|
|
74
|
+
if (includes(DASHBOARD_KEYS.root.summary, data)) return "summary";
|
|
75
|
+
if (includes(DASHBOARD_KEYS.root.artifacts, data)) return "artifacts";
|
|
76
|
+
if (includes(DASHBOARD_KEYS.root.api, data)) return "api";
|
|
77
|
+
if (includes(DASHBOARD_KEYS.root.agents, data)) return "agents";
|
|
78
|
+
if (includes(DASHBOARD_KEYS.root.mailbox, data)) return "mailbox";
|
|
79
|
+
if (includes(DASHBOARD_KEYS.root.events, data)) return "events";
|
|
80
|
+
if (includes(DASHBOARD_KEYS.root.output, data)) return "output";
|
|
81
|
+
if (includes(DASHBOARD_KEYS.root.transcript, data)) return "transcript";
|
|
82
|
+
if (includes(DASHBOARD_KEYS.root.reload, data)) return "reload";
|
|
83
|
+
if (includes(DASHBOARD_KEYS.root.progressToggle, data)) return "progressToggle";
|
|
84
|
+
if (includes(DASHBOARD_KEYS.pane.agents, data)) return "pane-agents";
|
|
85
|
+
if (includes(DASHBOARD_KEYS.pane.progress, data)) return "pane-progress";
|
|
86
|
+
if (includes(DASHBOARD_KEYS.pane.mailbox, data)) return "pane-mailbox";
|
|
87
|
+
if (includes(DASHBOARD_KEYS.pane.output, data)) return "pane-output";
|
|
88
|
+
if (includes(DASHBOARD_KEYS.pane.health, data)) return "pane-health";
|
|
89
|
+
if (includes(DASHBOARD_KEYS.navigation.up, data)) return "up";
|
|
90
|
+
if (includes(DASHBOARD_KEYS.navigation.down, data)) return "down";
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
@@ -12,6 +12,7 @@ import { 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";
|
|
15
|
+
import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
|
|
15
16
|
|
|
16
17
|
const TASK_READ_TTL_MS = 200;
|
|
17
18
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
@@ -58,22 +59,26 @@ export class LiveRunSidebar {
|
|
|
58
59
|
private readonly theme: CrewTheme;
|
|
59
60
|
private readonly config: CrewUiConfig;
|
|
60
61
|
private readonly unsubscribeTheme: () => void;
|
|
62
|
+
private readonly snapshotCache?: RunSnapshotCache;
|
|
61
63
|
private cachedLines: string[] = [];
|
|
62
64
|
private cachedWidth = 0;
|
|
63
65
|
private cachedSignature = "";
|
|
64
66
|
|
|
65
|
-
constructor(input: { cwd: string; runId: string; done: Done; theme?: unknown; config?: CrewUiConfig }) {
|
|
67
|
+
constructor(input: { cwd: string; runId: string; done: Done; theme?: unknown; config?: CrewUiConfig; snapshotCache?: RunSnapshotCache }) {
|
|
66
68
|
this.cwd = input.cwd;
|
|
67
69
|
this.runId = input.runId;
|
|
68
70
|
this.done = input.done;
|
|
69
71
|
this.theme = asCrewTheme(input.theme);
|
|
70
72
|
this.config = input.config ?? {};
|
|
73
|
+
this.snapshotCache = input.snapshotCache;
|
|
71
74
|
this.unsubscribeTheme = subscribeThemeChange(input.theme, () => this.invalidate());
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
private buildSignature(manifestStatus: string, tasks: TeamTaskState[],
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
private buildSignature(manifestStatus: string, tasks: TeamTaskState[], agents: ReturnType<typeof readCrewAgents>, waitingCount: number, snapshot?: RunUiSnapshot): string {
|
|
78
|
+
if (snapshot) return `${snapshot.signature}:${waitingCount}`;
|
|
79
|
+
const taskSig = tasks.map((task) => `${task.id}:${task.status}:${task.startedAt ?? ""}:${task.finishedAt ?? ""}:${task.agentProgress?.currentTool ?? ""}:${task.agentProgress?.toolCount ?? 0}:${task.agentProgress?.tokens ?? 0}:${task.usage ? JSON.stringify(task.usage) : ""}`).join("|");
|
|
80
|
+
const agentSig = agents.map((agent) => [agent.id, agent.status, agent.startedAt, agent.completedAt ?? "", agent.progress?.currentTool ?? "", agent.progress?.toolCount ?? 0, agent.progress?.tokens ?? 0, agent.progress?.turns ?? 0, agent.progress?.lastActivityAt ?? "", agent.progress?.recentOutput.at(-1) ?? "", agent.toolUses ?? 0].join(":")).join("|");
|
|
81
|
+
return `${manifestStatus}|${agents.length}|${waitingCount}|${taskSig}|${agentSig}`;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
private colorLine(line: string): string {
|
|
@@ -110,14 +115,21 @@ export class LiveRunSidebar {
|
|
|
110
115
|
);
|
|
111
116
|
}
|
|
112
117
|
|
|
113
|
-
|
|
114
|
-
|
|
118
|
+
let snapshot: RunUiSnapshot | undefined;
|
|
119
|
+
try {
|
|
120
|
+
snapshot = this.snapshotCache?.refreshIfStale(this.runId);
|
|
121
|
+
} catch {
|
|
122
|
+
snapshot = undefined;
|
|
123
|
+
}
|
|
124
|
+
const run = snapshot?.manifest ?? loaded.manifest;
|
|
125
|
+
const tasks = snapshot?.tasks ?? readTasks(run.tasksPath);
|
|
115
126
|
const controlConfig = resolveCrewControlConfig({ ui: this.config });
|
|
116
|
-
const
|
|
127
|
+
const rawAgents = snapshot?.agents ?? readCrewAgents(run);
|
|
128
|
+
const agents = rawAgents.map((agent) => applyAttentionState(run, agent, controlConfig));
|
|
117
129
|
const active = agents.filter((agent) => agent.status === "running");
|
|
118
130
|
const completed = agents.filter((agent) => agent.status !== "running").slice(-5);
|
|
119
131
|
const waiting = tasks.filter((task) => task.status === "queued");
|
|
120
|
-
const signature = this.buildSignature(run.updatedAt, tasks, agents
|
|
132
|
+
const signature = this.buildSignature(run.updatedAt, tasks, agents, waiting.length, snapshot);
|
|
121
133
|
if (signature !== this.cachedSignature || w !== this.cachedWidth) {
|
|
122
134
|
const lines: string[] = [
|
|
123
135
|
border("╭", "─", "╮", w),
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
|
|
2
|
+
import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
|
|
3
|
+
import { loadRunManifestById } from "../../state/state-store.ts";
|
|
4
|
+
import { pad, truncate } from "../../utils/visual.ts";
|
|
5
|
+
import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
|
|
6
|
+
|
|
7
|
+
export interface AgentPickerSelection {
|
|
8
|
+
agentId: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class AgentPickerOverlay {
|
|
12
|
+
private readonly agents: CrewAgentRecord[];
|
|
13
|
+
private readonly done: (selection: AgentPickerSelection | undefined) => void;
|
|
14
|
+
private readonly theme: CrewTheme;
|
|
15
|
+
private selected = 0;
|
|
16
|
+
|
|
17
|
+
constructor(opts: { cwd: string; runId: string; done: (selection: AgentPickerSelection | undefined) => void; theme?: unknown }) {
|
|
18
|
+
const loaded = loadRunManifestById(opts.cwd, opts.runId);
|
|
19
|
+
this.agents = loaded ? readCrewAgents(loaded.manifest) : [];
|
|
20
|
+
this.done = opts.done;
|
|
21
|
+
this.theme = asCrewTheme(opts.theme ?? {});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
invalidate(): void {
|
|
25
|
+
// Agent list is captured at open time.
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
render(width: number): string[] {
|
|
29
|
+
const inner = Math.max(24, width - 4);
|
|
30
|
+
const lines = [
|
|
31
|
+
this.theme.bold("Select agent"),
|
|
32
|
+
"↑/↓ move · Enter select · ESC cancel",
|
|
33
|
+
...this.agents.map((agent, index) => `${index === this.selected ? "›" : " "} ${agent.taskId} · ${agent.status} · ${agent.role}->${agent.agent}`),
|
|
34
|
+
];
|
|
35
|
+
if (!this.agents.length) lines.push("No agents found.");
|
|
36
|
+
return lines.map((line) => pad(truncate(line, inner), inner));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
handleInput(data: string): void {
|
|
40
|
+
if (data === "\u001b" || data === "q") {
|
|
41
|
+
this.done(undefined);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (data === "k" || data === "\u001b[A") {
|
|
45
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (data === "j" || data === "\u001b[B") {
|
|
49
|
+
this.selected = Math.min(Math.max(0, this.agents.length - 1), this.selected + 1);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (data === "\r" || data === "\n") {
|
|
53
|
+
const agent = this.agents[this.selected];
|
|
54
|
+
this.done(agent ? { agentId: agent.taskId } : undefined);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Box, Text } from "../layout-primitives.ts";
|
|
2
|
+
import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
|
|
3
|
+
import { pad, truncate } from "../../utils/visual.ts";
|
|
4
|
+
|
|
5
|
+
export interface ConfirmOptions {
|
|
6
|
+
title: string;
|
|
7
|
+
body?: string;
|
|
8
|
+
dangerLevel?: "low" | "medium" | "high";
|
|
9
|
+
defaultAction?: "confirm" | "cancel";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class ConfirmOverlay {
|
|
13
|
+
private readonly opts: ConfirmOptions;
|
|
14
|
+
private readonly done: (confirmed: boolean) => void;
|
|
15
|
+
private readonly theme: CrewTheme;
|
|
16
|
+
|
|
17
|
+
constructor(opts: ConfirmOptions, done: (confirmed: boolean) => void, theme: unknown = {}) {
|
|
18
|
+
this.opts = opts;
|
|
19
|
+
this.done = done;
|
|
20
|
+
this.theme = asCrewTheme(theme);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
invalidate(): void {
|
|
24
|
+
// Stateless overlay.
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
render(width: number): string[] {
|
|
28
|
+
const innerWidth = Math.max(24, Math.min(width - 4, 72));
|
|
29
|
+
const color = this.opts.dangerLevel === "high" ? "error" : this.opts.dangerLevel === "medium" ? "warning" : "accent";
|
|
30
|
+
const title = this.theme.bold(this.theme.fg(color, this.opts.title));
|
|
31
|
+
const hint = this.opts.defaultAction === "confirm" ? "Enter/Y confirm · N/ESC cancel" : "Y confirm · Enter/N/ESC cancel";
|
|
32
|
+
const bodyLines = (this.opts.body ?? "").split(/\r?\n/).filter(Boolean);
|
|
33
|
+
const lines = [
|
|
34
|
+
`╭${"─".repeat(innerWidth)}╮`,
|
|
35
|
+
`│ ${pad(truncate(title, innerWidth - 1), innerWidth - 1)}│`,
|
|
36
|
+
`├${"─".repeat(innerWidth)}┤`,
|
|
37
|
+
...(bodyLines.length ? bodyLines : ["Are you sure?"]).map((line) => `│ ${pad(truncate(line, innerWidth - 1), innerWidth - 1)}│`),
|
|
38
|
+
`├${"─".repeat(innerWidth)}┤`,
|
|
39
|
+
`│ ${pad(truncate(this.theme.fg("dim", hint), innerWidth - 1), innerWidth - 1)}│`,
|
|
40
|
+
`╰${"─".repeat(innerWidth)}╯`,
|
|
41
|
+
];
|
|
42
|
+
const box = new Box(0, 0);
|
|
43
|
+
for (const line of lines) box.addChild(new Text(line));
|
|
44
|
+
return box.render(width);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
handleInput(data: string): void {
|
|
48
|
+
if (data === "y" || data === "Y") {
|
|
49
|
+
this.done(true);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if ((data === "\r" || data === "\n") && this.opts.defaultAction === "confirm") {
|
|
53
|
+
this.done(true);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (data === "n" || data === "N" || data === "\u001b" || data === "q" || data === "\r" || data === "\n") this.done(false);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { MailboxDirection } from "../../state/mailbox.ts";
|
|
2
|
+
import { pad, truncate } from "../../utils/visual.ts";
|
|
3
|
+
import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
|
|
4
|
+
import { ConfirmOverlay } from "./confirm-overlay.ts";
|
|
5
|
+
import { renderComposePreview } from "./mailbox-compose-preview.ts";
|
|
6
|
+
|
|
7
|
+
export interface MailboxComposePayload {
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
body: string;
|
|
11
|
+
taskId?: string;
|
|
12
|
+
direction: MailboxDirection;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type MailboxComposeResult = { type: "submit"; payload: MailboxComposePayload } | { type: "cancel" };
|
|
16
|
+
|
|
17
|
+
type FieldName = "from" | "to" | "body" | "taskId" | "direction";
|
|
18
|
+
|
|
19
|
+
const FIELD_ORDER: FieldName[] = ["from", "to", "body", "taskId", "direction"];
|
|
20
|
+
|
|
21
|
+
export class MailboxComposeOverlay {
|
|
22
|
+
private readonly done: (result: MailboxComposeResult) => void;
|
|
23
|
+
private readonly theme: CrewTheme;
|
|
24
|
+
private fields: MailboxComposePayload = { from: "operator", to: "leader", body: "", direction: "inbox" };
|
|
25
|
+
private activeField = 1;
|
|
26
|
+
private error: string | undefined;
|
|
27
|
+
private preview = false;
|
|
28
|
+
private confirm: ConfirmOverlay | undefined;
|
|
29
|
+
|
|
30
|
+
constructor(opts: { done: (result: MailboxComposeResult) => void; theme?: unknown; initial?: Partial<MailboxComposePayload> }) {
|
|
31
|
+
this.done = opts.done;
|
|
32
|
+
this.theme = asCrewTheme(opts.theme ?? {});
|
|
33
|
+
this.fields = { ...this.fields, ...opts.initial };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
invalidate(): void {
|
|
37
|
+
// State is updated synchronously from input.
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
render(width: number): string[] {
|
|
41
|
+
if (this.confirm) return this.confirm.render(width);
|
|
42
|
+
const inner = Math.max(24, width - 4);
|
|
43
|
+
const formWidth = this.preview ? Math.max(24, Math.floor(inner * 0.6)) : inner;
|
|
44
|
+
const lines = [
|
|
45
|
+
this.theme.bold("Compose mailbox message"),
|
|
46
|
+
this.preview ? "P close preview · Tab cycle · Enter submit · ESC discard" : "P preview · Tab cycle · Enter submit · ESC discard",
|
|
47
|
+
...(this.error ? [this.theme.fg("error", this.error)] : []),
|
|
48
|
+
this.fieldLine("from", formWidth),
|
|
49
|
+
this.fieldLine("to", formWidth),
|
|
50
|
+
this.fieldLine("body", formWidth),
|
|
51
|
+
this.fieldLine("taskId", formWidth),
|
|
52
|
+
`${this.activeField === 4 ? "›" : " "} [${this.fields.direction === "outbox" ? "x" : " "}] Send to outbox`,
|
|
53
|
+
];
|
|
54
|
+
if (!this.preview) return lines.map((line) => pad(truncate(line, inner), inner));
|
|
55
|
+
const previewLines = renderComposePreview(this.fields.body, Math.max(20, inner - formWidth - 3), this.theme);
|
|
56
|
+
const max = Math.max(lines.length, previewLines.length);
|
|
57
|
+
const split: string[] = [];
|
|
58
|
+
for (let index = 0; index < max; index += 1) {
|
|
59
|
+
split.push(`${pad(truncate(lines[index] ?? "", formWidth), formWidth)} │ ${truncate(previewLines[index] ?? "", inner - formWidth - 3)}`);
|
|
60
|
+
}
|
|
61
|
+
return split;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private fieldLine(field: Exclude<FieldName, "direction">, width: number): string {
|
|
65
|
+
const active = FIELD_ORDER[this.activeField] === field;
|
|
66
|
+
const label = field === "taskId" ? "taskId" : field;
|
|
67
|
+
return `${active ? "›" : " "} ${label}: ${truncate(this.fields[field] ?? "", Math.max(8, width - label.length - 5))}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private activeName(): FieldName {
|
|
71
|
+
return FIELD_ORDER[this.activeField] ?? "body";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private appendText(data: string): void {
|
|
75
|
+
const field = this.activeName();
|
|
76
|
+
if (field === "direction") return;
|
|
77
|
+
this.fields = { ...this.fields, [field]: `${this.fields[field] ?? ""}${data}` };
|
|
78
|
+
this.error = undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private backspace(): void {
|
|
82
|
+
const field = this.activeName();
|
|
83
|
+
if (field === "direction") return;
|
|
84
|
+
this.fields = { ...this.fields, [field]: (this.fields[field] ?? "").slice(0, -1) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private submit(): void {
|
|
88
|
+
const body = this.fields.body.trim();
|
|
89
|
+
if (!body) {
|
|
90
|
+
this.error = "Body is required.";
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (!this.fields.to.trim()) {
|
|
94
|
+
this.error = "Recipient is required.";
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.done({ type: "submit", payload: { ...this.fields, from: this.fields.from.trim() || "operator", to: this.fields.to.trim(), body, taskId: this.fields.taskId?.trim() || undefined } });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private cancel(): void {
|
|
101
|
+
if (this.fields.body.length <= 50) {
|
|
102
|
+
this.done({ type: "cancel" });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.confirm = new ConfirmOverlay({ title: "Discard draft?", body: `Body has ${this.fields.body.length} chars. Y=discard, N=continue editing`, dangerLevel: "medium", defaultAction: "cancel" }, (confirmed) => {
|
|
106
|
+
this.confirm = undefined;
|
|
107
|
+
if (confirmed) this.done({ type: "cancel" });
|
|
108
|
+
}, this.theme);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
handleInput(data: string): void {
|
|
112
|
+
if (this.confirm) {
|
|
113
|
+
this.confirm.handleInput(data);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (data === "\u001b") {
|
|
117
|
+
this.cancel();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (data === "P") {
|
|
121
|
+
this.preview = !this.preview;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (data === "\t") {
|
|
125
|
+
this.activeField = (this.activeField + 1) % FIELD_ORDER.length;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (data === " ") {
|
|
129
|
+
if (this.activeName() === "direction") this.fields.direction = this.fields.direction === "inbox" ? "outbox" : "inbox";
|
|
130
|
+
else this.appendText(data);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (data === "\b" || data === "\u007f") {
|
|
134
|
+
this.backspace();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (data === "\r" || data === "\n") {
|
|
138
|
+
if (this.activeName() === "body" || this.fields.body.trim()) this.submit();
|
|
139
|
+
else this.activeField = (this.activeField + 1) % FIELD_ORDER.length;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (data.length === 1 && data >= " ") this.appendText(data);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { CrewTheme } from "../theme-adapter.ts";
|
|
2
|
+
import { asCrewTheme } from "../theme-adapter.ts";
|
|
3
|
+
import { truncate } from "../../utils/visual.ts";
|
|
4
|
+
|
|
5
|
+
export type MarkdownToken = { type: "heading" | "code-block" | "list-item" | "paragraph"; level?: number; text: string };
|
|
6
|
+
|
|
7
|
+
function stripInlineMarkdown(text: string): string {
|
|
8
|
+
return text
|
|
9
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
10
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
11
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
12
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
13
|
+
.replace(/\*([^*]+)\*/g, "$1");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function tokenizeMarkdown(body: string): MarkdownToken[] {
|
|
17
|
+
const tokens: MarkdownToken[] = [];
|
|
18
|
+
const lines = body.split(/\r?\n/);
|
|
19
|
+
let inCode = false;
|
|
20
|
+
let codeLines: string[] = [];
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
if (line.trim().startsWith("```")) {
|
|
23
|
+
if (inCode) {
|
|
24
|
+
tokens.push({ type: "code-block", text: codeLines.join("\n") });
|
|
25
|
+
codeLines = [];
|
|
26
|
+
inCode = false;
|
|
27
|
+
} else inCode = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (inCode) {
|
|
31
|
+
codeLines.push(line);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const heading = /^(#{1,3})\s+(.+)$/.exec(line);
|
|
35
|
+
if (heading) {
|
|
36
|
+
tokens.push({ type: "heading", level: heading[1]!.length, text: stripInlineMarkdown(heading[2]!) });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const list = /^\s*(?:[-*]|\d+\.)\s+(.+)$/.exec(line);
|
|
40
|
+
if (list) {
|
|
41
|
+
tokens.push({ type: "list-item", text: stripInlineMarkdown(list[1]!) });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (line.trim()) tokens.push({ type: "paragraph", text: stripInlineMarkdown(line.trim()) });
|
|
45
|
+
}
|
|
46
|
+
if (inCode && codeLines.length) tokens.push({ type: "code-block", text: codeLines.join("\n") });
|
|
47
|
+
return tokens;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderToken(token: MarkdownToken, width: number, theme: CrewTheme): string[] {
|
|
51
|
+
const safeWidth = Math.max(10, width);
|
|
52
|
+
if (token.type === "heading") return [truncate(theme.bold(`${"#".repeat(token.level ?? 1)} ${token.text}`), safeWidth)];
|
|
53
|
+
if (token.type === "list-item") return [truncate(`• ${token.text}`, safeWidth)];
|
|
54
|
+
if (token.type === "code-block") return ["```", ...token.text.split(/\r?\n/).map((line) => truncate(` ${line}`, safeWidth)), "```"];
|
|
55
|
+
return [truncate(token.text, safeWidth)];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function renderComposePreview(body: string, width: number, themeLike: unknown = {}): string[] {
|
|
59
|
+
const theme = asCrewTheme(themeLike);
|
|
60
|
+
const tokens = tokenizeMarkdown(body);
|
|
61
|
+
if (!tokens.length) return [theme.fg("dim", "Preview: (empty)")];
|
|
62
|
+
return [theme.bold("Preview"), ...tokens.flatMap((token) => renderToken(token, width, theme))];
|
|
63
|
+
}
|