pi-crew 0.1.8 → 0.1.10
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/package.json +1 -1
- package/src/extension/help.ts +2 -0
- package/src/extension/register.ts +70 -1
- package/src/runtime/agent-observability.ts +29 -4
- package/src/runtime/process-status.ts +18 -0
- package/src/ui/crew-widget.ts +119 -0
- package/src/ui/run-dashboard.ts +62 -17
- package/src/ui/transcript-viewer.ts +204 -0
package/package.json
CHANGED
package/src/extension/help.ts
CHANGED
|
@@ -18,6 +18,8 @@ export function piTeamsHelp(): string {
|
|
|
18
18
|
"- /team-worktrees <runId>",
|
|
19
19
|
"- /team-api <runId> <operation> [taskId=<taskId>] [body=<message>]",
|
|
20
20
|
"- /team-dashboard",
|
|
21
|
+
"- /team-transcript <runId> [taskId]",
|
|
22
|
+
"- /team-result <runId> [taskId]",
|
|
21
23
|
"- /team-manager",
|
|
22
24
|
"",
|
|
23
25
|
"Maintenance:",
|
|
@@ -10,6 +10,10 @@ import { handleTeamTool, type TeamToolDetails } from "./team-tool.ts";
|
|
|
10
10
|
import { listRuns } from "./run-index.ts";
|
|
11
11
|
import { RunDashboard, type RunDashboardSelection } from "../ui/run-dashboard.ts";
|
|
12
12
|
import { registerPiCrewRpc, type PiCrewRpcHandle } from "./cross-extension-rpc.ts";
|
|
13
|
+
import { stopCrewWidget, updateCrewWidget, type CrewWidgetState } from "../ui/crew-widget.ts";
|
|
14
|
+
import { DurableTextViewer, DurableTranscriptViewer } from "../ui/transcript-viewer.ts";
|
|
15
|
+
import { loadRunManifestById } from "../state/state-store.ts";
|
|
16
|
+
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
13
17
|
|
|
14
18
|
function parseRunArgs(args: string): TeamToolParamsValue {
|
|
15
19
|
const tokens = args.match(/"[^"]*"|'[^']*'|\S+/g)?.map((token) => token.replace(/^['"]|['"]$/g, "")) ?? [];
|
|
@@ -45,6 +49,36 @@ function parseScalar(raw: string): unknown {
|
|
|
45
49
|
return raw;
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
async function selectAgentTask(ctx: ExtensionCommandContext, runId: string | undefined, taskId?: string): Promise<{ runId: string; taskId?: string } | undefined> {
|
|
53
|
+
if (!runId) return undefined;
|
|
54
|
+
if (taskId) return { runId, taskId };
|
|
55
|
+
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
56
|
+
if (!loaded) return { runId };
|
|
57
|
+
const agents = readCrewAgents(loaded.manifest);
|
|
58
|
+
if (ctx.hasUI && agents.length > 1) {
|
|
59
|
+
const choice = await ctx.ui.select("Select pi-crew agent", agents.map((agent) => `${agent.taskId} ${agent.role}→${agent.agent} [${agent.status}]`));
|
|
60
|
+
return { runId, taskId: choice?.split(" ")[0] };
|
|
61
|
+
}
|
|
62
|
+
return { runId, taskId: agents[0]?.taskId };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function openTranscriptViewer(ctx: ExtensionCommandContext, runId: string | undefined, taskId?: string): Promise<boolean> {
|
|
66
|
+
const selected = await selectAgentTask(ctx, runId, taskId);
|
|
67
|
+
if (!selected) return false;
|
|
68
|
+
// eslint-disable-next-line no-param-reassign
|
|
69
|
+
runId = selected.runId;
|
|
70
|
+
// eslint-disable-next-line no-param-reassign
|
|
71
|
+
taskId = selected.taskId;
|
|
72
|
+
if (!runId || !ctx.hasUI) return false;
|
|
73
|
+
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
74
|
+
if (!loaded) return false;
|
|
75
|
+
await ctx.ui.custom<undefined>((_tui, theme, _keybindings, done) => new DurableTranscriptViewer(loaded.manifest, theme, done, taskId), {
|
|
76
|
+
overlay: true,
|
|
77
|
+
overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" },
|
|
78
|
+
});
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
48
82
|
function pushUnset(config: Record<string, unknown>, key: string): void {
|
|
49
83
|
const current = Array.isArray(config.unset) ? config.unset : [];
|
|
50
84
|
current.push(key);
|
|
@@ -67,6 +101,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
67
101
|
const notifierState: AsyncNotifierState = { seenFinishedRunIds: new Set() };
|
|
68
102
|
let currentCtx: ExtensionContext | undefined;
|
|
69
103
|
let rpcHandle: PiCrewRpcHandle | undefined;
|
|
104
|
+
const widgetState: CrewWidgetState = { frame: 0 };
|
|
70
105
|
registerAutonomousPolicy(pi);
|
|
71
106
|
rpcHandle = registerPiCrewRpc((pi as unknown as { events?: Parameters<typeof registerPiCrewRpc>[0] }).events, () => currentCtx);
|
|
72
107
|
|
|
@@ -75,9 +110,13 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
75
110
|
notifyActiveRuns(ctx);
|
|
76
111
|
const loadedConfig = loadConfig(ctx.cwd);
|
|
77
112
|
startAsyncRunNotifier(ctx, notifierState, loadedConfig.config.notifierIntervalMs ?? 5000);
|
|
113
|
+
updateCrewWidget(ctx, widgetState);
|
|
114
|
+
widgetState.interval = setInterval(() => { if (currentCtx) updateCrewWidget(currentCtx, widgetState); }, 1000);
|
|
115
|
+
widgetState.interval.unref?.();
|
|
78
116
|
});
|
|
79
117
|
pi.on("session_shutdown", () => {
|
|
80
118
|
stopAsyncRunNotifier(notifierState);
|
|
119
|
+
stopCrewWidget(currentCtx, widgetState);
|
|
81
120
|
currentCtx = undefined;
|
|
82
121
|
rpcHandle?.unsubscribe();
|
|
83
122
|
rpcHandle = undefined;
|
|
@@ -90,7 +129,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
90
129
|
promptSnippet: "Use the team tool proactively for coordinated multi-agent work. If unsure, call { action: 'recommend', goal } first, then run or plan with the suggested team/workflow.",
|
|
91
130
|
parameters: TeamToolParams as never,
|
|
92
131
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
93
|
-
|
|
132
|
+
const output = await handleTeamTool(params as TeamToolParamsValue, ctx);
|
|
133
|
+
updateCrewWidget(ctx, widgetState);
|
|
134
|
+
return output;
|
|
94
135
|
},
|
|
95
136
|
};
|
|
96
137
|
|
|
@@ -250,6 +291,33 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
250
291
|
handler: handleTeamManagerCommand,
|
|
251
292
|
});
|
|
252
293
|
|
|
294
|
+
pi.registerCommand("team-result", {
|
|
295
|
+
description: "Open a pi-crew agent result viewer: <runId> [taskId]",
|
|
296
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
297
|
+
const [runId, rawTaskId] = args.trim().split(/\s+/).filter(Boolean);
|
|
298
|
+
const selected = await selectAgentTask(ctx, runId, rawTaskId);
|
|
299
|
+
const loaded = selected ? loadRunManifestById(ctx.cwd, selected.runId) : undefined;
|
|
300
|
+
if (ctx.hasUI && loaded) {
|
|
301
|
+
const agent = readCrewAgents(loaded.manifest).find((item) => item.taskId === selected?.taskId || item.id === selected?.taskId) ?? readCrewAgents(loaded.manifest)[0];
|
|
302
|
+
const text = agent?.resultArtifactPath ? commandText(await handleTeamTool({ action: "api", runId: selected!.runId, config: { operation: "read-agent-output", agentId: agent.taskId, maxBytes: 64_000 } }, ctx)) : "(no result)";
|
|
303
|
+
await ctx.ui.custom<undefined>((_tui, theme, _keybindings, done) => new DurableTextViewer("pi-crew result", `${selected!.runId}:${agent?.taskId ?? "unknown"}`, text.split(/\r?\n/), theme, done), { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const result = await handleTeamTool({ action: "api", runId, config: { operation: "read-agent-output", agentId: rawTaskId, maxBytes: 64_000 } }, ctx);
|
|
307
|
+
await notifyCommandResult(ctx, commandText(result));
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
pi.registerCommand("team-transcript", {
|
|
312
|
+
description: "Open a pi-crew transcript viewer: <runId> [taskId]",
|
|
313
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
314
|
+
const [runId, taskId] = args.trim().split(/\s+/).filter(Boolean);
|
|
315
|
+
if (await openTranscriptViewer(ctx, runId, taskId)) return;
|
|
316
|
+
const result = await handleTeamTool({ action: "api", runId, config: { operation: "read-agent-transcript", agentId: taskId } }, ctx);
|
|
317
|
+
await notifyCommandResult(ctx, commandText(result));
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
253
321
|
pi.registerCommand("team-dashboard", {
|
|
254
322
|
description: "Open a pi-crew run dashboard overlay",
|
|
255
323
|
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
@@ -261,6 +329,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
261
329
|
});
|
|
262
330
|
if (!selection) return;
|
|
263
331
|
if (selection.action === "reload") continue;
|
|
332
|
+
if (selection.action === "agent-transcript" && await openTranscriptViewer(ctx, selection.runId)) continue;
|
|
264
333
|
const result = selection.action === "api"
|
|
265
334
|
? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, ctx)
|
|
266
335
|
: selection.action === "agents"
|
|
@@ -3,6 +3,16 @@ import type { TeamRunManifest } from "../state/types.ts";
|
|
|
3
3
|
import { agentOutputPath, readCrewAgents } from "./crew-agent-records.ts";
|
|
4
4
|
import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
|
|
5
5
|
|
|
6
|
+
const TOOL_LABELS: Record<string, string> = {
|
|
7
|
+
read: "reading",
|
|
8
|
+
bash: "running command",
|
|
9
|
+
edit: "editing",
|
|
10
|
+
write: "writing",
|
|
11
|
+
grep: "searching",
|
|
12
|
+
find: "finding files",
|
|
13
|
+
ls: "listing",
|
|
14
|
+
};
|
|
15
|
+
|
|
6
16
|
export interface TextTailResult {
|
|
7
17
|
path: string;
|
|
8
18
|
text: string;
|
|
@@ -24,14 +34,29 @@ export function readTextTail(filePath: string, maxBytes = 64_000): TextTailResul
|
|
|
24
34
|
}
|
|
25
35
|
}
|
|
26
36
|
|
|
37
|
+
function compactDuration(ms: number | undefined): string | undefined {
|
|
38
|
+
if (ms === undefined || !Number.isFinite(ms)) return undefined;
|
|
39
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
40
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
41
|
+
return `${Math.floor(ms / 60_000)}m${Math.floor((ms % 60_000) / 1000)}s`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ageBetween(start: string | undefined, end: string | undefined): string | undefined {
|
|
45
|
+
if (!start) return undefined;
|
|
46
|
+
const stop = end ? new Date(end).getTime() : Date.now();
|
|
47
|
+
const ms = Math.max(0, stop - new Date(start).getTime());
|
|
48
|
+
return compactDuration(ms);
|
|
49
|
+
}
|
|
50
|
+
|
|
27
51
|
function activityText(agent: CrewAgentRecord): string {
|
|
28
52
|
const parts: string[] = [];
|
|
29
53
|
if (agent.progress?.activityState) parts.push(agent.progress.activityState);
|
|
30
|
-
if (agent.progress?.currentTool) parts.push(`tool=${agent.progress.currentTool}`);
|
|
54
|
+
if (agent.progress?.currentTool) parts.push(TOOL_LABELS[agent.progress.currentTool] ?? `tool=${agent.progress.currentTool}`);
|
|
31
55
|
if (agent.toolUses !== undefined) parts.push(`tools=${agent.toolUses}`);
|
|
32
56
|
if (agent.progress?.tokens !== undefined) parts.push(`tokens=${agent.progress.tokens}`);
|
|
33
57
|
if (agent.progress?.turns !== undefined) parts.push(`turns=${agent.progress.turns}`);
|
|
34
|
-
|
|
58
|
+
const duration = compactDuration(agent.progress?.durationMs) ?? ageBetween(agent.startedAt, agent.completedAt);
|
|
59
|
+
if (duration) parts.push(duration);
|
|
35
60
|
if (agent.progress?.failedTool) parts.push(`failedTool=${agent.progress.failedTool}`);
|
|
36
61
|
if (agent.progress?.recentOutput?.length) parts.push(`last=${agent.progress.recentOutput.at(-1)}`);
|
|
37
62
|
return parts.join(" ") || "idle";
|
|
@@ -56,7 +81,7 @@ function outputWarning(agent: CrewAgentRecord): string {
|
|
|
56
81
|
}
|
|
57
82
|
|
|
58
83
|
function agentLine(agent: CrewAgentRecord): string {
|
|
59
|
-
return `- ${statusGlyph(agent.status)} ${agent.taskId}
|
|
84
|
+
return `- ${statusGlyph(agent.status)} ${agent.taskId} ${agent.role} → ${agent.agent} · ${agent.status} · ${agent.runtime} · ${activityText(agent)}${outputWarning(agent)}${agent.error ? ` · error=${agent.error}` : ""}`;
|
|
60
85
|
}
|
|
61
86
|
|
|
62
87
|
export function buildAgentDashboard(manifest: TeamRunManifest): { text: string; groups: Record<string, CrewAgentRecord[]> } {
|
|
@@ -68,7 +93,7 @@ export function buildAgentDashboard(manifest: TeamRunManifest): { text: string;
|
|
|
68
93
|
};
|
|
69
94
|
const lines = [
|
|
70
95
|
`Crew agents for ${manifest.runId}`,
|
|
71
|
-
`Run
|
|
96
|
+
`Run: ${manifest.status} · ${manifest.team}/${manifest.workflow ?? "none"} · agents=${agents.length}`,
|
|
72
97
|
`Counts: running=${groups.running.length}, queued=${groups.queued.length}, recent=${groups.recent.length}`,
|
|
73
98
|
"",
|
|
74
99
|
"## Running",
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
|
|
2
|
+
import type { TeamRunManifest } from "../state/types.ts";
|
|
3
|
+
|
|
1
4
|
export interface ProcessLiveness {
|
|
2
5
|
pid?: number;
|
|
3
6
|
alive: boolean;
|
|
4
7
|
detail: string;
|
|
5
8
|
}
|
|
6
9
|
|
|
10
|
+
const ORPHANED_ACTIVE_RUN_MS = 10 * 60 * 1000;
|
|
11
|
+
|
|
7
12
|
export function checkProcessLiveness(pid: number | undefined): ProcessLiveness {
|
|
8
13
|
if (pid === undefined || !Number.isInteger(pid) || pid <= 0) {
|
|
9
14
|
return { pid, alive: false, detail: "no pid recorded" };
|
|
@@ -23,3 +28,16 @@ export function checkProcessLiveness(pid: number | undefined): ProcessLiveness {
|
|
|
23
28
|
export function isActiveRunStatus(status: string): boolean {
|
|
24
29
|
return status === "queued" || status === "planning" || status === "running";
|
|
25
30
|
}
|
|
31
|
+
|
|
32
|
+
export function isLikelyOrphanedActiveRun(run: TeamRunManifest, agents: CrewAgentRecord[] = [], now = Date.now(), staleMs = ORPHANED_ACTIVE_RUN_MS): boolean {
|
|
33
|
+
if (!isActiveRunStatus(run.status)) return false;
|
|
34
|
+
if (run.async?.pid !== undefined) return false;
|
|
35
|
+
const updatedAt = new Date(run.updatedAt).getTime();
|
|
36
|
+
if (!Number.isFinite(updatedAt) || now - updatedAt < staleMs) return false;
|
|
37
|
+
if (agents.length === 0) return run.summary === "Creating workflow prompts and placeholder results.";
|
|
38
|
+
return agents.every((agent) => agent.status === "queued" && !agent.completedAt && !agent.progress);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isDisplayActiveRun(run: TeamRunManifest, agents: CrewAgentRecord[] = [], now = Date.now()): boolean {
|
|
42
|
+
return isActiveRunStatus(run.status) && !isLikelyOrphanedActiveRun(run, agents, now);
|
|
43
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { listRuns } from "../extension/run-index.ts";
|
|
3
|
+
import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
|
|
4
|
+
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
5
|
+
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
|
6
|
+
import type { TeamRunManifest } from "../state/types.ts";
|
|
7
|
+
|
|
8
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
9
|
+
const TOOL_LABELS: Record<string, string> = {
|
|
10
|
+
read: "reading",
|
|
11
|
+
bash: "running command",
|
|
12
|
+
edit: "editing",
|
|
13
|
+
write: "writing",
|
|
14
|
+
grep: "searching",
|
|
15
|
+
find: "finding files",
|
|
16
|
+
ls: "listing",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface CrewWidgetState {
|
|
20
|
+
frame: number;
|
|
21
|
+
interval?: ReturnType<typeof setInterval>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function elapsed(iso: string | undefined, now = Date.now()): string | undefined {
|
|
25
|
+
if (!iso) return undefined;
|
|
26
|
+
const ms = Math.max(0, now - new Date(iso).getTime());
|
|
27
|
+
if (!Number.isFinite(ms)) return undefined;
|
|
28
|
+
if (ms < 1000) return "now";
|
|
29
|
+
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
|
|
30
|
+
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`;
|
|
31
|
+
return `${Math.floor(ms / 3_600_000)}h`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function glyph(status: string, runningGlyph: string): string {
|
|
35
|
+
if (status === "running") return runningGlyph;
|
|
36
|
+
if (status === "queued") return "◦";
|
|
37
|
+
if (status === "completed") return "✓";
|
|
38
|
+
if (status === "failed") return "✗";
|
|
39
|
+
if (status === "cancelled" || status === "stopped") return "■";
|
|
40
|
+
return "·";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function agentActivity(agent: CrewAgentRecord): string {
|
|
44
|
+
if (agent.progress?.currentTool) return `${TOOL_LABELS[agent.progress.currentTool] ?? agent.progress.currentTool}…`;
|
|
45
|
+
const recent = agent.progress?.recentOutput?.at(-1);
|
|
46
|
+
if (recent) return recent.replace(/\s+/g, " ").trim();
|
|
47
|
+
if (agent.progress?.activityState === "needs_attention") return "needs attention";
|
|
48
|
+
if (agent.status === "queued") return "queued…";
|
|
49
|
+
if (agent.status === "running") return "thinking…";
|
|
50
|
+
if (agent.status === "failed") return agent.error ?? "failed";
|
|
51
|
+
return "done";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function agentStats(agent: CrewAgentRecord): string {
|
|
55
|
+
const parts: string[] = [];
|
|
56
|
+
if (agent.toolUses) parts.push(`${agent.toolUses} tools`);
|
|
57
|
+
if (agent.progress?.tokens) parts.push(`${agent.progress.tokens} tok`);
|
|
58
|
+
if (agent.progress?.turns) parts.push(`⟳${agent.progress.turns}`);
|
|
59
|
+
const age = elapsed(agent.completedAt ?? agent.startedAt);
|
|
60
|
+
if (age) parts.push(agent.completedAt ? age : `${age} ago`);
|
|
61
|
+
return parts.join(" · ");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function runStep(run: TeamRunManifest, agents: CrewAgentRecord[]): string {
|
|
65
|
+
if (isLikelyOrphanedActiveRun(run, agents)) return "stale";
|
|
66
|
+
const running = agents.find((agent) => agent.status === "running");
|
|
67
|
+
if (running) return running.taskId;
|
|
68
|
+
const queued = agents.find((agent) => agent.status === "queued");
|
|
69
|
+
if (queued) return queued.taskId;
|
|
70
|
+
return run.status;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function agentsFor(run: TeamRunManifest): CrewAgentRecord[] {
|
|
74
|
+
try { return readCrewAgents(run); } catch { return []; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8): string[] {
|
|
78
|
+
const runs = listRuns(cwd).slice(0, 20);
|
|
79
|
+
const runAgents = new Map(runs.map((run) => [run.runId, agentsFor(run)]));
|
|
80
|
+
const activeRuns = runs.filter((run) => isDisplayActiveRun(run, runAgents.get(run.runId) ?? []));
|
|
81
|
+
const recentRuns = runs.filter((run) => !isDisplayActiveRun(run, runAgents.get(run.runId) ?? [])).slice(0, 3);
|
|
82
|
+
const shownRuns = [...activeRuns, ...recentRuns];
|
|
83
|
+
if (!shownRuns.length) return [];
|
|
84
|
+
const runningGlyph = SPINNER[frame % SPINNER.length] ?? "⠋";
|
|
85
|
+
const lines: string[] = [];
|
|
86
|
+
const activeCount = activeRuns.length;
|
|
87
|
+
lines.push(`${activeCount ? "●" : "○"} pi-crew · active=${activeCount} recent=${recentRuns.length} · /team-dashboard`);
|
|
88
|
+
for (const run of shownRuns) {
|
|
89
|
+
const agents = runAgents.get(run.runId) ?? [];
|
|
90
|
+
const stale = isLikelyOrphanedActiveRun(run, agents);
|
|
91
|
+
const counts = new Map<string, number>();
|
|
92
|
+
for (const agent of agents) counts.set(agent.status, (counts.get(agent.status) ?? 0) + 1);
|
|
93
|
+
const countText = stale ? "stale queued run" : [...counts.entries()].map(([status, count]) => `${status}:${count}`).join(" ") || run.status;
|
|
94
|
+
lines.push(`${glyph(stale ? "failed" : run.status, runningGlyph)} ${run.runId.slice(-8)} ${run.team}/${run.workflow ?? "none"} · ${runStep(run, agents)} · ${countText}`);
|
|
95
|
+
for (const agent of (stale ? [] : agents.filter((item) => item.status === "running" || item.status === "queued").slice(0, 2))) {
|
|
96
|
+
const stats = agentStats(agent);
|
|
97
|
+
lines.push(` ${glyph(agent.status, runningGlyph)} ${agent.taskId} ${agent.role}→${agent.agent} · ${agentActivity(agent)}${stats ? ` · ${stats}` : ""}`);
|
|
98
|
+
}
|
|
99
|
+
if (lines.length >= maxLines) break;
|
|
100
|
+
}
|
|
101
|
+
return lines.slice(0, maxLines);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function updateCrewWidget(ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "ui">, state: CrewWidgetState): void {
|
|
105
|
+
if (!ctx.hasUI) return;
|
|
106
|
+
state.frame += 1;
|
|
107
|
+
const lines = buildCrewWidgetLines(ctx.cwd, state.frame);
|
|
108
|
+
ctx.ui.setStatus("pi-crew", lines.length ? lines[0] : undefined);
|
|
109
|
+
ctx.ui.setWidget("pi-crew", lines.length ? lines : undefined, { placement: "aboveEditor" });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function stopCrewWidget(ctx: Pick<ExtensionContext, "hasUI" | "ui"> | undefined, state: CrewWidgetState): void {
|
|
113
|
+
if (state.interval) clearInterval(state.interval);
|
|
114
|
+
state.interval = undefined;
|
|
115
|
+
if (ctx?.hasUI) {
|
|
116
|
+
ctx.ui.setStatus("pi-crew", undefined);
|
|
117
|
+
ctx.ui.setWidget("pi-crew", undefined, { placement: "aboveEditor" });
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
3
3
|
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
4
4
|
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
|
5
|
+
import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
|
|
5
6
|
|
|
6
7
|
interface DashboardComponent {
|
|
7
8
|
invalidate(): void;
|
|
@@ -50,7 +51,7 @@ function padVisible(value: string, width: number): string {
|
|
|
50
51
|
|
|
51
52
|
function statusIcon(status: string): string {
|
|
52
53
|
if (status === "completed") return "✓";
|
|
53
|
-
if (status === "failed") return "✗";
|
|
54
|
+
if (status === "failed" || status === "stale") return "✗";
|
|
54
55
|
if (status === "cancelled") return "!";
|
|
55
56
|
if (status === "running") return "▶";
|
|
56
57
|
if (status === "blocked") return "■";
|
|
@@ -68,6 +69,16 @@ function readProgressPreview(run: TeamRunManifest, maxLines = 5): string[] {
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
function formatAge(iso: string | undefined): string | undefined {
|
|
73
|
+
if (!iso) return undefined;
|
|
74
|
+
const ms = Math.max(0, Date.now() - new Date(iso).getTime());
|
|
75
|
+
if (!Number.isFinite(ms)) return undefined;
|
|
76
|
+
if (ms < 1000) return "now";
|
|
77
|
+
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
|
|
78
|
+
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`;
|
|
79
|
+
return `${Math.floor(ms / 3_600_000)}h`;
|
|
80
|
+
}
|
|
81
|
+
|
|
71
82
|
function agentPreviewLine(agent: CrewAgentRecord): string {
|
|
72
83
|
const stats = [
|
|
73
84
|
agent.progress?.activityState,
|
|
@@ -76,6 +87,7 @@ function agentPreviewLine(agent: CrewAgentRecord): string {
|
|
|
76
87
|
agent.progress?.tokens !== undefined ? `${agent.progress.tokens} tok` : undefined,
|
|
77
88
|
agent.progress?.turns !== undefined ? `${agent.progress.turns} turns` : undefined,
|
|
78
89
|
agent.progress?.failedTool ? `failedTool=${agent.progress.failedTool}` : undefined,
|
|
90
|
+
agent.startedAt ? `age=${formatAge(agent.completedAt ?? agent.startedAt)}` : undefined,
|
|
79
91
|
].filter((part): part is string => Boolean(part));
|
|
80
92
|
const recent = agent.progress?.recentOutput?.at(-1);
|
|
81
93
|
return `Agent: ${statusIcon(agent.status)} ${agent.taskId} ${agent.role}->${agent.agent}${stats.length ? ` · ${stats.join(" · ")}` : ""}${recent ? ` ⎿ ${recent}` : ""}`;
|
|
@@ -92,6 +104,34 @@ function readAgentPreview(run: TeamRunManifest, maxLines = 5): string[] {
|
|
|
92
104
|
}
|
|
93
105
|
}
|
|
94
106
|
|
|
107
|
+
function agentsFor(run: TeamRunManifest): CrewAgentRecord[] {
|
|
108
|
+
try { return readCrewAgents(run); } catch { return []; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function runLabel(run: TeamRunManifest, selected: boolean): string {
|
|
112
|
+
const agents = agentsFor(run);
|
|
113
|
+
const stale = isLikelyOrphanedActiveRun(run, agents);
|
|
114
|
+
const running = agents.find((agent) => agent.status === "running");
|
|
115
|
+
const queued = agents.find((agent) => agent.status === "queued");
|
|
116
|
+
const step = stale ? "orphaned queued run" : running ? `step ${running.taskId}` : queued ? `queued ${queued.taskId}` : `agents ${agents.length}`;
|
|
117
|
+
const status = stale ? "stale" : run.status;
|
|
118
|
+
const marker = selected ? "›" : " ";
|
|
119
|
+
return `${marker} ${statusIcon(status)} ${run.runId.slice(-8)} ${status} | ${run.team}/${run.workflow ?? "none"} | ${step} | ${run.goal}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function groupedRuns(runs: TeamRunManifest[]): Array<{ label: string; run?: TeamRunManifest }> {
|
|
123
|
+
const active = runs.filter((run) => isDisplayActiveRun(run, agentsFor(run)));
|
|
124
|
+
const recent = runs.filter((run) => !isDisplayActiveRun(run, agentsFor(run)));
|
|
125
|
+
const rows: Array<{ label: string; run?: TeamRunManifest }> = [];
|
|
126
|
+
if (active.length) rows.push({ label: "Active" }, ...active.map((run) => ({ label: run.runId, run })));
|
|
127
|
+
if (recent.length) rows.push({ label: "Recent" }, ...recent.map((run) => ({ label: run.runId, run })));
|
|
128
|
+
return rows;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function selectedRunFromGrouped(runs: TeamRunManifest[], selected: number): TeamRunManifest | undefined {
|
|
132
|
+
return groupedRuns(runs).filter((row) => row.run)[selected]?.run;
|
|
133
|
+
}
|
|
134
|
+
|
|
95
135
|
function countByStatus(runs: TeamRunManifest[]): string {
|
|
96
136
|
const counts = new Map<string, number>();
|
|
97
137
|
for (const run of runs) counts.set(run.status, (counts.get(run.status) ?? 0) + 1);
|
|
@@ -124,13 +164,17 @@ export class RunDashboard implements DashboardComponent {
|
|
|
124
164
|
if (this.runs.length === 0) {
|
|
125
165
|
lines.push(`│ ${padVisible(truncate("No runs found.", innerWidth - 1), innerWidth - 1)}│`);
|
|
126
166
|
} else {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
167
|
+
const rows = groupedRuns(this.runs).slice(0, 16);
|
|
168
|
+
const runRows = rows.filter((row) => row.run);
|
|
169
|
+
for (const row of rows) {
|
|
170
|
+
if (!row.run) {
|
|
171
|
+
lines.push(`│ ${padVisible(truncate(row.label, innerWidth - 1), innerWidth - 1)}│`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const index = runRows.findIndex((candidate) => candidate.run?.runId === row.run?.runId);
|
|
175
|
+
lines.push(`│ ${padVisible(truncate(runLabel(row.run, index === this.selected), innerWidth - 1), innerWidth - 1)}│`);
|
|
132
176
|
}
|
|
133
|
-
const selectedRun = this.runs
|
|
177
|
+
const selectedRun = selectedRunFromGrouped(this.runs, this.selected);
|
|
134
178
|
if (selectedRun) {
|
|
135
179
|
lines.push(`├${"─".repeat(borderWidth)}┤`);
|
|
136
180
|
const details = [
|
|
@@ -157,47 +201,47 @@ export class RunDashboard implements DashboardComponent {
|
|
|
157
201
|
return;
|
|
158
202
|
}
|
|
159
203
|
if (data === "\r" || data === "\n" || data === "s") {
|
|
160
|
-
const runId = this.runs
|
|
204
|
+
const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
|
|
161
205
|
this.done(runId ? { runId, action: "status" } : undefined);
|
|
162
206
|
return;
|
|
163
207
|
}
|
|
164
208
|
if (data === "u") {
|
|
165
|
-
const runId = this.runs
|
|
209
|
+
const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
|
|
166
210
|
this.done(runId ? { runId, action: "summary" } : undefined);
|
|
167
211
|
return;
|
|
168
212
|
}
|
|
169
213
|
if (data === "a") {
|
|
170
|
-
const runId = this.runs
|
|
214
|
+
const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
|
|
171
215
|
this.done(runId ? { runId, action: "artifacts" } : undefined);
|
|
172
216
|
return;
|
|
173
217
|
}
|
|
174
218
|
if (data === "i") {
|
|
175
|
-
const runId = this.runs
|
|
219
|
+
const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
|
|
176
220
|
this.done(runId ? { runId, action: "api" } : undefined);
|
|
177
221
|
return;
|
|
178
222
|
}
|
|
179
223
|
if (data === "d") {
|
|
180
|
-
const runId = this.runs
|
|
224
|
+
const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
|
|
181
225
|
this.done(runId ? { runId, action: "agents" } : undefined);
|
|
182
226
|
return;
|
|
183
227
|
}
|
|
184
228
|
if (data === "e") {
|
|
185
|
-
const runId = this.runs
|
|
229
|
+
const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
|
|
186
230
|
this.done(runId ? { runId, action: "agent-events" } : undefined);
|
|
187
231
|
return;
|
|
188
232
|
}
|
|
189
233
|
if (data === "o") {
|
|
190
|
-
const runId = this.runs
|
|
234
|
+
const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
|
|
191
235
|
this.done(runId ? { runId, action: "agent-output" } : undefined);
|
|
192
236
|
return;
|
|
193
237
|
}
|
|
194
238
|
if (data === "v") {
|
|
195
|
-
const runId = this.runs
|
|
239
|
+
const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
|
|
196
240
|
this.done(runId ? { runId, action: "agent-transcript" } : undefined);
|
|
197
241
|
return;
|
|
198
242
|
}
|
|
199
243
|
if (data === "r") {
|
|
200
|
-
this.done({ runId: this.runs
|
|
244
|
+
this.done({ runId: selectedRunFromGrouped(this.runs, this.selected)?.runId ?? "", action: "reload" });
|
|
201
245
|
return;
|
|
202
246
|
}
|
|
203
247
|
if (data === "p") {
|
|
@@ -209,7 +253,8 @@ export class RunDashboard implements DashboardComponent {
|
|
|
209
253
|
return;
|
|
210
254
|
}
|
|
211
255
|
if (data === "j" || data === "\u001b[B") {
|
|
212
|
-
|
|
256
|
+
const selectableCount = groupedRuns(this.runs).filter((row) => row.run).length;
|
|
257
|
+
this.selected = Math.min(Math.max(0, selectableCount - 1), this.selected + 1);
|
|
213
258
|
}
|
|
214
259
|
}
|
|
215
260
|
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
type Component = { invalidate(): void; render(width: number): string[]; handleInput(data: string): void };
|
|
3
|
+
type TranscriptTheme = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
|
|
4
|
+
import type { TeamRunManifest } from "../state/types.ts";
|
|
5
|
+
import { agentOutputPath, readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
6
|
+
|
|
7
|
+
function visibleWidth(text: string): number {
|
|
8
|
+
return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "").length;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function truncate(text: string, width: number): string {
|
|
12
|
+
if (width <= 0) return "";
|
|
13
|
+
if (visibleWidth(text) <= width) return text;
|
|
14
|
+
return width <= 1 ? "…" : `${text.slice(0, Math.max(0, width - 1))}…`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function wrap(text: string, width: number): string[] {
|
|
18
|
+
const source = text.split(/\r?\n/);
|
|
19
|
+
const lines: string[] = [];
|
|
20
|
+
for (const raw of source) {
|
|
21
|
+
const line = raw || " ";
|
|
22
|
+
if (line.length <= width) {
|
|
23
|
+
lines.push(line);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
for (let index = 0; index < line.length; index += width) lines.push(line.slice(index, index + width));
|
|
27
|
+
}
|
|
28
|
+
return lines;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
32
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function textFromContent(content: unknown): string {
|
|
36
|
+
if (typeof content === "string") return content;
|
|
37
|
+
if (!Array.isArray(content)) return "";
|
|
38
|
+
return content.map((part) => {
|
|
39
|
+
const obj = asRecord(part);
|
|
40
|
+
if (!obj) return "";
|
|
41
|
+
if (typeof obj.text === "string") return obj.text;
|
|
42
|
+
if (typeof obj.content === "string") return obj.content;
|
|
43
|
+
if (typeof obj.name === "string") return `[tool:${obj.name}]`;
|
|
44
|
+
return "";
|
|
45
|
+
}).filter(Boolean).join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatTranscriptEvent(event: unknown): string[] {
|
|
49
|
+
const obj = asRecord(event);
|
|
50
|
+
if (!obj) return [String(event)];
|
|
51
|
+
const type = typeof obj.type === "string" ? obj.type : undefined;
|
|
52
|
+
const toolName = typeof obj.toolName === "string" ? obj.toolName : typeof obj.name === "string" ? obj.name : undefined;
|
|
53
|
+
if (type && /tool/i.test(type)) {
|
|
54
|
+
const text = textFromContent(obj.content) || (typeof obj.text === "string" ? obj.text : typeof obj.result === "string" ? obj.result : "");
|
|
55
|
+
return [`[tool${toolName ? `:${toolName}` : ""} ${type}]: ${text.trim() || "(no output)"}`];
|
|
56
|
+
}
|
|
57
|
+
const message = asRecord(obj.message);
|
|
58
|
+
if (message) {
|
|
59
|
+
const role = typeof message.role === "string" ? message.role : "message";
|
|
60
|
+
const text = textFromContent(message.content);
|
|
61
|
+
if (text.trim()) return [`[${role}]: ${text.trim()}`];
|
|
62
|
+
}
|
|
63
|
+
if (type) {
|
|
64
|
+
const text = textFromContent(obj.content) || (typeof obj.text === "string" ? obj.text : "");
|
|
65
|
+
return text.trim() ? [`[${type}]: ${text.trim()}`] : [`[${type}]`];
|
|
66
|
+
}
|
|
67
|
+
return [JSON.stringify(event)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function formatTranscriptText(text: string): string[] {
|
|
71
|
+
const lines: string[] = [];
|
|
72
|
+
for (const raw of text.split(/\r?\n/).filter(Boolean)) {
|
|
73
|
+
try {
|
|
74
|
+
lines.push(...formatTranscriptEvent(JSON.parse(raw)));
|
|
75
|
+
} catch {
|
|
76
|
+
lines.push(raw);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return lines.length ? lines : ["(no transcript content)"];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function readRunTranscript(manifest: TeamRunManifest, taskId?: string): { title: string; path: string; lines: string[] } {
|
|
83
|
+
const agents = readCrewAgents(manifest);
|
|
84
|
+
const agent = taskId ? agents.find((item) => item.taskId === taskId || item.id === taskId) : agents.find((item) => item.transcriptPath) ?? agents[0];
|
|
85
|
+
const selectedTaskId = agent?.taskId ?? taskId ?? "unknown";
|
|
86
|
+
const transcriptPath = agent?.transcriptPath && fs.existsSync(agent.transcriptPath) ? agent.transcriptPath : agentOutputPath(manifest, selectedTaskId);
|
|
87
|
+
const text = fs.existsSync(transcriptPath) ? fs.readFileSync(transcriptPath, "utf-8") : "";
|
|
88
|
+
return { title: `${manifest.runId}:${selectedTaskId}`, path: transcriptPath, lines: formatTranscriptText(text) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class DurableTextViewer implements Component {
|
|
92
|
+
private scroll = 0;
|
|
93
|
+
private lastHeight = 10;
|
|
94
|
+
private title: string;
|
|
95
|
+
private subtitle: string;
|
|
96
|
+
private lines: string[];
|
|
97
|
+
private theme: unknown;
|
|
98
|
+
private done: (result: undefined) => void;
|
|
99
|
+
|
|
100
|
+
constructor(title: string, subtitle: string, lines: string[], theme: unknown, done: (result: undefined) => void) {
|
|
101
|
+
this.title = title;
|
|
102
|
+
this.subtitle = subtitle;
|
|
103
|
+
this.lines = lines.length ? lines : ["(empty)"];
|
|
104
|
+
this.theme = theme;
|
|
105
|
+
this.done = done;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
invalidate(): void {}
|
|
109
|
+
|
|
110
|
+
handleInput(data: string): void {
|
|
111
|
+
if (data === "q" || data === "\u001b") {
|
|
112
|
+
this.done(undefined);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const maxScroll = Math.max(0, this.lines.length - this.lastHeight);
|
|
116
|
+
if (data === "k" || data === "\u001b[A") this.scroll = Math.max(0, this.scroll - 1);
|
|
117
|
+
else if (data === "j" || data === "\u001b[B") this.scroll = Math.min(maxScroll, this.scroll + 1);
|
|
118
|
+
else if (data === "g") this.scroll = 0;
|
|
119
|
+
else if (data === "G") this.scroll = maxScroll;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
render(width: number): string[] {
|
|
123
|
+
const th = this.theme as TranscriptTheme;
|
|
124
|
+
const fg = th.fg?.bind(th) ?? ((_color: string, text: string) => text);
|
|
125
|
+
const bold = th.bold?.bind(th) ?? ((text: string) => text);
|
|
126
|
+
const inner = Math.max(20, width - 4);
|
|
127
|
+
this.lastHeight = 16;
|
|
128
|
+
const body = this.lines.flatMap((line) => wrap(line, inner));
|
|
129
|
+
const maxScroll = Math.max(0, body.length - this.lastHeight);
|
|
130
|
+
this.scroll = Math.min(this.scroll, maxScroll);
|
|
131
|
+
const visible = body.slice(this.scroll, this.scroll + this.lastHeight);
|
|
132
|
+
const pad = (text: string) => `${text}${" ".repeat(Math.max(0, inner - visibleWidth(text)))}`;
|
|
133
|
+
const row = (text: string) => `${fg("border", "│")} ${truncate(pad(text), inner)} ${fg("border", "│")}`;
|
|
134
|
+
return [
|
|
135
|
+
fg("border", `╭${"─".repeat(inner + 2)}╮`),
|
|
136
|
+
row(`${bold(this.title)} ${fg("dim", this.subtitle)}`),
|
|
137
|
+
row(fg("dim", "j/k scroll · g/G top/bottom · q close")),
|
|
138
|
+
fg("border", `├${"─".repeat(inner + 2)}┤`),
|
|
139
|
+
...visible.map(row),
|
|
140
|
+
fg("border", `├${"─".repeat(inner + 2)}┤`),
|
|
141
|
+
row(fg("dim", `${body.length} lines · ${body.length ? Math.round(((this.scroll + visible.length) / body.length) * 100) : 100}%`)),
|
|
142
|
+
fg("border", `╰${"─".repeat(inner + 2)}╯`),
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export class DurableTranscriptViewer implements Component {
|
|
148
|
+
private scroll = 0;
|
|
149
|
+
private lastHeight = 10;
|
|
150
|
+
private manifest: TeamRunManifest;
|
|
151
|
+
private theme: unknown;
|
|
152
|
+
private done: (result: undefined) => void;
|
|
153
|
+
private taskId?: string;
|
|
154
|
+
|
|
155
|
+
constructor(manifest: TeamRunManifest, theme: unknown, done: (result: undefined) => void, taskId?: string) {
|
|
156
|
+
this.manifest = manifest;
|
|
157
|
+
this.theme = theme;
|
|
158
|
+
this.done = done;
|
|
159
|
+
this.taskId = taskId;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
invalidate(): void {}
|
|
163
|
+
|
|
164
|
+
handleInput(data: string): void {
|
|
165
|
+
if (data === "q" || data === "\u001b") {
|
|
166
|
+
this.done(undefined);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const content = readRunTranscript(this.manifest, this.taskId).lines;
|
|
170
|
+
const maxScroll = Math.max(0, content.length - this.lastHeight);
|
|
171
|
+
if (data === "k" || data === "\u001b[A") this.scroll = Math.max(0, this.scroll - 1);
|
|
172
|
+
else if (data === "j" || data === "\u001b[B") this.scroll = Math.min(maxScroll, this.scroll + 1);
|
|
173
|
+
else if (data === "g") this.scroll = 0;
|
|
174
|
+
else if (data === "G") this.scroll = maxScroll;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
render(width: number): string[] {
|
|
178
|
+
const th = this.theme as TranscriptTheme;
|
|
179
|
+
const fg = th.fg?.bind(th) ?? ((_color: string, text: string) => text);
|
|
180
|
+
const bold = th.bold?.bind(th) ?? ((text: string) => text);
|
|
181
|
+
const inner = Math.max(20, width - 4);
|
|
182
|
+
const data = readRunTranscript(this.manifest, this.taskId);
|
|
183
|
+
const body = data.lines.flatMap((line) => wrap(line, inner));
|
|
184
|
+
this.lastHeight = 16;
|
|
185
|
+
const maxScroll = Math.max(0, body.length - this.lastHeight);
|
|
186
|
+
this.scroll = Math.min(this.scroll, maxScroll);
|
|
187
|
+
const visible = body.slice(this.scroll, this.scroll + this.lastHeight);
|
|
188
|
+
const pad = (text: string) => `${text}${" ".repeat(Math.max(0, inner - visibleWidth(text)))}`;
|
|
189
|
+
const row = (text: string) => `${fg("border", "│")} ${truncate(pad(text), inner)} ${fg("border", "│")}`;
|
|
190
|
+
const lines = [
|
|
191
|
+
fg("border", `╭${"─".repeat(inner + 2)}╮`),
|
|
192
|
+
row(`${bold("pi-crew transcript")} ${fg("dim", data.title)}`),
|
|
193
|
+
row(fg("dim", data.path)),
|
|
194
|
+
row(fg("dim", "j/k scroll · g/G top/bottom · q close")),
|
|
195
|
+
fg("border", `├${"─".repeat(inner + 2)}┤`),
|
|
196
|
+
...visible.map(row),
|
|
197
|
+
fg("border", `├${"─".repeat(inner + 2)}┤`),
|
|
198
|
+
row(fg("dim", `${body.length} lines · ${body.length ? Math.round(((this.scroll + visible.length) / body.length) * 100) : 100}%`)),
|
|
199
|
+
fg("border", `╰${"─".repeat(inner + 2)}╯`),
|
|
200
|
+
];
|
|
201
|
+
return lines;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|