@maplezzk/pi-dynamic-workflows 1.0.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/src/display.ts ADDED
@@ -0,0 +1,710 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { createTranslator, loadCatalog } from "pi-extensions-i18n";
3
+ import type { WorkflowMeta } from "./workflow.ts";
4
+
5
+ const i18n = createTranslator(loadCatalog(new URL("../locales/index.json", import.meta.url)));
6
+
7
+ export type WorkflowAgentStatus = "queued" | "running" | "done" | "error" | "skipped";
8
+
9
+ export interface WorkflowAgentSnapshot {
10
+ id: number;
11
+ label: string;
12
+ phase?: string;
13
+ prompt: string;
14
+ status: WorkflowAgentStatus;
15
+ resultPreview?: string;
16
+ error?: string;
17
+ startedAt?: number;
18
+ finishedAt?: number;
19
+ }
20
+
21
+ export interface WorkflowSnapshot {
22
+ name: string;
23
+ description?: string;
24
+ phases: string[];
25
+ currentPhase?: string;
26
+ logs: string[];
27
+ agents: WorkflowAgentSnapshot[];
28
+ agentCount: number;
29
+ runningCount: number;
30
+ doneCount: number;
31
+ errorCount: number;
32
+ durationMs?: number;
33
+ result?: unknown;
34
+ resultFile?: string;
35
+ startedAt?: number;
36
+ }
37
+
38
+ export interface WorkflowDisplay {
39
+ update(snapshot: WorkflowSnapshot): void;
40
+ complete(snapshot: WorkflowSnapshot): void;
41
+ clear(): void;
42
+ }
43
+
44
+ export interface WorkflowDisplayOptions {
45
+ key?: string;
46
+ placement?: "aboveEditor" | "belowEditor";
47
+ maxAgents?: number;
48
+ maxLogs?: number;
49
+ showStatus?: boolean;
50
+ showResultPreviews?: boolean;
51
+ }
52
+
53
+ export function createWorkflowSnapshot(meta: WorkflowMeta): WorkflowSnapshot {
54
+ return {
55
+ name: meta.name,
56
+ description: meta.description,
57
+ phases: meta.phases.map((p) => p.title),
58
+ logs: [],
59
+ agents: [],
60
+ agentCount: 0,
61
+ runningCount: 0,
62
+ doneCount: 0,
63
+ errorCount: 0,
64
+ };
65
+ }
66
+
67
+ export function recomputeWorkflowSnapshot(snapshot: WorkflowSnapshot): WorkflowSnapshot {
68
+ const runningCount = snapshot.agents.filter((agent) => agent.status === "running").length;
69
+ const doneCount = snapshot.agents.filter((agent) => agent.status === "done").length;
70
+ const errorCount = snapshot.agents.filter((agent) => agent.status === "error").length;
71
+ return { ...snapshot, agentCount: snapshot.agents.length, runningCount, doneCount, errorCount };
72
+ }
73
+
74
+ export function createWidgetWorkflowDisplay(
75
+ ctx: Pick<ExtensionContext, "ui" | "hasUI">,
76
+ options: WorkflowDisplayOptions = {},
77
+ ): WorkflowDisplay {
78
+ const key = options.key ?? "workflow";
79
+ const placement = options.placement ?? "belowEditor";
80
+ const showStatus = options.showStatus ?? false;
81
+
82
+ const render = (snapshot: WorkflowSnapshot, completed = false) => {
83
+ if (!ctx.hasUI) return;
84
+ if (showStatus) ctx.ui.setStatus(key, statusLine(snapshot, completed));
85
+ ctx.ui.setWidget(key, renderWorkflowLines(snapshot, options), { placement });
86
+ };
87
+
88
+ return {
89
+ update(snapshot) {
90
+ render(snapshot, false);
91
+ },
92
+ complete(snapshot) {
93
+ render(snapshot, true);
94
+ },
95
+ clear() {
96
+ if (!ctx.hasUI) return;
97
+ if (showStatus) ctx.ui.setStatus(key, undefined);
98
+ ctx.ui.setWidget(key, undefined);
99
+ },
100
+ };
101
+ }
102
+
103
+ export function createToolUpdateWorkflowDisplay(
104
+ onUpdate: ((result: { content: Array<{ type: "text"; text: string }>; details: unknown }) => void) | undefined,
105
+ ctx?: Pick<ExtensionContext, "ui" | "hasUI">,
106
+ options: WorkflowDisplayOptions & { streamToolUpdates?: boolean } = {},
107
+ ): WorkflowDisplay {
108
+ const widget = ctx ? createWidgetWorkflowDisplay(ctx, options) : undefined;
109
+ const streamToolUpdates = options.streamToolUpdates ?? !ctx?.hasUI;
110
+
111
+ const emit = (snapshot: WorkflowSnapshot, completed = false) => {
112
+ if (streamToolUpdates) {
113
+ onUpdate?.({
114
+ content: [{ type: "text", text: renderWorkflowText(snapshot, completed, options) }],
115
+ details: snapshot,
116
+ });
117
+ }
118
+ if (completed) widget?.complete(snapshot);
119
+ else widget?.update(snapshot);
120
+ };
121
+
122
+ return {
123
+ update(snapshot) {
124
+ emit(snapshot, false);
125
+ },
126
+ complete(snapshot) {
127
+ emit(snapshot, true);
128
+ },
129
+ clear() {
130
+ widget?.clear();
131
+ },
132
+ };
133
+ }
134
+
135
+ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: WorkflowDisplayOptions = {}): string[] {
136
+ const maxAgents = options.maxAgents ?? 8;
137
+ const maxLogs = options.maxLogs ?? 2;
138
+ const showResultPreviews = options.showResultPreviews ?? false;
139
+ const state =
140
+ snapshot.errorCount > 0
141
+ ? `, ${snapshot.errorCount} errors`
142
+ : snapshot.runningCount > 0
143
+ ? `, ${snapshot.runningCount} running`
144
+ : "";
145
+ const lines = [`◆ Workflow: ${snapshot.name} (${snapshot.doneCount}/${snapshot.agentCount} done${state})`];
146
+
147
+ const agentPhaseNames = snapshot.agents
148
+ .map((agent) => agent.phase)
149
+ .filter((phase): phase is string => Boolean(phase));
150
+ const phaseNames = unique([
151
+ ...snapshot.phases,
152
+ ...(snapshot.currentPhase ? [snapshot.currentPhase] : []),
153
+ ...agentPhaseNames,
154
+ ]);
155
+ const rendered = new Set<WorkflowAgentSnapshot>();
156
+
157
+ for (const phase of phaseNames) {
158
+ const agents = snapshot.agents.filter((agent) => agent.phase === phase);
159
+ if (agents.length === 0 && snapshot.currentPhase !== phase) continue;
160
+ for (const agent of agents) rendered.add(agent);
161
+ const done = agents.filter((agent) => agent.status === "done").length;
162
+ const running = agents.filter((agent) => agent.status === "running").length;
163
+ const errors = agents.filter((agent) => agent.status === "error").length;
164
+ const skipped = agents.filter((agent) => agent.status === "skipped").length;
165
+ const complete = agents.length > 0 && done + errors + skipped === agents.length;
166
+ const marker = running > 0 || (!complete && snapshot.currentPhase === phase) ? "▶" : complete ? "✓" : " ";
167
+ lines.push(
168
+ ` ${marker} ${phase} ${done}/${agents.length}${running ? ` · ${running} running` : ""}${errors ? ` · ${errors} errors` : ""}${skipped ? ` · ${skipped} skipped` : ""}`,
169
+ );
170
+
171
+ const visibleAgents = agents.slice(-maxAgents);
172
+ for (const agent of visibleAgents) {
173
+ const order = `#${agent.id}`;
174
+ const result = showResultPreviews && agent.resultPreview ? ` — ${agent.resultPreview}` : "";
175
+ const err = agent.status === "error" && agent.error ? ` [${shorten(agent.error, 80)}]` : "";
176
+ lines.push(` ${order} ${statusIcon(agent.status)} ${shorten(agent.label, 48)}${result}${err}`);
177
+ }
178
+ if (agents.length > visibleAgents.length)
179
+ lines.push(` … ${agents.length - visibleAgents.length} earlier agents`);
180
+ }
181
+
182
+ const unphased = snapshot.agents.filter((agent) => !rendered.has(agent));
183
+ if (unphased.length) {
184
+ lines.push(" Unphased");
185
+ for (const agent of unphased.slice(-maxAgents)) {
186
+ const result = showResultPreviews && agent.resultPreview ? ` — ${agent.resultPreview}` : "";
187
+ const err = agent.status === "error" && agent.error ? ` [${shorten(agent.error, 80)}]` : "";
188
+ lines.push(` #${agent.id} ${statusIcon(agent.status)} ${shorten(agent.label, 48)}${result}${err}`);
189
+ }
190
+ }
191
+
192
+ const visibleLogs = snapshot.logs.slice(-maxLogs);
193
+ if (visibleLogs.length) {
194
+ if (lines.length > 1) lines.push("");
195
+ for (const log of visibleLogs) lines.push(` log: ${log}`);
196
+ }
197
+
198
+ // 附加最终结果文件路径
199
+ if (snapshot.resultFile && snapshot.runningCount === 0) {
200
+ if (lines.length > 0) lines.push("");
201
+ lines.push(` ${i18n.t("totalResult", { path: snapshot.resultFile })}`);
202
+ }
203
+
204
+ return lines;
205
+ }
206
+
207
+ export function renderWorkflowText(
208
+ snapshot: WorkflowSnapshot,
209
+ completed = false,
210
+ options: WorkflowDisplayOptions = {},
211
+ ): string {
212
+ const header = completed ? "Workflow completed" : "Workflow running";
213
+ return [header, ...renderWorkflowLines(snapshot, options)].join("\n");
214
+ }
215
+
216
+ // 仿 pi-interactive-subagents 的 formatStatusLine:
217
+ // 每行一个 agent 状态,格式:`{label} {state detail} {elapsed}.`
218
+ export function formatAgentStatusLine(agent: WorkflowAgentSnapshot, now = Date.now()): string {
219
+ const label = shorten(agent.label, 64);
220
+ const elapsed = agent.startedAt ? ((agent.finishedAt ?? now) - agent.startedAt) / 1000 : 0;
221
+ const elapsedText = `${elapsed.toFixed(1)}s`;
222
+ if (agent.status === "running") {
223
+ return `${label} running ${elapsedText}, active.`;
224
+ }
225
+ if (agent.status === "done") {
226
+ return `${label} finished in ${elapsedText}.`;
227
+ }
228
+ if (agent.status === "error") {
229
+ return `${label} failed after ${elapsedText}${agent.error ? ` (${shorten(agent.error, 60)})` : ""}.`;
230
+ }
231
+ if (agent.status === "skipped") {
232
+ return `${label} skipped.`;
233
+ }
234
+ return `${label} queued.`;
235
+ }
236
+
237
+ // 汇总所有 active agent(queued + running),其他只输出当前 phase 的进行中 agent
238
+ export function formatWorkflowStatusAggregate(
239
+ snapshot: WorkflowSnapshot,
240
+ lineLimit = 4,
241
+ now = Date.now(),
242
+ ): { lines: string[]; overflow: number } {
243
+ const running = snapshot.agents.filter((a) => a.status === "running");
244
+ const queued = snapshot.agents.filter((a) => a.status === "queued");
245
+ const recent = [...running, ...queued].slice(0, lineLimit);
246
+ const lines = recent.map((a) => formatAgentStatusLine(a, now));
247
+ const overflow = Math.max(0, running.length + queued.length - recent.length);
248
+ return { lines, overflow };
249
+ }
250
+
251
+ function statusLine(snapshot: WorkflowSnapshot, completed: boolean): string {
252
+ if (completed) return `workflow ✓ ${snapshot.name}: ${snapshot.doneCount}/${snapshot.agentCount}`;
253
+ if (snapshot.runningCount > 0)
254
+ return `workflow ${snapshot.name}: ${snapshot.runningCount} running, ${snapshot.doneCount}/${snapshot.agentCount} done`;
255
+ return `workflow ${snapshot.name}: ${snapshot.doneCount}/${snapshot.agentCount} done`;
256
+ }
257
+
258
+ function statusIcon(status: WorkflowAgentStatus): string {
259
+ switch (status) {
260
+ case "queued":
261
+ return "○";
262
+ case "running":
263
+ return "●";
264
+ case "done":
265
+ return "✓";
266
+ case "error":
267
+ return "✗";
268
+ case "skipped":
269
+ return "-";
270
+ }
271
+ }
272
+
273
+ function unique(values: string[]): string[] {
274
+ return [...new Set(values)];
275
+ }
276
+
277
+ function shorten(value: string, max: number): string {
278
+ const text = value.replace(/\s+/g, " ").trim();
279
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
280
+ }
281
+
282
+ /**
283
+ * CJK 双宽字符检测:返回字符的终端可见宽度(1 或 2)。
284
+ */
285
+ function cjkCharWidth(code: number): number {
286
+ if (
287
+ (code >= 0x1100 && code <= 0x115f) ||
288
+ (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
289
+ (code >= 0xac00 && code <= 0xd7af) ||
290
+ (code >= 0xf900 && code <= 0xfaff) ||
291
+ (code >= 0xfe10 && code <= 0xfe6f) ||
292
+ (code >= 0xff01 && code <= 0xff60) ||
293
+ (code >= 0xffe0 && code <= 0xffe6) ||
294
+ (code >= 0x20000 && code <= 0x2fffd) ||
295
+ (code >= 0x30000 && code <= 0x3fffd)
296
+ )
297
+ return 2;
298
+ return 1;
299
+ }
300
+
301
+ /**
302
+ * 计算字符串的终端可见宽度(考虑 CJK 双宽字符)。
303
+ */
304
+ function visibleStrWidth(str: string): number {
305
+ let w = 0;
306
+ for (const ch of str) {
307
+ w += cjkCharWidth(ch.codePointAt(0) ?? 0);
308
+ }
309
+ return w;
310
+ }
311
+
312
+ /**
313
+ * 按可见宽度截断字符串,超出部分用省略号「…」替代。
314
+ * 用于 TUI widget 行内容截断,防止超出终端宽度导致 pi-tui 崩溃。
315
+ */
316
+ function truncateVisible(str: string, maxWidth: number): string {
317
+ if (maxWidth <= 0) return "";
318
+ const w = visibleStrWidth(str);
319
+ if (w <= maxWidth) return str;
320
+ const target = maxWidth - 1; // 留给「…」
321
+ let result = "";
322
+ let currentWidth = 0;
323
+ for (const ch of str) {
324
+ const cw = cjkCharWidth(ch.codePointAt(0) ?? 0);
325
+ if (currentWidth + cw > target) break;
326
+ result += ch;
327
+ currentWidth += cw;
328
+ }
329
+ return `${result}…`;
330
+ }
331
+
332
+ export function preview(value: unknown, max = 200): string {
333
+ const text = typeof value === "string" ? value : JSON.stringify(value);
334
+ if (!text) return "";
335
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
336
+ }
337
+
338
+ export function formatElapsed(ms: number): string {
339
+ if (!Number.isFinite(ms) || ms < 1000) return "<1s";
340
+ if (ms < 60000) return `${Math.floor(ms / 1000)}s`;
341
+ if (ms < 3600000) {
342
+ const m = Math.floor(ms / 60000);
343
+ const s = Math.floor((ms % 60000) / 1000);
344
+ return `${m}m ${s}s`;
345
+ }
346
+ const h = Math.floor(ms / 3600000);
347
+ const m = Math.floor((ms % 3600000) / 60000);
348
+ return `${h}h ${m}m`;
349
+ }
350
+
351
+ export interface WorkflowTheme {
352
+ fg: (color: string, text: string) => string;
353
+ bold: (text: string) => string;
354
+ }
355
+
356
+ export function renderWorkflowThemed(
357
+ snapshot: WorkflowSnapshot,
358
+ theme: WorkflowTheme,
359
+ options: WorkflowDisplayOptions = {},
360
+ ): string {
361
+ const showResultPreviews = options.showResultPreviews ?? false;
362
+ const maxAgents = options.maxAgents ?? 8;
363
+
364
+ const elapsed = snapshot.durationMs ? formatElapsed(snapshot.durationMs) : "";
365
+ const agentSummary = `${snapshot.agentCount} agents`;
366
+ const durationPart = elapsed ? ` · ${elapsed}` : "";
367
+
368
+ const lines: string[] = [];
369
+
370
+ // Header: ▸ {name} — {agentCount} agents · {duration}
371
+ lines.push(
372
+ `${theme.fg("accent", "▸")} ${theme.fg("toolTitle", theme.bold(snapshot.name))} ${theme.fg("dim", `— ${agentSummary}${durationPart}`)}`,
373
+ );
374
+ lines.push("");
375
+
376
+ // Group agents by phase
377
+ const agentPhaseNames = snapshot.agents
378
+ .map((agent) => agent.phase)
379
+ .filter((phase): phase is string => Boolean(phase));
380
+ const phaseNames = unique([
381
+ ...snapshot.phases,
382
+ ...(snapshot.currentPhase ? [snapshot.currentPhase] : []),
383
+ ...agentPhaseNames,
384
+ ]);
385
+ const rendered = new Set<WorkflowAgentSnapshot>();
386
+
387
+ for (const phase of phaseNames) {
388
+ const agents = snapshot.agents.filter((agent) => agent.phase === phase);
389
+ if (agents.length === 0 && snapshot.currentPhase !== phase) continue;
390
+ for (const agent of agents) rendered.add(agent);
391
+
392
+ const done = agents.filter((a) => a.status === "done").length;
393
+ const running = agents.filter((a) => a.status === "running").length;
394
+ const errors = agents.filter((a) => a.status === "error").length;
395
+ const skipped = agents.filter((a) => a.status === "skipped").length;
396
+ const complete = agents.length > 0 && done + errors + skipped === agents.length;
397
+
398
+ // Phase icon
399
+ let phaseIcon: string;
400
+ if (complete) {
401
+ phaseIcon = theme.fg("success", "✓");
402
+ } else if (running > 0 || snapshot.currentPhase === phase) {
403
+ phaseIcon = theme.fg("accent", "▶");
404
+ } else {
405
+ phaseIcon = theme.fg("dim", "○");
406
+ }
407
+
408
+ // Phase duration: first agent startedAt → last agent finishedAt
409
+ const phaseElapsed = computePhaseDuration(agents);
410
+ const phaseDurationText = phaseElapsed ? theme.fg("dim", formatElapsed(phaseElapsed)) : "";
411
+
412
+ lines.push(` ${phaseIcon} ${phase}${phaseDurationText ? ` ${phaseDurationText}` : ""}`);
413
+
414
+ // Agents in this phase
415
+ const visibleAgents = agents.slice(-maxAgents);
416
+ for (const agent of visibleAgents) {
417
+ const order = `#${agent.id}`;
418
+ const icon = themedStatusIcon(agent.status, theme);
419
+ const label = theme.fg("toolOutput", shorten(agent.label, 48));
420
+ const agentElapsed = computeAgentDuration(agent);
421
+ const agentDurationText = agentElapsed ? ` ${theme.fg("dim", formatElapsed(agentElapsed))}` : "";
422
+ const err =
423
+ agent.status === "error" && agent.error ? ` ${theme.fg("error", `[${shorten(agent.error, 60)}]`)}` : "";
424
+ lines.push(` ${order} ${icon} ${label}${agentDurationText}${err}`);
425
+
426
+ // Result preview (file path)
427
+ if (showResultPreviews && agent.resultPreview) {
428
+ lines.push(` ${theme.fg("muted", agent.resultPreview)}`);
429
+ }
430
+ }
431
+ if (agents.length > visibleAgents.length) {
432
+ lines.push(` ${theme.fg("dim", `… ${agents.length - visibleAgents.length} earlier agents`)}`);
433
+ }
434
+ }
435
+
436
+ // Unphased agents
437
+ const unphased = snapshot.agents.filter((agent) => !rendered.has(agent));
438
+ if (unphased.length) {
439
+ lines.push(` ${theme.fg("dim", "Unphased")}`);
440
+ for (const agent of unphased.slice(-maxAgents)) {
441
+ const icon = themedStatusIcon(agent.status, theme);
442
+ const label = theme.fg("toolOutput", shorten(agent.label, 48));
443
+ const agentElapsed = computeAgentDuration(agent);
444
+ const agentDurationText = agentElapsed ? ` ${theme.fg("dim", formatElapsed(agentElapsed))}` : "";
445
+ const err =
446
+ agent.status === "error" && agent.error ? ` ${theme.fg("error", `[${shorten(agent.error, 60)}]`)}` : "";
447
+ lines.push(` #${agent.id} ${icon} ${label}${agentDurationText}${err}`);
448
+ if (showResultPreviews && agent.resultPreview) {
449
+ lines.push(` ${theme.fg("muted", agent.resultPreview)}`);
450
+ }
451
+ }
452
+ }
453
+
454
+ // Result file
455
+ if (snapshot.resultFile) {
456
+ lines.push("");
457
+ lines.push(` ${theme.fg("muted", `📄 ${snapshot.resultFile}`)}`);
458
+ }
459
+
460
+ return lines.join("\n");
461
+ }
462
+
463
+ function themedStatusIcon(status: WorkflowAgentStatus, theme: WorkflowTheme): string {
464
+ switch (status) {
465
+ case "done":
466
+ return theme.fg("success", "✓");
467
+ case "error":
468
+ return theme.fg("error", "✗");
469
+ case "running":
470
+ return theme.fg("accent", "●");
471
+ case "queued":
472
+ return theme.fg("dim", "○");
473
+ case "skipped":
474
+ return theme.fg("dim", "-");
475
+ }
476
+ }
477
+
478
+ /**
479
+ * 渲染带边框的 Widget 状态栏行(用于 aboveEditor widget)。
480
+ * 使用 box-drawing 字符和硬编码 ANSI 色彩。
481
+ */
482
+ export function renderWorkflowWidgetLines(snapshot: WorkflowSnapshot, width: number): string[] {
483
+ const ACCENT = "\x1b[38;2;77;163;255m";
484
+ const RST = "\x1b[0m";
485
+ const GREEN = "\x1b[32m";
486
+ const CYAN = "\x1b[36m";
487
+ const RED = "\x1b[31m";
488
+ const DIM = "\x1b[90m";
489
+
490
+ const MAX_VISIBLE_AGENTS = 6;
491
+ // boxWidth 必须严格不超过终端实际宽度,否则 pi-tui 会因行超出终端宽度而崩溃。
492
+ // 原先 Math.max(50, width) 在窄终端(如 43 列分屏)下强制设为 50,导致渲染行
493
+ // visible width=50 > 终端宽度 43,pi-tui 抛出 uncaughtException 退出。
494
+ // 修复:去掉 50 上限,让 widget 跟随终端实际宽度自适应填满。
495
+ // 最小 20 是为了保证极窄终端下 innerWidth=18 有足够空间显示基本内容。
496
+ const boxWidth = Math.max(20, width);
497
+ const innerWidth = boxWidth - 2; // exclude │ on each side
498
+
499
+ // 可见宽度辅助函数(考虑 CJK 双宽字符),已抽离为模块级 visibleStrWidth
500
+ const strWidth = visibleStrWidth;
501
+
502
+ // Helper: pad content line to fit inside box.
503
+ // 不变式:调用方保证 rawLen <= innerWidth,padLine 只补右侧空白到 innerWidth。
504
+ const padLine = (content: string, rawLen: number): string => {
505
+ const padding = Math.max(0, innerWidth - rawLen);
506
+ return `${ACCENT}│${RST}${content}${" ".repeat(padding)}${ACCENT}│${RST}`;
507
+ };
508
+
509
+ // Title line: ╭─ Workflow: {name} ──── {done}/{total} done ─╮
510
+ // 标题结构:╭ + ─ + titleText + ─*fill + statsText + ─ + ╮
511
+ // 总可见宽度 = 1(╭) + 1(─) + titleTextW + fillLen + statsTextW + 1(─) + 1(╮)
512
+ // = 4 + titleTextW + fillLen + statsTextW
513
+ // fillLen 至少 1。极窄终端下逐步压缩 titlePrefix/statsText。
514
+ let titlePrefix = ` Workflow: `;
515
+ let statsText = ` ${snapshot.doneCount}/${snapshot.agentCount} done `;
516
+ // 判断在最小 fill(1) 下能否装下:4 + prefixW + 1(name至少1) + 1(fill) + statsW + 1(尾部空格) <= boxWidth
517
+ const tryFit = (prefix: string, stats: string): boolean =>
518
+ 4 + strWidth(prefix) + 1 + 1 + strWidth(stats) + 1 <= boxWidth;
519
+ if (!tryFit(titlePrefix, statsText)) {
520
+ statsText = ` ${snapshot.doneCount}/${snapshot.agentCount} `;
521
+ }
522
+ if (!tryFit(titlePrefix, statsText)) {
523
+ titlePrefix = ` `;
524
+ statsText = `${snapshot.doneCount}/${snapshot.agentCount}`;
525
+ }
526
+ if (!tryFit(titlePrefix, statsText)) {
527
+ // 极窄:只显示 name + 边框
528
+ titlePrefix = ``;
529
+ statsText = ``;
530
+ }
531
+ // 固定占用 4(╭ ─ ─ ╮,不含 titleText/statsText/fill)
532
+ const fixedOverhead = 4;
533
+ // titleText = titlePrefix + name + " "(尾部空格)
534
+ const maxNameWidth = Math.max(1, boxWidth - fixedOverhead - strWidth(titlePrefix) - strWidth(statsText) - 2); // -1 尾空格 -1 fill
535
+ const truncatedName = truncateVisible(snapshot.name, maxNameWidth);
536
+ const titleText = `${titlePrefix}${truncatedName} `;
537
+ const fillLen = Math.max(1, boxWidth - fixedOverhead - strWidth(titleText) - strWidth(statsText));
538
+ const topLine = `${ACCENT}╭─${titleText}${"─".repeat(fillLen)}${statsText}─╮${RST}`;
539
+
540
+ // Bottom line: ╰ + ─*(boxWidth-2) + ╯
541
+ const bottomLine = `${ACCENT}╰${"─".repeat(boxWidth - 2)}╯${RST}`;
542
+
543
+ const lines: string[] = [topLine];
544
+
545
+ // Group agents by phase
546
+ const agentPhaseNames = snapshot.agents.map((a) => a.phase).filter((p): p is string => Boolean(p));
547
+ const phaseNames = unique([
548
+ ...snapshot.phases,
549
+ ...(snapshot.currentPhase ? [snapshot.currentPhase] : []),
550
+ ...agentPhaseNames,
551
+ ]);
552
+ const rendered = new Set<WorkflowAgentSnapshot>();
553
+
554
+ /** 渲染单个 agent 行。返回 padLine 后的字符串。 */
555
+ const renderAgentLine = (agent: WorkflowAgentSnapshot): string => {
556
+ const order = `#${agent.id}`;
557
+ const { icon } = widgetStatusIcon(agent.status);
558
+ const agentElapsed = computeAgentDuration(agent);
559
+ const elapsedText = agentElapsed ? formatElapsed(agentElapsed) : "";
560
+
561
+ const statusLabel =
562
+ agent.status === "done"
563
+ ? "done"
564
+ : agent.status === "running"
565
+ ? "running"
566
+ : agent.status === "error"
567
+ ? "error"
568
+ : "";
569
+ let rightText = elapsedText ? `${statusLabel} ${elapsedText}` : statusLabel;
570
+ let rightRawLen = rightText.length;
571
+ const statusColor =
572
+ agent.status === "done" ? GREEN : agent.status === "running" ? CYAN : agent.status === "error" ? RED : DIM;
573
+ let rightPart = rightText ? `${statusColor}${rightText}${RST}` : "";
574
+
575
+ // 布局:" " + order + " " + icon + " " + label + gap + rightText
576
+ // 左侧固定部分宽度(不含 label):4(空格) + orderW + 1(空格) + 1(icon) + 1(空格)
577
+ const leftFixedWidth = 4 + order.length + 1 + 1 + 1;
578
+ // 如果有 rightText,需留至少 1 个 gap + rightRawLen
579
+ let minRightWidth = rightRawLen > 0 ? 1 + rightRawLen : 0;
580
+ // 极窄终端下 leftFixedWidth + minRightWidth 可能已超过 innerWidth。
581
+ // 此时逐步压缩:先去掉耗时,只留状态;再去掉状态,只留 label;最后连 label 也截断。
582
+ if (leftFixedWidth + minRightWidth > innerWidth) {
583
+ // 阶段 1:只留状态标签(去掉耗时)
584
+ rightText = statusLabel;
585
+ rightRawLen = rightText.length;
586
+ minRightWidth = rightRawLen > 0 ? 1 + rightRawLen : 0;
587
+ }
588
+ if (leftFixedWidth + minRightWidth > innerWidth) {
589
+ // 阶段 2:去掉右侧状态,只留左侧 label
590
+ rightText = "";
591
+ rightRawLen = 0;
592
+ rightPart = "";
593
+ minRightWidth = 0;
594
+ }
595
+ const labelMaxWidth = Math.max(1, innerWidth - leftFixedWidth - minRightWidth);
596
+ const label = truncateVisible(agent.label.replace(/\s+/g, " ").trim(), labelMaxWidth);
597
+
598
+ const leftPart = ` ${order} ${icon} ${label}`;
599
+ const leftRawLen = leftFixedWidth + strWidth(label);
600
+
601
+ // gap 填充剩余空间;如果没有 rightText,gap 可以是 0(label 占满)
602
+ const gapLen = Math.max(rightRawLen > 0 ? 1 : 0, innerWidth - leftRawLen - rightRawLen);
603
+ const agentLine = `${leftPart}${" ".repeat(gapLen)}${rightPart}`;
604
+ const agentRawLen = leftRawLen + gapLen + rightRawLen;
605
+ return padLine(agentLine, agentRawLen);
606
+ };
607
+
608
+ for (const phase of phaseNames) {
609
+ const agents = snapshot.agents.filter((a) => a.phase === phase);
610
+ for (const a of agents) rendered.add(a);
611
+
612
+ const done = agents.filter((a) => a.status === "done").length;
613
+ const running = agents.filter((a) => a.status === "running").length;
614
+ const errors = agents.filter((a) => a.status === "error").length;
615
+ const skipped = agents.filter((a) => a.status === "skipped").length;
616
+ const complete = agents.length > 0 && done + errors + skipped === agents.length;
617
+
618
+ // Phase icon
619
+ let phaseIcon: string;
620
+ if (complete) {
621
+ phaseIcon = `${GREEN}✓${RST}`;
622
+ } else if (running > 0 || snapshot.currentPhase === phase) {
623
+ phaseIcon = `${CYAN}▶${RST}`;
624
+ } else {
625
+ phaseIcon = `${DIM}○${RST}`;
626
+ }
627
+
628
+ // Phase 行:按可见宽度截断 phase 名称,防止超出 innerWidth
629
+ // 布局:" " + icon(1) + " " + phaseName,总共占 2+1+1+nameW = 4+nameW
630
+ const phaseNameMaxWidth = Math.max(1, innerWidth - 4);
631
+ const truncatedPhase = truncateVisible(phase, phaseNameMaxWidth);
632
+ const phaseContent = ` ${phaseIcon} ${truncatedPhase}`;
633
+ const phaseRawLen = 2 + 1 + 1 + strWidth(truncatedPhase); // " " + icon + " " + name
634
+ lines.push(padLine(phaseContent, phaseRawLen));
635
+
636
+ // Agents in this phase
637
+ const visibleAgents = agents.slice(-MAX_VISIBLE_AGENTS);
638
+ for (const agent of visibleAgents) {
639
+ lines.push(renderAgentLine(agent));
640
+ }
641
+ if (agents.length > visibleAgents.length) {
642
+ const moreText = ` … ${agents.length - visibleAgents.length} earlier`;
643
+ const moreRawLen = 2 + moreText.length;
644
+ // 极窄终端下 moreText 可能超过 innerWidth,截断保护
645
+ if (moreRawLen > innerWidth) {
646
+ const truncated = truncateVisible(` ${moreText}`, innerWidth);
647
+ lines.push(padLine(`${DIM}${truncated}${RST}`, strWidth(truncated)));
648
+ } else {
649
+ lines.push(padLine(` ${DIM}${moreText}${RST}`, moreRawLen));
650
+ }
651
+ }
652
+ }
653
+
654
+ // Unphased agents
655
+ const unphased = snapshot.agents.filter((a) => !rendered.has(a));
656
+ if (unphased.length) {
657
+ const visibleAgents = unphased.slice(-MAX_VISIBLE_AGENTS);
658
+ for (const agent of visibleAgents) {
659
+ lines.push(renderAgentLine(agent));
660
+ }
661
+ if (unphased.length > visibleAgents.length) {
662
+ const moreText = ` … ${unphased.length - visibleAgents.length} earlier`;
663
+ const moreRawLen = 2 + moreText.length;
664
+ if (moreRawLen > innerWidth) {
665
+ const truncated = truncateVisible(` ${moreText}`, innerWidth);
666
+ lines.push(padLine(`${DIM}${truncated}${RST}`, strWidth(truncated)));
667
+ } else {
668
+ lines.push(padLine(` ${DIM}${moreText}${RST}`, moreRawLen));
669
+ }
670
+ }
671
+ }
672
+
673
+ lines.push(bottomLine);
674
+ return lines;
675
+ }
676
+
677
+ function widgetStatusIcon(status: WorkflowAgentStatus): { icon: string; iconRaw: string } {
678
+ const GREEN = "\x1b[32m";
679
+ const CYAN = "\x1b[36m";
680
+ const RED = "\x1b[31m";
681
+ const DIM = "\x1b[90m";
682
+ const RST = "\x1b[0m";
683
+ switch (status) {
684
+ case "done":
685
+ return { icon: `${GREEN}✓${RST}`, iconRaw: "✓" };
686
+ case "running":
687
+ return { icon: `${CYAN}●${RST}`, iconRaw: "●" };
688
+ case "error":
689
+ return { icon: `${RED}✗${RST}`, iconRaw: "✗" };
690
+ case "queued":
691
+ return { icon: `${DIM}○${RST}`, iconRaw: "○" };
692
+ case "skipped":
693
+ return { icon: `${DIM}-${RST}`, iconRaw: "-" };
694
+ }
695
+ }
696
+
697
+ function computePhaseDuration(agents: WorkflowAgentSnapshot[]): number | undefined {
698
+ const starts = agents.map((a) => a.startedAt).filter((t): t is number => t != null);
699
+ const ends = agents.map((a) => a.finishedAt).filter((t): t is number => t != null);
700
+ if (starts.length === 0 || ends.length === 0) return undefined;
701
+ const duration = Math.max(...ends) - Math.min(...starts);
702
+ return duration > 0 ? duration : undefined;
703
+ }
704
+
705
+ function computeAgentDuration(agent: WorkflowAgentSnapshot): number | undefined {
706
+ if (!agent.startedAt) return undefined;
707
+ const end = agent.finishedAt ?? Date.now();
708
+ const duration = end - agent.startedAt;
709
+ return duration > 0 ? duration : undefined;
710
+ }