@tt-a1i/openpi 0.1.0
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/README.md +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- package/themes/github-dark-default.json +89 -0
|
@@ -0,0 +1,1226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /workflows dashboard: a full-screen overlay with a run list and a per-run
|
|
3
|
+
* detail view (phases sidebar + agents panel), modeled after:
|
|
4
|
+
*
|
|
5
|
+
* name 5/5 agents · 31m18s · done
|
|
6
|
+
* description
|
|
7
|
+
* ╭ Phases ────────────╮ ╭ Gather · 3 agents ──────────────────────────────╮
|
|
8
|
+
* │ ❯ ■ Gather 3/3 │ │ ■ CodeRabbit feedback gpt-5 · 7%/372k 5m37s│
|
|
9
|
+
* │ ■ Verify 1/1 │ │ ■ Other bot feedback gpt-5 · 9%/372k 4m43s│
|
|
10
|
+
* ╰────────────────────╯ ╰─────────────────────────────────────────────────╯
|
|
11
|
+
* up/down select · right enter · left back · s save report
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
getAgentDir,
|
|
18
|
+
type ExtensionContext,
|
|
19
|
+
type KeybindingsManager,
|
|
20
|
+
} from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import {
|
|
22
|
+
Key,
|
|
23
|
+
matchesKey,
|
|
24
|
+
truncateToWidth,
|
|
25
|
+
visibleWidth,
|
|
26
|
+
wrapTextWithAnsi,
|
|
27
|
+
type TUI,
|
|
28
|
+
} from "@earendil-works/pi-tui";
|
|
29
|
+
import { isAcceptanceLedger } from "./acceptance.ts";
|
|
30
|
+
import {
|
|
31
|
+
agentContext,
|
|
32
|
+
countStates,
|
|
33
|
+
formatElapsed,
|
|
34
|
+
formatUsage,
|
|
35
|
+
aggregateUsage,
|
|
36
|
+
isWorkflowRunId,
|
|
37
|
+
MAX_LOG_TEXT,
|
|
38
|
+
phaseGroups,
|
|
39
|
+
resultJson,
|
|
40
|
+
resolveWorkflowRunTarget,
|
|
41
|
+
sanitizeLine,
|
|
42
|
+
shortenHome,
|
|
43
|
+
stateSquare,
|
|
44
|
+
statusColor,
|
|
45
|
+
statusWord,
|
|
46
|
+
SQUARE,
|
|
47
|
+
type Theme,
|
|
48
|
+
type AgentRecord,
|
|
49
|
+
type PhaseGroup,
|
|
50
|
+
type TranscriptEntry,
|
|
51
|
+
type WorkflowDetails,
|
|
52
|
+
type WorkflowLogEntry,
|
|
53
|
+
} from "./model.ts";
|
|
54
|
+
import { sanitizeTerminalText } from "../shared/terminal-text.ts";
|
|
55
|
+
import { writeFileAtomic } from "./serialization.ts";
|
|
56
|
+
|
|
57
|
+
const NOTICE_TTL_MS = 4000;
|
|
58
|
+
const MIN_HEIGHT = 10;
|
|
59
|
+
const TRANSCRIPT_SCROLL_STEP = 20;
|
|
60
|
+
|
|
61
|
+
function wrapSelection(index: number, delta: number, length: number): number {
|
|
62
|
+
if (length === 0) return 0;
|
|
63
|
+
return (index + delta + length) % length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RunEntry {
|
|
67
|
+
runId: string;
|
|
68
|
+
details: WorkflowDetails;
|
|
69
|
+
live: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function runsDir(): string {
|
|
73
|
+
return path.join(getAgentDir(), "workflows");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isWorktreeCleanup(
|
|
77
|
+
value: unknown,
|
|
78
|
+
): value is NonNullable<AgentRecord["worktreeCleanup"]> {
|
|
79
|
+
if (!value || typeof value !== "object") return false;
|
|
80
|
+
const cleanup = value as Record<string, unknown>;
|
|
81
|
+
return (
|
|
82
|
+
typeof cleanup.removed === "boolean" &&
|
|
83
|
+
typeof cleanup.branchDeleted === "boolean" &&
|
|
84
|
+
typeof cleanup.branch === "string" &&
|
|
85
|
+
typeof cleanup.detached === "boolean"
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeTranscript(value: unknown): TranscriptEntry[] {
|
|
90
|
+
if (!Array.isArray(value)) return [];
|
|
91
|
+
const transcript: TranscriptEntry[] = [];
|
|
92
|
+
for (const item of value) {
|
|
93
|
+
if (!item || typeof item !== "object") continue;
|
|
94
|
+
const entry = item as Record<string, unknown>;
|
|
95
|
+
if (
|
|
96
|
+
entry.role !== "user" &&
|
|
97
|
+
entry.role !== "assistant" &&
|
|
98
|
+
entry.role !== "thinking" &&
|
|
99
|
+
entry.role !== "tool" &&
|
|
100
|
+
entry.role !== "toolResult"
|
|
101
|
+
) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (typeof entry.text !== "string") continue;
|
|
105
|
+
transcript.push({
|
|
106
|
+
role: entry.role,
|
|
107
|
+
text: sanitizeTerminalText(entry.text),
|
|
108
|
+
name:
|
|
109
|
+
typeof entry.name === "string"
|
|
110
|
+
? sanitizeLine(entry.name, 160) || undefined
|
|
111
|
+
: undefined,
|
|
112
|
+
isError: entry.isError === true,
|
|
113
|
+
timestamp:
|
|
114
|
+
typeof entry.timestamp === "number" ? entry.timestamp : undefined,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return transcript;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Leniently normalize a workflow.json (including runs from older tooling). */
|
|
121
|
+
export function normalizePersistedWorkflowDetails(
|
|
122
|
+
runId: string,
|
|
123
|
+
raw: unknown,
|
|
124
|
+
): WorkflowDetails | undefined {
|
|
125
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
126
|
+
const record = raw as Record<string, unknown>;
|
|
127
|
+
const meta = (record.meta ?? {}) as Record<string, unknown>;
|
|
128
|
+
|
|
129
|
+
const rawAgents = Array.isArray(record.agents) ? record.agents : [];
|
|
130
|
+
const startedAt = typeof record.startedAt === "number" ? record.startedAt : 0;
|
|
131
|
+
const agents: AgentRecord[] = [];
|
|
132
|
+
for (const item of rawAgents) {
|
|
133
|
+
if (!item || typeof item !== "object") continue;
|
|
134
|
+
const a = item as Record<string, unknown>;
|
|
135
|
+
const state =
|
|
136
|
+
a.state === "error" || a.state === "failed"
|
|
137
|
+
? "error"
|
|
138
|
+
: a.state === "running"
|
|
139
|
+
? "running"
|
|
140
|
+
: "done";
|
|
141
|
+
agents.push({
|
|
142
|
+
index: typeof a.index === "number" ? a.index : agents.length + 1,
|
|
143
|
+
label:
|
|
144
|
+
typeof a.label === "string"
|
|
145
|
+
? sanitizeLine(a.label, 160) || `agent-${agents.length + 1}`
|
|
146
|
+
: `agent-${agents.length + 1}`,
|
|
147
|
+
phase:
|
|
148
|
+
typeof a.phase === "string"
|
|
149
|
+
? sanitizeLine(a.phase, 160) || undefined
|
|
150
|
+
: undefined,
|
|
151
|
+
state,
|
|
152
|
+
model:
|
|
153
|
+
typeof a.model === "string"
|
|
154
|
+
? sanitizeLine(a.model, 240) || undefined
|
|
155
|
+
: undefined,
|
|
156
|
+
contextWindow:
|
|
157
|
+
typeof a.contextWindow === "number" &&
|
|
158
|
+
Number.isFinite(a.contextWindow) &&
|
|
159
|
+
a.contextWindow > 0
|
|
160
|
+
? a.contextWindow
|
|
161
|
+
: undefined,
|
|
162
|
+
startedAt: typeof a.startedAt === "number" ? a.startedAt : startedAt,
|
|
163
|
+
finishedAt: typeof a.finishedAt === "number" ? a.finishedAt : undefined,
|
|
164
|
+
error:
|
|
165
|
+
typeof a.error === "string" && a.error !== "[undefined]"
|
|
166
|
+
? sanitizeLine(a.error, 2_000) || undefined
|
|
167
|
+
: undefined,
|
|
168
|
+
preview:
|
|
169
|
+
typeof a.preview === "string" ? sanitizeLine(a.preview, 4_000) : "",
|
|
170
|
+
usage: {
|
|
171
|
+
input: 0,
|
|
172
|
+
output: 0,
|
|
173
|
+
cacheRead: 0,
|
|
174
|
+
cacheWrite: 0,
|
|
175
|
+
cost: 0,
|
|
176
|
+
turns: 0,
|
|
177
|
+
...(a.usage && typeof a.usage === "object" ? (a.usage as object) : {}),
|
|
178
|
+
},
|
|
179
|
+
...(a.replayed === true ? { replayed: true } : {}),
|
|
180
|
+
...(isAcceptanceLedger(a.acceptance) ? { acceptance: a.acceptance } : {}),
|
|
181
|
+
...(typeof a.worktreeBranch === "string"
|
|
182
|
+
? { worktreeBranch: a.worktreeBranch }
|
|
183
|
+
: {}),
|
|
184
|
+
...(typeof a.worktreePath === "string"
|
|
185
|
+
? { worktreePath: a.worktreePath }
|
|
186
|
+
: {}),
|
|
187
|
+
...(isWorktreeCleanup(a.worktreeCleanup)
|
|
188
|
+
? { worktreeCleanup: a.worktreeCleanup }
|
|
189
|
+
: {}),
|
|
190
|
+
...(typeof a.worktreeHandoffArtifact === "string"
|
|
191
|
+
? { worktreeHandoffArtifact: a.worktreeHandoffArtifact }
|
|
192
|
+
: {}),
|
|
193
|
+
transcript: normalizeTranscript(a.transcript),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const rawPhases = Array.isArray(record.phases)
|
|
198
|
+
? record.phases
|
|
199
|
+
: Array.isArray(meta.phases)
|
|
200
|
+
? meta.phases
|
|
201
|
+
: [];
|
|
202
|
+
const phases: WorkflowDetails["phases"] = [];
|
|
203
|
+
for (const item of rawPhases) {
|
|
204
|
+
if (!item || typeof item !== "object") continue;
|
|
205
|
+
const p = item as Record<string, unknown>;
|
|
206
|
+
if (typeof p.title !== "string") continue;
|
|
207
|
+
const title = sanitizeLine(p.title, 160);
|
|
208
|
+
if (!title) continue;
|
|
209
|
+
const detail =
|
|
210
|
+
typeof p.detail === "string" ? sanitizeLine(p.detail, 2_000) : "";
|
|
211
|
+
phases.push({ title, ...(detail ? { detail } : {}) });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const rawLogs = Array.isArray(record.logs) ? record.logs : [];
|
|
215
|
+
const logs: WorkflowLogEntry[] = [];
|
|
216
|
+
for (const item of rawLogs) {
|
|
217
|
+
if (!item || typeof item !== "object") continue;
|
|
218
|
+
const entry = item as Record<string, unknown>;
|
|
219
|
+
if (typeof entry.text !== "string") continue;
|
|
220
|
+
// Re-sanitized on the way in: this file was written by an earlier run and
|
|
221
|
+
// is read back as untrusted data, not as something we just produced.
|
|
222
|
+
logs.push({
|
|
223
|
+
at: typeof entry.at === "number" ? entry.at : startedAt,
|
|
224
|
+
text: sanitizeLine(entry.text, MAX_LOG_TEXT),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const status =
|
|
229
|
+
record.status === "running" ||
|
|
230
|
+
record.status === "failed" ||
|
|
231
|
+
record.status === "aborted"
|
|
232
|
+
? record.status
|
|
233
|
+
: "completed";
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
runId,
|
|
237
|
+
sessionId:
|
|
238
|
+
typeof record.sessionId === "string" ? record.sessionId : undefined,
|
|
239
|
+
name:
|
|
240
|
+
typeof record.name === "string"
|
|
241
|
+
? sanitizeLine(record.name, 160) || undefined
|
|
242
|
+
: typeof meta.name === "string"
|
|
243
|
+
? sanitizeLine(meta.name, 160) || undefined
|
|
244
|
+
: undefined,
|
|
245
|
+
description:
|
|
246
|
+
typeof record.description === "string"
|
|
247
|
+
? sanitizeLine(record.description, 2_000) || undefined
|
|
248
|
+
: typeof meta.description === "string"
|
|
249
|
+
? sanitizeLine(meta.description, 2_000) || undefined
|
|
250
|
+
: undefined,
|
|
251
|
+
background: record.background === true,
|
|
252
|
+
status,
|
|
253
|
+
startedAt,
|
|
254
|
+
finishedAt:
|
|
255
|
+
typeof record.finishedAt === "number" ? record.finishedAt : undefined,
|
|
256
|
+
phases,
|
|
257
|
+
currentPhase:
|
|
258
|
+
typeof record.currentPhase === "string"
|
|
259
|
+
? sanitizeLine(record.currentPhase, 160) || undefined
|
|
260
|
+
: undefined,
|
|
261
|
+
agents,
|
|
262
|
+
...(logs.length > 0 ? { logs } : {}),
|
|
263
|
+
...(typeof record.logsDropped === "number" && record.logsDropped > 0
|
|
264
|
+
? { logsDropped: record.logsDropped }
|
|
265
|
+
: {}),
|
|
266
|
+
result: record.result,
|
|
267
|
+
resultArtifact:
|
|
268
|
+
typeof record.resultArtifact === "string"
|
|
269
|
+
? record.resultArtifact
|
|
270
|
+
: undefined,
|
|
271
|
+
transcriptArtifact:
|
|
272
|
+
typeof record.transcriptArtifact === "string"
|
|
273
|
+
? record.transcriptArtifact
|
|
274
|
+
: undefined,
|
|
275
|
+
resumedFrom:
|
|
276
|
+
typeof record.resumedFrom === "string" ? record.resumedFrom : undefined,
|
|
277
|
+
resumeNote:
|
|
278
|
+
typeof record.resumeNote === "string" ? record.resumeNote : undefined,
|
|
279
|
+
error:
|
|
280
|
+
typeof record.error === "string"
|
|
281
|
+
? sanitizeLine(record.error, 2_000) || undefined
|
|
282
|
+
: undefined,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function sessionWorkflowRunIds(ctx: ExtensionContext): Set<string> {
|
|
287
|
+
const runIds = new Set<string>();
|
|
288
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
289
|
+
if (
|
|
290
|
+
entry.type !== "message" ||
|
|
291
|
+
entry.message.role !== "toolResult" ||
|
|
292
|
+
entry.message.toolName !== "workflow"
|
|
293
|
+
) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const details = entry.message.details;
|
|
297
|
+
if (!details || typeof details !== "object") continue;
|
|
298
|
+
const runId = (details as Record<string, unknown>).runId;
|
|
299
|
+
if (typeof runId === "string") runIds.add(runId);
|
|
300
|
+
}
|
|
301
|
+
return runIds;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function loadRunEntries(
|
|
305
|
+
active: Map<string, WorkflowDetails>,
|
|
306
|
+
sessionId: string,
|
|
307
|
+
referencedRunIds: ReadonlySet<string>,
|
|
308
|
+
/** Hide runs untouched by the current request; live runs always show. */
|
|
309
|
+
startedSince = 0,
|
|
310
|
+
): RunEntry[] {
|
|
311
|
+
let names: string[] = [];
|
|
312
|
+
try {
|
|
313
|
+
names = fs.readdirSync(runsDir()).filter(isWorkflowRunId);
|
|
314
|
+
} catch {
|
|
315
|
+
// No runs yet.
|
|
316
|
+
}
|
|
317
|
+
const entries: RunEntry[] = [];
|
|
318
|
+
for (const runId of names) {
|
|
319
|
+
const live = active.get(runId);
|
|
320
|
+
if (live) {
|
|
321
|
+
entries.push({ runId, details: live, live: true });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
const raw = JSON.parse(
|
|
326
|
+
fs.readFileSync(path.join(runsDir(), runId, "workflow.json"), "utf8"),
|
|
327
|
+
);
|
|
328
|
+
const details = normalizePersistedWorkflowDetails(runId, raw);
|
|
329
|
+
const touchedAt = Math.max(
|
|
330
|
+
details?.startedAt ?? 0,
|
|
331
|
+
details?.finishedAt ?? 0,
|
|
332
|
+
);
|
|
333
|
+
if (
|
|
334
|
+
details &&
|
|
335
|
+
touchedAt >= startedSince &&
|
|
336
|
+
(details.sessionId === sessionId || referencedRunIds.has(runId))
|
|
337
|
+
) {
|
|
338
|
+
const runDir = path.join(runsDir(), runId);
|
|
339
|
+
if (details.resultArtifact) {
|
|
340
|
+
try {
|
|
341
|
+
details.result = JSON.parse(
|
|
342
|
+
fs.readFileSync(
|
|
343
|
+
path.join(runDir, path.basename(details.resultArtifact)),
|
|
344
|
+
"utf8",
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
} catch {
|
|
348
|
+
// Keep the compact compatibility marker from workflow.json.
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (details.transcriptArtifact) {
|
|
352
|
+
try {
|
|
353
|
+
const transcripts = JSON.parse(
|
|
354
|
+
fs.readFileSync(
|
|
355
|
+
path.join(runDir, path.basename(details.transcriptArtifact)),
|
|
356
|
+
"utf8",
|
|
357
|
+
),
|
|
358
|
+
) as Record<string, unknown>;
|
|
359
|
+
for (const agent of details.agents) {
|
|
360
|
+
agent.transcript = normalizeTranscript(
|
|
361
|
+
transcripts[String(agent.index)],
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
} catch {
|
|
365
|
+
// Older or partially written artifacts simply lack transcripts.
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (details.status === "running") {
|
|
369
|
+
details.status = "aborted";
|
|
370
|
+
details.finishedAt = details.finishedAt ?? Date.now();
|
|
371
|
+
details.error =
|
|
372
|
+
details.error ?? "Recovered stale run that was not active";
|
|
373
|
+
for (const agent of details.agents) {
|
|
374
|
+
if (agent.state !== "running") continue;
|
|
375
|
+
agent.state = "error";
|
|
376
|
+
agent.error = agent.error ?? "Run ended before this agent settled";
|
|
377
|
+
agent.finishedAt = details.finishedAt;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
entries.push({ runId, details, live: false });
|
|
381
|
+
}
|
|
382
|
+
} catch {
|
|
383
|
+
// Skip unreadable runs.
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return entries.sort((a, b) => b.details.startedAt - a.details.startedAt);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function buildReport(details: WorkflowDetails): string {
|
|
390
|
+
const { done, failed } = countStates(details);
|
|
391
|
+
const lines: string[] = [
|
|
392
|
+
`# Workflow ${details.name ?? details.runId}`,
|
|
393
|
+
"",
|
|
394
|
+
`- Run: ${details.runId}`,
|
|
395
|
+
`- Status: ${statusWord(details.status)}`,
|
|
396
|
+
`- Agents: ${done}/${details.agents.length} ok${failed ? `, ${failed} failed` : ""}`,
|
|
397
|
+
`- Elapsed: ${formatElapsed(details.startedAt, details.finishedAt)}`,
|
|
398
|
+
];
|
|
399
|
+
const totals = formatUsage(aggregateUsage(details.agents));
|
|
400
|
+
if (totals) lines.push(`- Usage: ${totals}`);
|
|
401
|
+
if (details.description) lines.push("", details.description);
|
|
402
|
+
if (details.error) lines.push("", `**Error:** ${details.error}`);
|
|
403
|
+
|
|
404
|
+
for (const group of phaseGroups(details, true)) {
|
|
405
|
+
lines.push("", `## ${group.title}`, "");
|
|
406
|
+
if (group.agents.length === 0) {
|
|
407
|
+
lines.push("_no agents_");
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
for (const agent of group.agents) {
|
|
411
|
+
const status =
|
|
412
|
+
agent.state === "done"
|
|
413
|
+
? "ok"
|
|
414
|
+
: agent.state === "error"
|
|
415
|
+
? "FAILED"
|
|
416
|
+
: "running";
|
|
417
|
+
const stats = [
|
|
418
|
+
agent.model,
|
|
419
|
+
agentContext(agent),
|
|
420
|
+
agent.acceptance ? `acceptance:${agent.acceptance.status}` : undefined,
|
|
421
|
+
formatElapsed(agent.startedAt, agent.finishedAt),
|
|
422
|
+
]
|
|
423
|
+
.filter(Boolean)
|
|
424
|
+
.join(" · ");
|
|
425
|
+
lines.push(
|
|
426
|
+
`- **${agent.label}** — ${status}${stats ? ` (${stats})` : ""}`,
|
|
427
|
+
);
|
|
428
|
+
if (agent.error) lines.push(` - error: ${agent.error}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (details.result !== undefined) {
|
|
433
|
+
lines.push(
|
|
434
|
+
"",
|
|
435
|
+
"## Result",
|
|
436
|
+
"",
|
|
437
|
+
"```json",
|
|
438
|
+
resultJson(details.result),
|
|
439
|
+
"```",
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
if (details.logs && details.logs.length > 0) {
|
|
443
|
+
lines.push("", "## Log", "");
|
|
444
|
+
if (details.logsDropped) {
|
|
445
|
+
lines.push(`_${details.logsDropped} earlier line(s) dropped_`, "");
|
|
446
|
+
}
|
|
447
|
+
for (const entry of details.logs) lines.push(`- ${entry.text}`);
|
|
448
|
+
}
|
|
449
|
+
lines.push("");
|
|
450
|
+
return lines.join("\n");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
type View = "list" | "detail" | "transcript";
|
|
454
|
+
type DetailFocus = "phases" | "agents";
|
|
455
|
+
|
|
456
|
+
export class WorkflowDashboard {
|
|
457
|
+
private view: View = "list";
|
|
458
|
+
private entries: RunEntry[] = [];
|
|
459
|
+
private listIndex = 0;
|
|
460
|
+
private phaseIndex = 0;
|
|
461
|
+
private agentIndex = 0;
|
|
462
|
+
private detailFocus: DetailFocus = "phases";
|
|
463
|
+
private transcriptScroll = 0;
|
|
464
|
+
private transcriptRowCount = 0;
|
|
465
|
+
private transcriptViewportSize = 1;
|
|
466
|
+
private current?: RunEntry;
|
|
467
|
+
private openedDirectly = false;
|
|
468
|
+
private notice?: string;
|
|
469
|
+
private noticeAt = 0;
|
|
470
|
+
private disposed = false;
|
|
471
|
+
private timer: ReturnType<typeof setInterval>;
|
|
472
|
+
private tui: TUI;
|
|
473
|
+
private theme: Theme;
|
|
474
|
+
private keybindings: KeybindingsManager;
|
|
475
|
+
private getActive: () => Map<string, WorkflowDetails>;
|
|
476
|
+
private sessionId: string;
|
|
477
|
+
private referencedRunIds: ReadonlySet<string>;
|
|
478
|
+
private startedSince: number;
|
|
479
|
+
private close: () => void;
|
|
480
|
+
private onAbort?: (runId: string) => boolean;
|
|
481
|
+
|
|
482
|
+
constructor(
|
|
483
|
+
tui: TUI,
|
|
484
|
+
theme: Theme,
|
|
485
|
+
keybindings: KeybindingsManager,
|
|
486
|
+
getActive: () => Map<string, WorkflowDetails>,
|
|
487
|
+
sessionId: string,
|
|
488
|
+
referencedRunIds: ReadonlySet<string>,
|
|
489
|
+
startedSince: number,
|
|
490
|
+
close: () => void,
|
|
491
|
+
initialRunId?: string,
|
|
492
|
+
onAbort?: (runId: string) => boolean,
|
|
493
|
+
) {
|
|
494
|
+
this.tui = tui;
|
|
495
|
+
this.theme = theme;
|
|
496
|
+
this.keybindings = keybindings;
|
|
497
|
+
this.getActive = getActive;
|
|
498
|
+
this.sessionId = sessionId;
|
|
499
|
+
this.referencedRunIds = referencedRunIds;
|
|
500
|
+
this.startedSince = startedSince;
|
|
501
|
+
this.close = close;
|
|
502
|
+
this.onAbort = onAbort;
|
|
503
|
+
this.refresh();
|
|
504
|
+
if (initialRunId) {
|
|
505
|
+
const resolution = resolveWorkflowRunTarget(
|
|
506
|
+
initialRunId,
|
|
507
|
+
this.entries.map((entry) => entry.runId),
|
|
508
|
+
);
|
|
509
|
+
if (resolution.ok) {
|
|
510
|
+
const entry = this.entries.find(
|
|
511
|
+
(candidate) => candidate.runId === resolution.runId,
|
|
512
|
+
);
|
|
513
|
+
if (entry) {
|
|
514
|
+
this.listIndex = this.entries.indexOf(entry);
|
|
515
|
+
this.enterEntry(entry, true);
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
this.notice = resolution.error;
|
|
519
|
+
this.noticeAt = Date.now();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
this.timer = setInterval(() => {
|
|
523
|
+
if (
|
|
524
|
+
this.entries.some((e) => e.live) ||
|
|
525
|
+
this.current?.live ||
|
|
526
|
+
this.notice
|
|
527
|
+
) {
|
|
528
|
+
this.refresh();
|
|
529
|
+
this.tui.requestRender();
|
|
530
|
+
}
|
|
531
|
+
}, 500);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
dispose() {
|
|
535
|
+
if (this.disposed) return;
|
|
536
|
+
this.disposed = true;
|
|
537
|
+
clearInterval(this.timer);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
invalidate() {}
|
|
541
|
+
|
|
542
|
+
private refresh() {
|
|
543
|
+
const selected = this.entries[this.listIndex]?.runId;
|
|
544
|
+
this.entries = loadRunEntries(
|
|
545
|
+
this.getActive(),
|
|
546
|
+
this.sessionId,
|
|
547
|
+
this.referencedRunIds,
|
|
548
|
+
this.startedSince,
|
|
549
|
+
);
|
|
550
|
+
if (selected) {
|
|
551
|
+
const index = this.entries.findIndex((e) => e.runId === selected);
|
|
552
|
+
if (index >= 0) this.listIndex = index;
|
|
553
|
+
}
|
|
554
|
+
this.listIndex = Math.min(
|
|
555
|
+
this.listIndex,
|
|
556
|
+
Math.max(0, this.entries.length - 1),
|
|
557
|
+
);
|
|
558
|
+
if (this.current) {
|
|
559
|
+
const refreshed = this.entries.find(
|
|
560
|
+
(e) => e.runId === this.current?.runId,
|
|
561
|
+
);
|
|
562
|
+
if (refreshed) this.current = refreshed;
|
|
563
|
+
}
|
|
564
|
+
if (this.notice && Date.now() - this.noticeAt > NOTICE_TTL_MS)
|
|
565
|
+
this.notice = undefined;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private enterEntry(entry: RunEntry, directly: boolean) {
|
|
569
|
+
this.current = entry;
|
|
570
|
+
this.openedDirectly = directly;
|
|
571
|
+
const groups = phaseGroups(entry.details, true);
|
|
572
|
+
const currentPhase = groups.findIndex(
|
|
573
|
+
(group) => group.title === entry.details.currentPhase,
|
|
574
|
+
);
|
|
575
|
+
this.phaseIndex = Math.max(0, currentPhase);
|
|
576
|
+
this.agentIndex = 0;
|
|
577
|
+
this.detailFocus = "phases";
|
|
578
|
+
this.view = "detail";
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private groups(): PhaseGroup[] {
|
|
582
|
+
if (!this.current) return [];
|
|
583
|
+
return phaseGroups(this.current.details, true);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private selectedGroup(): PhaseGroup | undefined {
|
|
587
|
+
return this.groups()[this.phaseIndex];
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private selectedAgent(): AgentRecord | undefined {
|
|
591
|
+
return this.selectedGroup()?.agents[this.agentIndex];
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private clampAgentIndex() {
|
|
595
|
+
const agents = this.selectedGroup()?.agents ?? [];
|
|
596
|
+
this.agentIndex = Math.min(this.agentIndex, Math.max(0, agents.length - 1));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private saveReport() {
|
|
600
|
+
const entry = this.current;
|
|
601
|
+
if (!entry) return;
|
|
602
|
+
const target = path.join(runsDir(), entry.runId, "report.md");
|
|
603
|
+
try {
|
|
604
|
+
writeFileAtomic(target, buildReport(entry.details));
|
|
605
|
+
this.notice = `saved ${shortenHome(target)}`;
|
|
606
|
+
} catch (error) {
|
|
607
|
+
this.notice = `save failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
608
|
+
}
|
|
609
|
+
this.noticeAt = Date.now();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Request cancellation of a run by id, surfacing the outcome as a notice. */
|
|
613
|
+
private abortRun(entry: RunEntry | undefined) {
|
|
614
|
+
if (!entry) return;
|
|
615
|
+
if (entry.details.status !== "running") {
|
|
616
|
+
this.setNotice(`${entry.runId} is already ${entry.details.status}`);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const stopped = this.onAbort?.(entry.runId) ?? false;
|
|
620
|
+
this.setNotice(
|
|
621
|
+
stopped ? `stopping ${entry.runId}…` : `cannot stop ${entry.runId}`,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private setNotice(text: string) {
|
|
626
|
+
this.notice = text;
|
|
627
|
+
this.noticeAt = Date.now();
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
handleInput(data: string) {
|
|
631
|
+
const up = this.keybindings.matches(data, "tui.select.up") || data === "k";
|
|
632
|
+
const down =
|
|
633
|
+
this.keybindings.matches(data, "tui.select.down") || data === "j";
|
|
634
|
+
const left =
|
|
635
|
+
this.keybindings.matches(data, "tui.editor.cursorLeft") || data === "h";
|
|
636
|
+
const right =
|
|
637
|
+
this.keybindings.matches(data, "tui.editor.cursorRight") || data === "l";
|
|
638
|
+
const confirm = this.keybindings.matches(data, "tui.select.confirm");
|
|
639
|
+
const cancel = this.keybindings.matches(data, "tui.select.cancel");
|
|
640
|
+
|
|
641
|
+
if (this.view === "list") {
|
|
642
|
+
if (up) {
|
|
643
|
+
this.listIndex = wrapSelection(this.listIndex, -1, this.entries.length);
|
|
644
|
+
} else if (down) {
|
|
645
|
+
this.listIndex = wrapSelection(this.listIndex, 1, this.entries.length);
|
|
646
|
+
} else if (data === "g") {
|
|
647
|
+
this.listIndex = 0;
|
|
648
|
+
} else if (data === "G") {
|
|
649
|
+
this.listIndex = Math.max(0, this.entries.length - 1);
|
|
650
|
+
} else if (data === "x") {
|
|
651
|
+
this.abortRun(this.entries[this.listIndex]);
|
|
652
|
+
} else if (confirm || right) {
|
|
653
|
+
const entry = this.entries[this.listIndex];
|
|
654
|
+
if (entry) this.enterEntry(entry, false);
|
|
655
|
+
} else if (cancel || left) {
|
|
656
|
+
this.close();
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
} else if (this.view === "detail") {
|
|
660
|
+
if (this.detailFocus === "phases") {
|
|
661
|
+
if (up) {
|
|
662
|
+
this.phaseIndex = wrapSelection(
|
|
663
|
+
this.phaseIndex,
|
|
664
|
+
-1,
|
|
665
|
+
this.groups().length,
|
|
666
|
+
);
|
|
667
|
+
this.agentIndex = 0;
|
|
668
|
+
} else if (down) {
|
|
669
|
+
this.phaseIndex = wrapSelection(
|
|
670
|
+
this.phaseIndex,
|
|
671
|
+
1,
|
|
672
|
+
this.groups().length,
|
|
673
|
+
);
|
|
674
|
+
this.agentIndex = 0;
|
|
675
|
+
} else if (data === "g") {
|
|
676
|
+
this.phaseIndex = 0;
|
|
677
|
+
this.agentIndex = 0;
|
|
678
|
+
} else if (data === "G") {
|
|
679
|
+
this.phaseIndex = Math.max(0, this.groups().length - 1);
|
|
680
|
+
this.agentIndex = 0;
|
|
681
|
+
} else if (
|
|
682
|
+
right ||
|
|
683
|
+
(confirm && (this.selectedGroup()?.agents.length ?? 0) > 0)
|
|
684
|
+
) {
|
|
685
|
+
if ((this.selectedGroup()?.agents.length ?? 0) > 0) {
|
|
686
|
+
this.detailFocus = "agents";
|
|
687
|
+
this.clampAgentIndex();
|
|
688
|
+
}
|
|
689
|
+
} else if (cancel || left) {
|
|
690
|
+
if (this.openedDirectly) this.close();
|
|
691
|
+
else {
|
|
692
|
+
this.view = "list";
|
|
693
|
+
this.refresh();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
} else {
|
|
697
|
+
const agents = this.selectedGroup()?.agents ?? [];
|
|
698
|
+
if (up) {
|
|
699
|
+
this.agentIndex = wrapSelection(this.agentIndex, -1, agents.length);
|
|
700
|
+
} else if (down) {
|
|
701
|
+
this.agentIndex = wrapSelection(this.agentIndex, 1, agents.length);
|
|
702
|
+
} else if (data === "g") {
|
|
703
|
+
this.agentIndex = 0;
|
|
704
|
+
} else if (data === "G") {
|
|
705
|
+
this.agentIndex = Math.max(0, agents.length - 1);
|
|
706
|
+
} else if (left || cancel) {
|
|
707
|
+
this.detailFocus = "phases";
|
|
708
|
+
} else if ((right || confirm) && this.selectedAgent()) {
|
|
709
|
+
this.transcriptScroll = 0;
|
|
710
|
+
this.view = "transcript";
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (data === "s") this.saveReport();
|
|
714
|
+
if (data === "x") this.abortRun(this.current);
|
|
715
|
+
} else {
|
|
716
|
+
const maxScroll = Math.max(
|
|
717
|
+
0,
|
|
718
|
+
this.transcriptRowCount - this.transcriptViewportSize,
|
|
719
|
+
);
|
|
720
|
+
const scrollStep =
|
|
721
|
+
data === "j" || data === "k" ? TRANSCRIPT_SCROLL_STEP : 1;
|
|
722
|
+
const pageStep = Math.max(1, this.transcriptViewportSize - 2);
|
|
723
|
+
if (up) {
|
|
724
|
+
this.transcriptScroll = Math.max(0, this.transcriptScroll - scrollStep);
|
|
725
|
+
} else if (down) {
|
|
726
|
+
this.transcriptScroll = Math.min(
|
|
727
|
+
maxScroll,
|
|
728
|
+
this.transcriptScroll + scrollStep,
|
|
729
|
+
);
|
|
730
|
+
} else if (matchesKey(data, Key.ctrl("u"))) {
|
|
731
|
+
this.transcriptScroll = Math.max(0, this.transcriptScroll - pageStep);
|
|
732
|
+
} else if (matchesKey(data, Key.ctrl("d"))) {
|
|
733
|
+
this.transcriptScroll = Math.min(
|
|
734
|
+
maxScroll,
|
|
735
|
+
this.transcriptScroll + pageStep,
|
|
736
|
+
);
|
|
737
|
+
} else if (data === "g") {
|
|
738
|
+
this.transcriptScroll = 0;
|
|
739
|
+
} else if (data === "G") {
|
|
740
|
+
this.transcriptScroll = maxScroll;
|
|
741
|
+
} else if (cancel || left) {
|
|
742
|
+
this.view = "detail";
|
|
743
|
+
this.detailFocus = "agents";
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
this.tui.requestRender();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
render(width: number): string[] {
|
|
750
|
+
const height = Math.max(MIN_HEIGHT, this.tui.terminal.rows - 1);
|
|
751
|
+
let lines: string[];
|
|
752
|
+
if (this.view === "transcript" && this.current && this.selectedAgent()) {
|
|
753
|
+
lines = this.renderTranscript(
|
|
754
|
+
this.current.details,
|
|
755
|
+
this.selectedAgent()!,
|
|
756
|
+
width,
|
|
757
|
+
height,
|
|
758
|
+
);
|
|
759
|
+
} else if (this.view === "detail" && this.current) {
|
|
760
|
+
lines = this.renderDetail(this.current.details, width, height);
|
|
761
|
+
} else {
|
|
762
|
+
lines = this.renderList(width, height);
|
|
763
|
+
}
|
|
764
|
+
return lines.map((line) => truncateToWidth(line, width, ""));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** Compose `left ... right` within `width`, truncating left when needed. */
|
|
768
|
+
private split(left: string, right: string, width: number): string {
|
|
769
|
+
const rightWidth = visibleWidth(right);
|
|
770
|
+
let text = left;
|
|
771
|
+
if (visibleWidth(text) + rightWidth + 1 > width) {
|
|
772
|
+
text = truncateToWidth(text, Math.max(0, width - rightWidth - 2), "…");
|
|
773
|
+
}
|
|
774
|
+
const pad = Math.max(1, width - visibleWidth(text) - rightWidth);
|
|
775
|
+
return text + " ".repeat(pad) + right;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Bordered panel with a title in the top border, padded to exact height. */
|
|
779
|
+
private panel(
|
|
780
|
+
title: string,
|
|
781
|
+
rows: string[],
|
|
782
|
+
width: number,
|
|
783
|
+
height: number,
|
|
784
|
+
): string[] {
|
|
785
|
+
const theme = this.theme;
|
|
786
|
+
const inner = Math.max(0, width - 2);
|
|
787
|
+
const border = (s: string) => theme.fg("borderMuted", s);
|
|
788
|
+
const titleText = truncateToWidth(` ${title} `, Math.max(0, inner - 2));
|
|
789
|
+
const dashes = Math.max(0, inner - visibleWidth(titleText) - 1);
|
|
790
|
+
const lines: string[] = [
|
|
791
|
+
border("╭─") + titleText + border("─".repeat(dashes) + "╮"),
|
|
792
|
+
];
|
|
793
|
+
const bodyHeight = Math.max(0, height - 2);
|
|
794
|
+
for (let i = 0; i < bodyHeight; i++) {
|
|
795
|
+
const row = rows[i] ?? "";
|
|
796
|
+
const clipped = truncateToWidth(row, inner, "…");
|
|
797
|
+
const pad = Math.max(0, inner - visibleWidth(clipped));
|
|
798
|
+
lines.push(border("│") + clipped + " ".repeat(pad) + border("│"));
|
|
799
|
+
}
|
|
800
|
+
lines.push(border("╰" + "─".repeat(inner) + "╯"));
|
|
801
|
+
return lines;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Scroll window keeping `selected` visible. */
|
|
805
|
+
private windowed<T>(
|
|
806
|
+
items: T[],
|
|
807
|
+
selected: number,
|
|
808
|
+
size: number,
|
|
809
|
+
): { items: T[]; offset: number } {
|
|
810
|
+
if (items.length <= size) return { items, offset: 0 };
|
|
811
|
+
const offset = Math.max(
|
|
812
|
+
0,
|
|
813
|
+
Math.min(selected - Math.floor(size / 2), items.length - size),
|
|
814
|
+
);
|
|
815
|
+
return { items: items.slice(offset, offset + size), offset };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
private keys(binding: Parameters<KeybindingsManager["getKeys"]>[0]) {
|
|
819
|
+
return this.keybindings.getKeys(binding).join("/") || "unbound";
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
private hintLine(hint: string, width: number): string {
|
|
823
|
+
const theme = this.theme;
|
|
824
|
+
if (this.notice)
|
|
825
|
+
return truncateToWidth(theme.fg("accent", ` ${this.notice}`), width);
|
|
826
|
+
return truncateToWidth(theme.fg("dim", ` ${hint}`), width);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
private renderList(width: number, height: number): string[] {
|
|
830
|
+
const theme = this.theme;
|
|
831
|
+
const lines: string[] = [];
|
|
832
|
+
const header = this.split(
|
|
833
|
+
" " + theme.bold(theme.fg("accent", "Workflows")),
|
|
834
|
+
theme.fg(
|
|
835
|
+
"dim",
|
|
836
|
+
`${this.entries.length} run${this.entries.length === 1 ? "" : "s"} `,
|
|
837
|
+
),
|
|
838
|
+
width,
|
|
839
|
+
);
|
|
840
|
+
lines.push(header);
|
|
841
|
+
|
|
842
|
+
const panelHeight = height - 2;
|
|
843
|
+
const bodyHeight = Math.max(0, panelHeight - 2);
|
|
844
|
+
|
|
845
|
+
if (this.entries.length === 0) {
|
|
846
|
+
lines.push(
|
|
847
|
+
...this.panel(
|
|
848
|
+
"Runs",
|
|
849
|
+
[theme.fg("dim", " no workflow runs for this request")],
|
|
850
|
+
width,
|
|
851
|
+
panelHeight,
|
|
852
|
+
),
|
|
853
|
+
);
|
|
854
|
+
lines.push(
|
|
855
|
+
this.hintLine(`${this.keys("tui.select.cancel")} close`, width),
|
|
856
|
+
);
|
|
857
|
+
return lines;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const { items, offset } = this.windowed(
|
|
861
|
+
this.entries,
|
|
862
|
+
this.listIndex,
|
|
863
|
+
bodyHeight,
|
|
864
|
+
);
|
|
865
|
+
const rows = items.map((entry, i) => {
|
|
866
|
+
const index = offset + i;
|
|
867
|
+
const selected = index === this.listIndex;
|
|
868
|
+
const d = entry.details;
|
|
869
|
+
const marker = selected ? theme.fg("accent", "❯") : " ";
|
|
870
|
+
const name = d.name ?? d.runId;
|
|
871
|
+
const label = selected
|
|
872
|
+
? theme.fg("accent", name)
|
|
873
|
+
: theme.fg("text", name);
|
|
874
|
+
const { done, failed } = countStates(d);
|
|
875
|
+
const settled = done + failed;
|
|
876
|
+
const right =
|
|
877
|
+
theme.fg(
|
|
878
|
+
"dim",
|
|
879
|
+
`${settled}/${d.agents.length} agents · ${formatElapsed(d.startedAt, d.finishedAt)} · `,
|
|
880
|
+
) +
|
|
881
|
+
theme.fg(statusColor(d.status), statusWord(d.status)) +
|
|
882
|
+
" ";
|
|
883
|
+
const left = ` ${marker} ${statusSquareFor(d, theme)} ${label} ${theme.fg("dim", d.runId)}`;
|
|
884
|
+
return this.split(left, right, width - 2);
|
|
885
|
+
});
|
|
886
|
+
lines.push(...this.panel("Runs", rows, width, panelHeight));
|
|
887
|
+
lines.push(
|
|
888
|
+
this.hintLine(
|
|
889
|
+
`${this.keys("tui.select.up")}/${this.keys("tui.select.down")} select · ${this.keys("tui.select.confirm")} open · x stop · ${this.keys("tui.select.cancel")} close`,
|
|
890
|
+
width,
|
|
891
|
+
),
|
|
892
|
+
);
|
|
893
|
+
return lines;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
private renderDetail(
|
|
897
|
+
d: WorkflowDetails,
|
|
898
|
+
width: number,
|
|
899
|
+
height: number,
|
|
900
|
+
): string[] {
|
|
901
|
+
const theme = this.theme;
|
|
902
|
+
const lines: string[] = [];
|
|
903
|
+
|
|
904
|
+
const { done, failed } = countStates(d);
|
|
905
|
+
const settled = done + failed;
|
|
906
|
+
const right =
|
|
907
|
+
theme.fg(
|
|
908
|
+
"dim",
|
|
909
|
+
`${settled}/${d.agents.length} agents · ${formatElapsed(d.startedAt, d.finishedAt)} · `,
|
|
910
|
+
) +
|
|
911
|
+
theme.fg(statusColor(d.status), statusWord(d.status)) +
|
|
912
|
+
" ";
|
|
913
|
+
lines.push(
|
|
914
|
+
this.split(
|
|
915
|
+
" " + theme.bold(theme.fg("accent", d.name ?? d.runId)),
|
|
916
|
+
right,
|
|
917
|
+
width,
|
|
918
|
+
),
|
|
919
|
+
);
|
|
920
|
+
const totals = formatUsage(aggregateUsage(d.agents));
|
|
921
|
+
const subLeft = " " + theme.fg("muted", d.description ?? d.runId);
|
|
922
|
+
lines.push(
|
|
923
|
+
this.split(subLeft, totals ? theme.fg("dim", `${totals} `) : " ", width),
|
|
924
|
+
);
|
|
925
|
+
|
|
926
|
+
const groups = this.groups();
|
|
927
|
+
this.phaseIndex = Math.min(this.phaseIndex, Math.max(0, groups.length - 1));
|
|
928
|
+
const selectedGroup = groups[this.phaseIndex];
|
|
929
|
+
this.clampAgentIndex();
|
|
930
|
+
|
|
931
|
+
// A narrator strip under the panels, since log() is run-wide and belongs to
|
|
932
|
+
// no single phase. It yields rows to the panels first: on a short terminal
|
|
933
|
+
// the agent list is what the view exists for.
|
|
934
|
+
const logBudget = Math.max(0, Math.min(3, height - 12));
|
|
935
|
+
const recentLogs = (d.logs ?? []).slice(-logBudget);
|
|
936
|
+
const panelHeight = height - 3 - recentLogs.length;
|
|
937
|
+
const bodyHeight = Math.max(0, panelHeight - 2);
|
|
938
|
+
|
|
939
|
+
// Left: phases sidebar.
|
|
940
|
+
const maxTitle = Math.max(8, ...groups.map((g) => g.title.length));
|
|
941
|
+
const sidebarWidth = Math.min(
|
|
942
|
+
Math.max(maxTitle + 12, 20),
|
|
943
|
+
Math.floor(width / 3),
|
|
944
|
+
);
|
|
945
|
+
const sidebarInner = sidebarWidth - 2;
|
|
946
|
+
const phaseWindow = this.windowed(groups, this.phaseIndex, bodyHeight);
|
|
947
|
+
const phaseRows = phaseWindow.items.map((group, i) => {
|
|
948
|
+
const index = phaseWindow.offset + i;
|
|
949
|
+
const selected = index === this.phaseIndex;
|
|
950
|
+
const marker = selected
|
|
951
|
+
? theme.fg(this.detailFocus === "phases" ? "accent" : "muted", "❯")
|
|
952
|
+
: " ";
|
|
953
|
+
const groupDone = group.agents.filter(
|
|
954
|
+
(a) => a.state !== "running",
|
|
955
|
+
).length;
|
|
956
|
+
const square = groupSquare(group, theme);
|
|
957
|
+
const title =
|
|
958
|
+
selected && this.detailFocus === "phases"
|
|
959
|
+
? theme.fg("accent", group.title)
|
|
960
|
+
: theme.fg("text", group.title);
|
|
961
|
+
const counts =
|
|
962
|
+
group.agents.length > 0
|
|
963
|
+
? theme.fg("dim", `${groupDone}/${group.agents.length} `)
|
|
964
|
+
: theme.fg("dim", "- ");
|
|
965
|
+
return this.split(` ${marker} ${square} ${title}`, counts, sidebarInner);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
// Right: agents in the selected phase.
|
|
969
|
+
const agentsWidth = width - sidebarWidth - 1;
|
|
970
|
+
const agentsInner = agentsWidth - 2;
|
|
971
|
+
const agentRows: string[] = [];
|
|
972
|
+
if (selectedGroup) {
|
|
973
|
+
const maxLabel = Math.max(
|
|
974
|
+
0,
|
|
975
|
+
...selectedGroup.agents.map((a) => a.label.length),
|
|
976
|
+
);
|
|
977
|
+
const agentWindow = this.windowed(
|
|
978
|
+
selectedGroup.agents,
|
|
979
|
+
this.agentIndex,
|
|
980
|
+
bodyHeight,
|
|
981
|
+
);
|
|
982
|
+
for (const [visibleIndex, agent] of agentWindow.items.entries()) {
|
|
983
|
+
const index = agentWindow.offset + visibleIndex;
|
|
984
|
+
const selected = index === this.agentIndex;
|
|
985
|
+
const marker =
|
|
986
|
+
selected && this.detailFocus === "agents"
|
|
987
|
+
? theme.fg("accent", "❯")
|
|
988
|
+
: " ";
|
|
989
|
+
const stats = [
|
|
990
|
+
agent.model,
|
|
991
|
+
agentContext(agent),
|
|
992
|
+
agent.acceptance
|
|
993
|
+
? `acceptance:${agent.acceptance.status}`
|
|
994
|
+
: undefined,
|
|
995
|
+
]
|
|
996
|
+
.filter(Boolean)
|
|
997
|
+
.join(" · ");
|
|
998
|
+
const label =
|
|
999
|
+
selected && this.detailFocus === "agents"
|
|
1000
|
+
? theme.fg("accent", agent.label.padEnd(Math.min(maxLabel, 40)))
|
|
1001
|
+
: theme.fg("text", agent.label.padEnd(Math.min(maxLabel, 40)));
|
|
1002
|
+
const left = ` ${marker} ${stateSquare(agent.state, theme)} ${label} ${theme.fg("dim", stats)}`;
|
|
1003
|
+
const right = theme.fg(
|
|
1004
|
+
"dim",
|
|
1005
|
+
`${formatElapsed(agent.startedAt, agent.finishedAt)} `,
|
|
1006
|
+
);
|
|
1007
|
+
agentRows.push(this.split(left, right, agentsInner));
|
|
1008
|
+
if (agent.error) {
|
|
1009
|
+
agentRows.push(
|
|
1010
|
+
truncateToWidth(
|
|
1011
|
+
` ${theme.fg("error", sanitizeLine(agent.error, 2_000))}`,
|
|
1012
|
+
agentsInner,
|
|
1013
|
+
"…",
|
|
1014
|
+
),
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (selectedGroup.agents.length === 0) {
|
|
1019
|
+
agentRows.push(theme.fg("dim", " no agents in this phase yet"));
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
if (d.error) {
|
|
1023
|
+
agentRows.push("");
|
|
1024
|
+
agentRows.push(
|
|
1025
|
+
truncateToWidth(
|
|
1026
|
+
` ${theme.fg("error", `workflow error: ${d.error}`)}`,
|
|
1027
|
+
agentsInner,
|
|
1028
|
+
"…",
|
|
1029
|
+
),
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const agentCount = selectedGroup?.agents.length ?? 0;
|
|
1034
|
+
const agentsTitle = selectedGroup
|
|
1035
|
+
? `${selectedGroup.title} · ${agentCount} agent${agentCount === 1 ? "" : "s"}`
|
|
1036
|
+
: "Agents";
|
|
1037
|
+
const leftPanel = this.panel(
|
|
1038
|
+
"Phases",
|
|
1039
|
+
phaseRows,
|
|
1040
|
+
sidebarWidth,
|
|
1041
|
+
panelHeight,
|
|
1042
|
+
);
|
|
1043
|
+
const rightPanel = this.panel(
|
|
1044
|
+
agentsTitle,
|
|
1045
|
+
agentRows,
|
|
1046
|
+
agentsWidth,
|
|
1047
|
+
panelHeight,
|
|
1048
|
+
);
|
|
1049
|
+
for (let i = 0; i < panelHeight; i++) {
|
|
1050
|
+
lines.push(`${leftPanel[i] ?? ""} ${rightPanel[i] ?? ""}`);
|
|
1051
|
+
}
|
|
1052
|
+
for (const entry of recentLogs) {
|
|
1053
|
+
lines.push(
|
|
1054
|
+
truncateToWidth(
|
|
1055
|
+
` ${theme.fg("muted", "›")} ${theme.fg("dim", entry.text)}`,
|
|
1056
|
+
width,
|
|
1057
|
+
"…",
|
|
1058
|
+
),
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const hint =
|
|
1063
|
+
this.detailFocus === "phases"
|
|
1064
|
+
? `j/k select phase · l/${this.keys("tui.editor.cursorRight")}/${this.keys("tui.select.confirm")} agents · ${this.keys("tui.select.cancel")} back · x stop · s save report`
|
|
1065
|
+
: `j/k select agent · h/${this.keys("tui.editor.cursorLeft")}/${this.keys("tui.select.cancel")} phases · l/${this.keys("tui.editor.cursorRight")}/${this.keys("tui.select.confirm")} details · x stop · s save report`;
|
|
1066
|
+
lines.push(this.hintLine(hint, width));
|
|
1067
|
+
return lines;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
private transcriptRows(agent: AgentRecord, width: number): string[] {
|
|
1071
|
+
const theme = this.theme;
|
|
1072
|
+
const rows: string[] = [];
|
|
1073
|
+
if (agent.transcript.length === 0) {
|
|
1074
|
+
return [
|
|
1075
|
+
theme.fg(
|
|
1076
|
+
"dim",
|
|
1077
|
+
" transcript unavailable (this run predates transcript capture)",
|
|
1078
|
+
),
|
|
1079
|
+
];
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
for (const entry of agent.transcript) {
|
|
1083
|
+
const label = transcriptLabel(entry);
|
|
1084
|
+
const color = transcriptColor(entry);
|
|
1085
|
+
rows.push(
|
|
1086
|
+
` ${theme.fg(color, SQUARE)} ${theme.bold(theme.fg(color, label))}`,
|
|
1087
|
+
);
|
|
1088
|
+
const contentWidth = Math.max(8, width - 4);
|
|
1089
|
+
const styled = theme.fg(
|
|
1090
|
+
entry.role === "thinking" ? "dim" : entry.isError ? "error" : "text",
|
|
1091
|
+
sanitizeTerminalText(entry.text),
|
|
1092
|
+
);
|
|
1093
|
+
for (const line of wrapTextWithAnsi(styled, contentWidth)) {
|
|
1094
|
+
rows.push(` ${line}`);
|
|
1095
|
+
}
|
|
1096
|
+
rows.push("");
|
|
1097
|
+
}
|
|
1098
|
+
return rows;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
private renderTranscript(
|
|
1102
|
+
details: WorkflowDetails,
|
|
1103
|
+
agent: AgentRecord,
|
|
1104
|
+
width: number,
|
|
1105
|
+
height: number,
|
|
1106
|
+
): string[] {
|
|
1107
|
+
const theme = this.theme;
|
|
1108
|
+
const lines: string[] = [];
|
|
1109
|
+
const right = theme.fg(
|
|
1110
|
+
"dim",
|
|
1111
|
+
[
|
|
1112
|
+
agent.model,
|
|
1113
|
+
agentContext(agent),
|
|
1114
|
+
agent.acceptance ? `acceptance:${agent.acceptance.status}` : undefined,
|
|
1115
|
+
formatElapsed(agent.startedAt, agent.finishedAt),
|
|
1116
|
+
]
|
|
1117
|
+
.filter(Boolean)
|
|
1118
|
+
.join(" · ") + " ",
|
|
1119
|
+
);
|
|
1120
|
+
lines.push(
|
|
1121
|
+
this.split(
|
|
1122
|
+
` ${stateSquare(agent.state, theme)} ${theme.bold(theme.fg("accent", agent.label))}`,
|
|
1123
|
+
right,
|
|
1124
|
+
width,
|
|
1125
|
+
),
|
|
1126
|
+
);
|
|
1127
|
+
lines.push(
|
|
1128
|
+
this.split(
|
|
1129
|
+
` ${theme.fg("muted", `${details.name ?? details.runId} · ${agent.phase ?? "unphased"}`)}`,
|
|
1130
|
+
theme.fg("dim", `${agent.transcript.length} entries `),
|
|
1131
|
+
width,
|
|
1132
|
+
),
|
|
1133
|
+
);
|
|
1134
|
+
|
|
1135
|
+
const panelHeight = height - 3;
|
|
1136
|
+
const bodyHeight = Math.max(1, panelHeight - 2);
|
|
1137
|
+
const rows = this.transcriptRows(agent, width - 2);
|
|
1138
|
+
this.transcriptRowCount = rows.length;
|
|
1139
|
+
this.transcriptViewportSize = bodyHeight;
|
|
1140
|
+
const maxScroll = Math.max(0, rows.length - bodyHeight);
|
|
1141
|
+
this.transcriptScroll = Math.min(this.transcriptScroll, maxScroll);
|
|
1142
|
+
const visible = rows.slice(
|
|
1143
|
+
this.transcriptScroll,
|
|
1144
|
+
this.transcriptScroll + bodyHeight,
|
|
1145
|
+
);
|
|
1146
|
+
const position =
|
|
1147
|
+
rows.length > bodyHeight
|
|
1148
|
+
? `Transcript · ${this.transcriptScroll + 1}-${Math.min(rows.length, this.transcriptScroll + bodyHeight)}/${rows.length}`
|
|
1149
|
+
: "Transcript";
|
|
1150
|
+
lines.push(...this.panel(position, visible, width, panelHeight));
|
|
1151
|
+
lines.push(
|
|
1152
|
+
this.hintLine(
|
|
1153
|
+
"j/k scroll · ctrl-u/d page · g/G top/bottom · h/left/esc back",
|
|
1154
|
+
width,
|
|
1155
|
+
),
|
|
1156
|
+
);
|
|
1157
|
+
return lines;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function transcriptLabel(entry: TranscriptEntry): string {
|
|
1162
|
+
if (entry.role === "user") return "USER";
|
|
1163
|
+
if (entry.role === "assistant") return "ASSISTANT";
|
|
1164
|
+
if (entry.role === "thinking") return "THINKING";
|
|
1165
|
+
const name = entry.name ? sanitizeLine(entry.name, 160) : "unknown";
|
|
1166
|
+
if (entry.role === "tool") return `TOOL ${name}`;
|
|
1167
|
+
return `RESULT ${name}`;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function transcriptColor(
|
|
1171
|
+
entry: TranscriptEntry,
|
|
1172
|
+
): "accent" | "success" | "dim" | "warning" | "error" | "muted" {
|
|
1173
|
+
if (entry.isError) return "error";
|
|
1174
|
+
if (entry.role === "user") return "accent";
|
|
1175
|
+
if (entry.role === "assistant") return "success";
|
|
1176
|
+
if (entry.role === "thinking") return "dim";
|
|
1177
|
+
if (entry.role === "tool") return "warning";
|
|
1178
|
+
return "muted";
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function statusSquareFor(details: WorkflowDetails, theme: Theme): string {
|
|
1182
|
+
return theme.fg(statusColor(details.status), SQUARE);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function groupSquare(group: PhaseGroup, theme: Theme): string {
|
|
1186
|
+
if (group.agents.length === 0) return theme.fg("dim", SQUARE);
|
|
1187
|
+
if (group.agents.some((a) => a.state === "running"))
|
|
1188
|
+
return theme.fg("warning", SQUARE);
|
|
1189
|
+
if (group.agents.some((a) => a.state === "error"))
|
|
1190
|
+
return theme.fg("error", SQUARE);
|
|
1191
|
+
return theme.fg("success", SQUARE);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/** Open the dashboard as a full-screen overlay. */
|
|
1195
|
+
export async function showWorkflowDashboard(
|
|
1196
|
+
ctx: ExtensionContext,
|
|
1197
|
+
getActive: () => Map<string, WorkflowDetails>,
|
|
1198
|
+
initialRunId?: string,
|
|
1199
|
+
startedSince = 0,
|
|
1200
|
+
onAbort?: (runId: string) => boolean,
|
|
1201
|
+
): Promise<void> {
|
|
1202
|
+
await ctx.ui.custom<void>(
|
|
1203
|
+
(tui, theme, keybindings, done) => {
|
|
1204
|
+
const dashboard: WorkflowDashboard = new WorkflowDashboard(
|
|
1205
|
+
tui,
|
|
1206
|
+
theme,
|
|
1207
|
+
keybindings,
|
|
1208
|
+
getActive,
|
|
1209
|
+
ctx.sessionManager.getSessionId(),
|
|
1210
|
+
sessionWorkflowRunIds(ctx),
|
|
1211
|
+
startedSince,
|
|
1212
|
+
() => {
|
|
1213
|
+
dashboard.dispose();
|
|
1214
|
+
done(undefined);
|
|
1215
|
+
},
|
|
1216
|
+
initialRunId,
|
|
1217
|
+
onAbort,
|
|
1218
|
+
);
|
|
1219
|
+
return dashboard;
|
|
1220
|
+
},
|
|
1221
|
+
{
|
|
1222
|
+
overlay: true,
|
|
1223
|
+
overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
|
|
1224
|
+
},
|
|
1225
|
+
);
|
|
1226
|
+
}
|