@ghyper9023/pi-dev-workflow 0.3.3 → 0.4.1

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.
@@ -0,0 +1,1029 @@
1
+ /**
2
+ * ui-helpers.ts — Rich TUI component builders for select/confirm/input/notify
3
+ *
4
+ * Wraps ctx.ui.custom() with proper text wrapping, black-background panels,
5
+ * and Ctrl+O expand/collapse support.
6
+ *
7
+ * Provides:
8
+ * - uiSelect() — replaces ctx.ui.select() with wrapping
9
+ * - uiConfirm() — replaces ctx.ui.confirm() with wrapping
10
+ * - uiInput() — replaces ctx.ui.input() with wrapping
11
+ * - updateWorkflowWidget — persistent progress panel (widget)
12
+ * - sendWorkflowResult() — persistent session completion message
13
+ */
14
+
15
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
16
+ import {
17
+ Box,
18
+ Container,
19
+ SelectList,
20
+ Text,
21
+ Spacer,
22
+ Input,
23
+ type Component,
24
+ type SelectItem,
25
+ visibleWidth,
26
+ wrapTextWithAnsi,
27
+ truncateToWidth,
28
+ } from "@earendil-works/pi-tui";
29
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
30
+
31
+ // ── Types ────────────────────────────────────────────────────
32
+
33
+ type Theme = ExtensionCommandContext["ui"]["theme"];
34
+ type TUI = Parameters<Parameters<ExtensionCommandContext["ui"]["custom"]>[0]>[0];
35
+
36
+ const WIDGET_KEY = "dev-workflow";
37
+
38
+ // ── Constants ─────────────────────────────────────────────────
39
+
40
+ /** Marker returned by uiInput/uiSelect when user triggers "back". */
41
+ export const BACK_MARKER = "__BACK__";
42
+
43
+ /** The display text for the back option. */
44
+ export const BACK_OPTION_TEXT = "← 返回上一步";
45
+
46
+ // ── Helpers ──────────────────────────────────────────────────
47
+
48
+ /** Draw a bordered box around content lines. */
49
+ function boxify(lines: string[], theme: Theme, width: number): string[] {
50
+ if (width < 4) return lines;
51
+ const innerW = width - 2;
52
+ const result: string[] = [];
53
+ const top = `╭${"─".repeat(innerW)}╮`;
54
+ const bot = `╰${"─".repeat(innerW)}╯`;
55
+ result.push(theme.fg("accent", top));
56
+ for (const line of lines) {
57
+ const wrapped = wrapTextWithAnsi(line, innerW);
58
+ for (const w of wrapped) {
59
+ const t = truncateToWidth(w, innerW, "");
60
+ const pad = " ".repeat(Math.max(0, innerW - visibleWidth(t)));
61
+ result.push(theme.fg("accent", `│${t}${pad}│`));
62
+ }
63
+ }
64
+ result.push(theme.fg("accent", bot));
65
+ return result;
66
+ }
67
+
68
+ /** Theme-aware bold. */
69
+ function bold(theme: Theme, text: string): string {
70
+ return (theme as { bold?: (s: string) => string }).bold?.(text) ?? text;
71
+ }
72
+
73
+ /** Theme-aware dim. */
74
+ function dim(theme: Theme, text: string): string {
75
+ return theme.fg("dim", text);
76
+ }
77
+
78
+ // ── Select (replaces ctx.ui.select) ──────────────────────────
79
+
80
+ /**
81
+ * Show a select list with proper text wrapping.
82
+ * Returns the selected item value, or undefined on cancel (Esc).
83
+ * When backable=true, prepends "← 返回上一步" as the first item;
84
+ * caller should check for it via choice === BACK_OPTION_TEXT.
85
+ */
86
+ export function uiSelect(
87
+ ctx: ExtensionCommandContext,
88
+ title: string,
89
+ items: string[],
90
+ backable = false,
91
+ ): Promise<string | undefined> {
92
+ const selectItems: SelectItem[] = [];
93
+ if (backable) {
94
+ selectItems.push({ value: BACK_OPTION_TEXT, label: BACK_OPTION_TEXT });
95
+ }
96
+ for (const item of items) {
97
+ selectItems.push({ value: item, label: item });
98
+ }
99
+
100
+ return ctx.ui.custom<string | undefined>((tui, theme, _kb, done) => {
101
+ const container = new Container();
102
+
103
+ const titleWrapped = wrapTextWithAnsi(title, Math.max(20, process.stdout.columns - 6));
104
+ container.addChild(new Spacer(1));
105
+ container.addChild(new Text(theme.fg("accent", bold(theme, ` ${titleWrapped[0] ?? title}`)), 0, 0));
106
+ for (const line of titleWrapped.slice(1)) {
107
+ container.addChild(new Text(theme.fg("accent", ` ${line}`), 0, 0));
108
+ }
109
+ container.addChild(new Spacer(1));
110
+
111
+ const visibleCount = Math.min(selectItems.length + 1, 12);
112
+ const selectList = new SelectList(selectItems, visibleCount, {
113
+ selectedPrefix: (s) => theme.fg("accent", s),
114
+ selectedText: (s) => theme.fg("accent", s),
115
+ description: (s) => theme.fg("muted", s),
116
+ scrollInfo: (s) => theme.fg("dim", s),
117
+ noMatch: (s) => theme.fg("warning", s),
118
+ });
119
+ selectList.onSelect = (item) => done(item.value);
120
+ selectList.onCancel = () => done(undefined);
121
+ container.addChild(selectList);
122
+
123
+ container.addChild(new Spacer(1));
124
+ container.addChild(new Text(theme.fg("dim", " ↑↓ 导航 • Enter 选择 • Esc 取消"), 0, 0));
125
+
126
+ return {
127
+ render: (w) => container.render(w),
128
+ invalidate: () => container.invalidate(),
129
+ handleInput: (data) => {
130
+ selectList.handleInput(data);
131
+ tui.requestRender();
132
+ },
133
+ };
134
+ });
135
+ }
136
+
137
+ // ── Confirm (replaces ctx.ui.confirm) ────────────────────────
138
+
139
+ /**
140
+ * Show a confirm dialog with proper wrapping.
141
+ * Returns true for Yes, false for No, "back" for back, undefined on cancel.
142
+ */
143
+ export function uiConfirm(
144
+ ctx: ExtensionCommandContext,
145
+ title: string,
146
+ message?: string,
147
+ backable = false,
148
+ ): Promise<boolean | "back" | undefined> {
149
+ const items: SelectItem[] = [
150
+ { value: "yes", label: "✅ 是" },
151
+ { value: "no", label: "❌ 否" },
152
+ ];
153
+ if (backable) {
154
+ items.push({ value: "back", label: BACK_OPTION_TEXT });
155
+ }
156
+
157
+ return ctx.ui.custom<boolean | "back" | undefined>((tui, theme, _kb, done) => {
158
+ const container = new Container();
159
+
160
+ container.addChild(new Spacer(1));
161
+ const titleWrapped = wrapTextWithAnsi(title, Math.max(20, process.stdout.columns - 6));
162
+ container.addChild(new Text(theme.fg("accent", bold(theme, ` ${titleWrapped[0] ?? title}`)), 0, 0));
163
+ for (const line of titleWrapped.slice(1)) {
164
+ container.addChild(new Text(theme.fg("accent", ` ${line}`), 0, 0));
165
+ }
166
+
167
+ if (message) {
168
+ container.addChild(new Spacer(1));
169
+ const msgWrapped = wrapTextWithAnsi(message, Math.max(20, process.stdout.columns - 6));
170
+ for (const line of msgWrapped) {
171
+ container.addChild(new Text(theme.fg("text", ` ${line}`), 0, 0));
172
+ }
173
+ }
174
+
175
+ container.addChild(new Spacer(1));
176
+ const visibleCount = backable ? 3 : 2;
177
+ const selectList = new SelectList(items, visibleCount, {
178
+ selectedPrefix: (s) => theme.fg("accent", s),
179
+ selectedText: (s) => theme.fg("accent", s),
180
+ });
181
+ selectList.onSelect = (item) => {
182
+ if (item.value === "back") done("back" as const);
183
+ else done(item.value === "yes");
184
+ };
185
+ selectList.onCancel = () => done(undefined);
186
+ container.addChild(selectList);
187
+
188
+ container.addChild(new Spacer(1));
189
+ const hint = backable
190
+ ? " ↑↓ 导航 • Enter 选择 • Esc 取消"
191
+ : " ↑↓ 导航 • Enter 选择 • Esc 取消";
192
+ container.addChild(new Text(theme.fg("dim", hint), 0, 0));
193
+
194
+ return {
195
+ render: (w) => container.render(w),
196
+ invalidate: () => container.invalidate(),
197
+ handleInput: (data) => {
198
+ selectList.handleInput(data);
199
+ tui.requestRender();
200
+ },
201
+ };
202
+ });
203
+ }
204
+
205
+ // ── Input (replaces ctx.ui.input) ────────────────────────────
206
+
207
+ /**
208
+ * Show an input dialog with proper wrapping.
209
+ * Returns the entered string, or BACK_MARKER on back, or undefined on cancel.
210
+ * When backable=true, supports Ctrl+Shift+← for back and Ctrl+Shift+→ for submit+next.
211
+ */
212
+ export function uiInput(
213
+ ctx: ExtensionCommandContext,
214
+ label: string,
215
+ placeholder?: string,
216
+ required = false,
217
+ backable = false,
218
+ initialValue = "",
219
+ ): Promise<string | undefined> {
220
+ return ctx.ui.custom<string | undefined>((tui, theme, _kb, done) => {
221
+ const container = new Container();
222
+ const width = Math.max(20, process.stdout.columns - 6);
223
+
224
+ container.addChild(new Spacer(1));
225
+ const labelWrapped = wrapTextWithAnsi(label, width);
226
+ container.addChild(new Text(theme.fg("accent", bold(theme, ` ${labelWrapped[0] ?? label}`)), 0, 0));
227
+ for (const line of labelWrapped.slice(1)) {
228
+ container.addChild(new Text(theme.fg("accent", ` ${line}`), 0, 0));
229
+ }
230
+ container.addChild(new Spacer(1));
231
+
232
+ const input = new Input(placeholder ?? "", width - 2);
233
+ if (initialValue) {
234
+ input.setValue(initialValue);
235
+ }
236
+ input.onSubmit = (val) => {
237
+ if (required && !val.trim()) return;
238
+ done(val || "");
239
+ };
240
+ input.onEscape = () => done(undefined);
241
+
242
+ container.addChild(input);
243
+ container.addChild(new Spacer(1));
244
+
245
+ if (backable) {
246
+ container.addChild(new Text(
247
+ theme.fg("dim", " Enter 确认 • Ctrl+Shift+← 上一步 • Ctrl+Shift+→ 跳过 • Esc 取消"),
248
+ 0, 0,
249
+ ));
250
+ } else {
251
+ container.addChild(new Text(theme.fg("dim", " Enter 确认 • Esc 取消"), 0, 0));
252
+ }
253
+
254
+ return {
255
+ render: (w) => container.render(w),
256
+ invalidate: () => container.invalidate(),
257
+ handleInput: (data) => {
258
+ // Intercept back/next keys before passing to Input
259
+ if (backable) {
260
+ // Ctrl+Shift+← → go back to previous question
261
+ if (matchesKey(data, Key.ctrlShift("left"))) {
262
+ done(BACK_MARKER);
263
+ return;
264
+ }
265
+ // Ctrl+Shift+→ → submit current value and go next
266
+ if (matchesKey(data, Key.ctrlShift("right"))) {
267
+ done(input.getValue() || "");
268
+ return;
269
+ }
270
+ }
271
+ input.handleInput(data);
272
+ tui.requestRender();
273
+ },
274
+ };
275
+ });
276
+ }
277
+
278
+ // ═══════════════════════════════════════════════════════════════
279
+ // Workflow Progress Widget
280
+ // ═══════════════════════════════════════════════════════════════
281
+
282
+ /**
283
+ * A single sub-step within a workflow step (e.g. planner, worker, reviewer).
284
+ */
285
+ export interface WorkflowSubStepWidgetState {
286
+ agent: string;
287
+ status: "pending" | "running" | "done" | "failed";
288
+ /** Recent tool activity (e.g. "edit:src/main.rs", "read:config.json") */
289
+ tools?: string[];
290
+ /** Output file paths */
291
+ outputs?: string[];
292
+ /** Free-form detail text (e.g. "3 files changed") */
293
+ detail?: string;
294
+ /** Elapsed time for this sub-step */
295
+ durationMs?: number;
296
+ /** Token usage */
297
+ tokenCount?: number;
298
+ /** Tool usage count */
299
+ toolCount?: number;
300
+ /** When this sub-step started (for live timing) */
301
+ startedAt?: number;
302
+ }
303
+
304
+ /**
305
+ * Workflow step state for the widget.
306
+ */
307
+ export interface WorkflowStepWidgetState {
308
+ label: string;
309
+ status: "pending" | "running" | "done" | "failed" | "skipped";
310
+ /** Timeout in ms for this step */
311
+ timeoutMs?: number;
312
+ durationMs?: number;
313
+ /** When this step started executing (for live timing) */
314
+ startedAt?: number;
315
+ loopCount?: number;
316
+ maxLoops?: number;
317
+ error?: string;
318
+ /** Sub-steps within this step */
319
+ subSteps?: WorkflowSubStepWidgetState[];
320
+ }
321
+
322
+ /**
323
+ * Workflow widget state shared between the workflow engine and the widget.
324
+ */
325
+ export interface WorkflowWidgetState {
326
+ mode: string;
327
+ steps: WorkflowStepWidgetState[];
328
+ currentStepIndex: number;
329
+ startedAt: number;
330
+ status: "running" | "done" | "failed" | "cancelled";
331
+ toolCount?: number;
332
+ tokenCount?: number;
333
+ updatedAt: string;
334
+ /** Human-readable task summary shown in widget header, e.g. "[feat - 在 auth 中实现登录]" */
335
+ taskSummary?: string;
336
+ }
337
+
338
+ // ── Widget component builder ─────────────────────────────────
339
+
340
+ let _widgetState: WorkflowWidgetState | null = null;
341
+ let _widgetAnimationTimer: ReturnType<typeof setInterval> | null = null;
342
+ let _lastWidgetCtx: ExtensionCommandContext | null = null;
343
+
344
+ /** Tracks pi's tools panel expanded state (Ctrl+O toggles it, widget mirrors it) */
345
+ let _widgetExpanded = false;
346
+
347
+ /** Callback invoked when user presses Esc to cancel */
348
+ let _onCancelWorkflow: (() => void) | null = null;
349
+
350
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
351
+ const ANIMATION_MS = 80;
352
+
353
+ function spinnerFrame(): string {
354
+ return SPINNER[Math.floor(Date.now() / ANIMATION_MS) % SPINNER.length]!;
355
+ }
356
+
357
+ function formatDurationFull(ms: number): string {
358
+ if (ms < 1000) return `${ms}ms`;
359
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
360
+ const m = Math.floor(ms / 60000);
361
+ const s = Math.floor((ms % 60000) / 1000);
362
+ return `${m}m${s}s`;
363
+ }
364
+
365
+ function formatTimeout(ms: number): string {
366
+ const m = Math.floor(ms / 60000);
367
+ const s = Math.floor((ms % 60000) / 1000);
368
+ return s > 0 ? `${m}m${s}s` : `${m}m`;
369
+ }
370
+
371
+ /**
372
+ * Build the widget component lines for the given state.
373
+ * New UI design — black background, |__ tree connectors, gold footer.
374
+ *
375
+ * Running state example:
376
+ * ⠋ 工作流 · 值守模式 · 6m16s
377
+ * ✓ 📋生成实施计划 (1m59s/超时时间15m)
378
+ * |__ planner ·
379
+ * |__ output:.pi-dev-output/pi-plans/20260520-1628-export-kcp-public2-api.md
380
+ * ▶ ⠋ 🔧实施代码 → 审查 · 第 1 次循环 (1s/超时时间15m)
381
+ * |__ worker ·
382
+ * | edit:代码xxx.rs
383
+ * | edit:代码xxx.rs
384
+ * |__ new:代码xxx.ts
385
+ * |__ reviewer ·
386
+ * | output:.pi-dev-output/pi-review/md/review-20260520-180001.md
387
+ * |__ output:.pi-dev-output/pi-review/md/review-20260520-180002.md
388
+ * ◦ ✂️ 精简代码 → 审查 · 第 0 次循环
389
+ * |__ trimmer ·
390
+ * |__ 正在排队
391
+ * |__ reviewer ·
392
+ * |__ 正在排队
393
+ * ◦ 📝 更新文档
394
+ * |__ docWriter ·
395
+ * |__ 正在排队
396
+ * Ctrl+O 展开详情(金色) | Escape 取消(金色)
397
+ */
398
+ function buildWidgetLines(state: WorkflowWidgetState, theme: Theme, expanded: boolean, width: number): string[] {
399
+ const lines: string[] = [];
400
+ const elapsed = Date.now() - state.startedAt;
401
+
402
+ // ── Task summary (shown above the main header) ──
403
+ if (state.taskSummary) {
404
+ lines.push(` ${dim(theme, state.taskSummary)}`);
405
+ }
406
+
407
+ // ── Header ──
408
+ const modeLabel =
409
+ state.mode === "full-auto" ? "全自动模式" : state.mode === "full-attended" ? "完全值守模式" : "值守模式";
410
+ const glyph =
411
+ state.status === "running"
412
+ ? theme.fg("accent", spinnerFrame())
413
+ : state.status === "done"
414
+ ? theme.fg("success", "✓")
415
+ : state.status === "failed"
416
+ ? theme.fg("error", "✗")
417
+ : theme.fg("warning", "■");
418
+ lines.push(`${glyph} 工作流 · ${dim(theme, modeLabel)} · ${dim(theme, formatDurationFull(elapsed))}`);
419
+
420
+ // ── Step list ──
421
+ for (let i = 0; i < state.steps.length; i++) {
422
+ const s = state.steps[i]!;
423
+ const isCurrent = i === state.currentStepIndex && state.status === "running";
424
+ const isDone = s.status === "done";
425
+ const isFailed = s.status === "failed";
426
+ const isRunning = s.status === "running" || isCurrent;
427
+ const isPending = s.status === "pending" && !isRunning;
428
+ const isSkipped = s.status === "skipped";
429
+
430
+ // ── Icon ──
431
+ // ✓ green for done, ▶ ⠋ orange for current, ◦ dim for pending
432
+ let icon: string;
433
+ if (isDone) {
434
+ icon = theme.fg("success", "✓");
435
+ } else if (isRunning) {
436
+ icon = `▶ ${theme.fg("warning", spinnerFrame())}`;
437
+ } else if (isFailed) {
438
+ icon = theme.fg("error", "✗");
439
+ } else if (isSkipped) {
440
+ icon = theme.fg("warning", "⏭");
441
+ } else {
442
+ icon = dim(theme, "◦");
443
+ }
444
+
445
+ // ── Duration ──
446
+ let displayDurMs: number | undefined = s.durationMs;
447
+ if (isRunning && s.startedAt) {
448
+ displayDurMs = Date.now() - s.startedAt;
449
+ }
450
+ const durStr =
451
+ displayDurMs != null
452
+ ? dim(theme, ` (${formatDurationFull(displayDurMs)}`)
453
+ : isRunning
454
+ ? dim(theme, ` (0s`)
455
+ : "";
456
+ const timeout = s.timeoutMs ? dim(theme, `/超时时间${formatTimeout(s.timeoutMs)}`) : "";
457
+ const durClose = displayDurMs != null || isRunning ? dim(theme, ")") : "";
458
+
459
+ // ── Loop count (第 N 次循环) for loop-group steps ──
460
+ let loopStr = "";
461
+ if (s.loopCount != null && s.loopCount > 0) {
462
+ loopStr = dim(theme, ` · 第 ${s.loopCount} 次循环`);
463
+ } else if (s.maxLoops != null) {
464
+ if (isRunning) {
465
+ // Immediately show 第 1 次循环 when loop-group starts
466
+ loopStr = dim(theme, ` · 第 1 次循环`);
467
+ } else if (isPending) {
468
+ loopStr = dim(theme, ` · 第 0 次循环`);
469
+ }
470
+ }
471
+
472
+ // ── Label color ──
473
+ const labelStyle = isRunning
474
+ ? theme.fg("warning", s.label)
475
+ : isDone
476
+ ? theme.fg("success", s.label)
477
+ : isFailed
478
+ ? theme.fg("error", s.label)
479
+ : dim(theme, s.label);
480
+
481
+ // ── Step indentation ──
482
+ // Done: " ✓ ..."
483
+ // Running: " ▶ ⠋ ..."
484
+ // Other: " ◦ ..."
485
+ let stepIndent: string;
486
+ if (isDone) {
487
+ stepIndent = " ";
488
+ } else if (isRunning) {
489
+ stepIndent = " ";
490
+ } else {
491
+ stepIndent = " ";
492
+ }
493
+
494
+ // ── Step line ──
495
+ lines.push(`${stepIndent}${icon} ${labelStyle}${loopStr}${durStr}${timeout}${durClose}`);
496
+
497
+ // ── Sub-steps (agents with |__ tree) ──
498
+ if (s.subSteps && s.subSteps.length > 0) {
499
+ // Agent indent: 9 for done, 8 for running/pending
500
+ const agentIndent = isDone ? " " : " ";
501
+ // Child indent: always 12
502
+ const childIndent = " ";
503
+ const lastSubIdx = s.subSteps.length - 1;
504
+
505
+ for (let si = 0; si < s.subSteps.length; si++) {
506
+ const sub = s.subSteps[si]!;
507
+ const isSubDone = sub.status === "done" || sub.status === "failed";
508
+ const isSubRunning = sub.status === "running";
509
+ const isSubPending = sub.status === "pending";
510
+ const isLastSub = si === lastSubIdx;
511
+
512
+ // Sub-step icon
513
+ const subIcon = isSubDone
514
+ ? theme.fg("success", "✓")
515
+ : isSubRunning
516
+ ? theme.fg("accent", spinnerFrame())
517
+ : dim(theme, "◦");
518
+
519
+ // Agent line: " |__ ✓ worker ·"
520
+ const agentConnector = dim(theme, "|__");
521
+ lines.push(`${agentIndent}${agentConnector} ${subIcon} ${sub.agent} ·`);
522
+
523
+ // ── Children (tools, outputs, or "正在排队") ──
524
+ const childItems: string[] = [];
525
+
526
+ if (isSubPending) {
527
+ childItems.push(dim(theme, "正在排队"));
528
+ } else if (isSubRunning || isSubDone) {
529
+ if (sub.tools && sub.tools.length > 0) {
530
+ for (const t of sub.tools) {
531
+ childItems.push(t);
532
+ }
533
+ }
534
+ if (sub.outputs && sub.outputs.length > 0) {
535
+ for (const o of sub.outputs) {
536
+ childItems.push(`output:${o}`);
537
+ }
538
+ }
539
+ if (childItems.length === 0 && sub.detail) {
540
+ childItems.push(sub.detail);
541
+ }
542
+ }
543
+
544
+ // Render children with tree branching
545
+ // Non-last child: | (pipe + 3 spaces)
546
+ // Last child: |__ (pipe + 2 underscores + space) — closes the branch
547
+ const lastChildIdx = childItems.length - 1;
548
+ for (let ci = 0; ci < childItems.length; ci++) {
549
+ const isLastChild = ci === lastChildIdx;
550
+ const childConnector = isLastChild ? dim(theme, "|__") : dim(theme, "| ");
551
+ lines.push(`${childIndent}${childConnector} ${childItems[ci]!}`);
552
+ }
553
+ }
554
+ } else if (isPending) {
555
+ // Pending step with no sub-steps yet
556
+ const agentIndent = " ";
557
+ lines.push(`${agentIndent}${dim(theme, "|__")} ${dim(theme, "◦")} 正在排队`);
558
+ }
559
+
560
+ // Error detail for failed steps
561
+ if (isFailed && s.error) {
562
+ for (const errLine of s.error.split("\n")) {
563
+ lines.push(` ${theme.fg("error", errLine)}`);
564
+ }
565
+ }
566
+ }
567
+
568
+ // ── Stats line ──
569
+ const stats: string[] = [];
570
+ if (state.toolCount) stats.push(`${state.toolCount} tools`);
571
+ if (state.tokenCount) stats.push(`${state.tokenCount} tokens`);
572
+ if (stats.length > 0) {
573
+ lines.push(` ${dim(theme, stats.join(" · "))}`);
574
+ }
575
+
576
+ // ── Footer hints (金色) ──
577
+ // if (state.status === "running") {
578
+ // const gold = (text: string) => theme.fg("warning", text);
579
+ // lines.push(` ${gold("Ctrl+O 展开详情")} ${dim(theme, "|")} ${gold("Escape 取消")}`);
580
+ // }
581
+ if (state.status === "running") {
582
+ const gold = (text: string) => theme.fg("warning", text);
583
+ if (!expanded) {
584
+ lines.push(` ${gold("Ctrl+O 展开详情")} ${dim(theme, "|")} ${gold("Escape 取消")}`);
585
+ } else {
586
+ lines.push(` ${dim(theme, "Ctrl+O 折叠详情")} ${dim(theme, "|")} ${gold("Escape 取消")}`);
587
+ }
588
+ }
589
+
590
+ return lines;
591
+ }
592
+
593
+ /**
594
+ * Build the widget component factory for the current state.
595
+ * Returns a factory function (tui, theme) => Component.
596
+ * Ctrl+O toggling is handled by the animation loop, which detects
597
+ * changes in getToolsExpanded() and toggles _widgetExpanded independently.
598
+ */
599
+ function buildWidgetFactory(state: WorkflowWidgetState, expanded: boolean): (_tui: unknown, theme: Theme) => Component {
600
+ return (_tui, theme) => {
601
+ const width = process.stdout.columns || 120;
602
+ const lines = buildWidgetLines(state, theme, expanded, width);
603
+
604
+ const container = new Container();
605
+ const box = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
606
+ const inner = new Container();
607
+ for (const line of lines) {
608
+ inner.addChild(new Text(` ${line}`, 1, 0));
609
+ }
610
+ box.addChild(inner);
611
+ container.addChild(box);
612
+ return container;
613
+ };
614
+ }
615
+
616
+ /**
617
+ * Initialize or update the workflow widget.
618
+ * If state is provided, creates/updates the widget.
619
+ * If state is null, removes the widget.
620
+ */
621
+ export function updateWorkflowWidget(ctx: ExtensionCommandContext, state: WorkflowWidgetState | null): void {
622
+ if (!ctx.hasUI) return;
623
+
624
+ if (!state) {
625
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
626
+ stopWidgetAnimation();
627
+ _widgetState = null;
628
+ _lastWidgetCtx = null;
629
+ return;
630
+ }
631
+
632
+ _widgetState = state;
633
+ _lastWidgetCtx = ctx;
634
+
635
+ // Initialize expanded state from tools panel state
636
+ _widgetExpanded = ctx.ui.getToolsExpanded?.() ?? false;
637
+ ctx.ui.setWidget(WIDGET_KEY, buildWidgetFactory(state, _widgetExpanded));
638
+
639
+ if (state.status === "running") {
640
+ startWidgetAnimation();
641
+ } else {
642
+ stopWidgetAnimation();
643
+ }
644
+ }
645
+
646
+ function startWidgetAnimation(): void {
647
+ if (_widgetAnimationTimer) return;
648
+ _widgetAnimationTimer = setInterval(() => {
649
+ if (!_widgetState || !_lastWidgetCtx?.hasUI) {
650
+ stopWidgetAnimation();
651
+ return;
652
+ }
653
+ try {
654
+ // Sync widget's expanded state from pi's tools panel state (Ctrl+O toggles this)
655
+ // No fighting — let pi handle the toggle normally
656
+ const toolsExpanded = _lastWidgetCtx.ui.getToolsExpanded?.() ?? false;
657
+ _widgetExpanded = toolsExpanded;
658
+
659
+ _lastWidgetCtx.ui.setWidget(WIDGET_KEY, buildWidgetFactory(_widgetState, _widgetExpanded));
660
+ _lastWidgetCtx.ui.requestRender?.();
661
+ } catch {
662
+ stopWidgetAnimation();
663
+ }
664
+ }, ANIMATION_MS);
665
+ _widgetAnimationTimer.unref?.();
666
+ }
667
+
668
+ function stopWidgetAnimation(): void {
669
+ if (_widgetAnimationTimer) {
670
+ clearInterval(_widgetAnimationTimer);
671
+ _widgetAnimationTimer = null;
672
+ }
673
+ }
674
+
675
+ /** Register a cancel callback triggered by Esc in the widget */
676
+ export function setWorkflowCancelCallback(fn: (() => void) | null): void {
677
+ _onCancelWorkflow = fn;
678
+ }
679
+
680
+ /** Trigger workflow cancellation */
681
+ export function cancelWorkflow(): void {
682
+ _onCancelWorkflow?.();
683
+ }
684
+
685
+ // ── Send workflow result to session ──────────────────────────
686
+
687
+ /**
688
+ * Helper: extract all file changes from step states for the completion report.
689
+ * Returns { edits, news, deletes } and a directory-tree formatted string.
690
+ *
691
+ * Checks:
692
+ * 1. sub.tools for edit:/new:/delete: patterns
693
+ * 2. sub.outputs for output file paths (plans, reviews, etc.)
694
+ * 3. Falls back to known output patterns if nothing found
695
+ */
696
+ function extractFileChanges(steps: WorkflowStepWidgetState[]): {
697
+ edits: number;
698
+ news: number;
699
+ deletes: number;
700
+ treeText: string;
701
+ } {
702
+ const editFiles: string[] = [];
703
+ const newFiles: string[] = [];
704
+ const delFiles: string[] = [];
705
+
706
+ // Collect output paths that should be tracked as files
707
+ const outputFiles: string[] = [];
708
+
709
+ for (const s of steps) {
710
+ if (!s.subSteps) continue;
711
+ for (const sub of s.subSteps) {
712
+ // Check tools for file change patterns.
713
+ // Supports both old format ("edit: path", "new: path", "delete: path")
714
+ // and new git-format ("M path", "A path", "D path") from updateToolsFromGit.
715
+ if (sub.tools) {
716
+ for (const tool of sub.tools) {
717
+ // Old format: "edit: path", "new: path", "delete: path"
718
+ const oldEdit = tool.match(/^edit:\s*(.+)/i);
719
+ const oldNew = tool.match(/^new:\s*(.+)/i);
720
+ const oldDel = tool.match(/^delete:\s*(.+)/i);
721
+ // New git-format: "M path", "A path", "D path"
722
+ const gitFormat = tool.match(/^([MAD])\s{2,}(.+)$/);
723
+
724
+ if (oldEdit) {
725
+ const fp = oldEdit[1]!.trim();
726
+ if (!editFiles.includes(fp)) editFiles.push(fp);
727
+ } else if (oldNew) {
728
+ const fp = oldNew[1]!.trim();
729
+ if (!newFiles.includes(fp)) newFiles.push(fp);
730
+ } else if (oldDel) {
731
+ const fp = oldDel[1]!.trim();
732
+ if (!delFiles.includes(fp)) delFiles.push(fp);
733
+ } else if (gitFormat) {
734
+ const status = gitFormat[1]!;
735
+ const fp = gitFormat[2]!.trim();
736
+ if (status === "M" && !editFiles.includes(fp)) {
737
+ editFiles.push(fp);
738
+ } else if (status === "A" && !newFiles.includes(fp)) {
739
+ newFiles.push(fp);
740
+ } else if (status === "D" && !delFiles.includes(fp)) {
741
+ delFiles.push(fp);
742
+ }
743
+ }
744
+ }
745
+ }
746
+ // Check outputs for generated file paths (plans, review reports, etc.)
747
+ if (sub.outputs) {
748
+ for (const o of sub.outputs) {
749
+ // Only track output files that look like actual generated docs
750
+ if (
751
+ o.includes(".pi-dev-output") ||
752
+ o.includes(".md") ||
753
+ o.includes("review-") ||
754
+ o.includes("pi-plans") ||
755
+ o.includes("pi-review")
756
+ ) {
757
+ if (!outputFiles.includes(o)) {
758
+ outputFiles.push(o);
759
+ }
760
+ }
761
+ }
762
+ }
763
+ }
764
+ }
765
+
766
+ // ── Merge all file paths, deduplicate ─────────────────────
767
+ const allPaths = new Map<string, "edit" | "new" | "delete" | "output">();
768
+ for (const fp of editFiles) allPaths.set(fp, "edit");
769
+ for (const fp of newFiles) allPaths.set(fp, "new");
770
+ for (const fp of delFiles) allPaths.set(fp, "delete");
771
+ for (const fp of outputFiles) {
772
+ if (!allPaths.has(fp)) allPaths.set(fp, "output");
773
+ }
774
+
775
+ // ── Build multi-level tree ────────────────────────────────
776
+ interface TreeNode {
777
+ name: string;
778
+ children: Map<string, TreeNode>;
779
+ isFile: boolean;
780
+ }
781
+
782
+ function buildTree(paths: Iterable<string>): TreeNode {
783
+ const root: TreeNode = { name: "", children: new Map(), isFile: false };
784
+ for (const filePath of paths) {
785
+ const parts = filePath.split("/");
786
+ let current = root;
787
+ for (let i = 0; i < parts.length; i++) {
788
+ const part = parts[i]!;
789
+ const isLast = i === parts.length - 1;
790
+ let child = current.children.get(part);
791
+ if (!child) {
792
+ child = { name: part, children: new Map(), isFile: false };
793
+ current.children.set(part, child);
794
+ }
795
+ current = child;
796
+ if (isLast) current.isFile = true;
797
+ }
798
+ }
799
+ return root;
800
+ }
801
+
802
+ function renderTreeNode(node: TreeNode, prefix: string, isLast: boolean, lines: string[]): void {
803
+ const connector = isLast ? "└── " : "├── ";
804
+ lines.push(`${prefix}${connector}${node.name}`);
805
+ renderChildren(node, prefix + (isLast ? " " : "│ "), lines);
806
+ }
807
+
808
+ function renderChildren(node: TreeNode, prefix: string, lines: string[]): void {
809
+ const entries = [...node.children.entries()].sort((a, b) => {
810
+ // Directories before files, then alphabetical
811
+ if (a[1].isFile !== b[1].isFile) return a[1].isFile ? 1 : -1;
812
+ return a[0].localeCompare(b[0]);
813
+ });
814
+ for (let i = 0; i < entries.length; i++) {
815
+ const [, child] = entries[i]!;
816
+ const isLastChild = i === entries.length - 1;
817
+ if (child.children.size > 0 && !child.isFile) {
818
+ // Directory node (has children, not marked as file)
819
+ renderTreeNode(child, prefix, isLastChild, lines);
820
+ } else {
821
+ // Leaf node (file or empty directory)
822
+ const connector = isLastChild ? "└── " : "├── ";
823
+ lines.push(`${prefix}${connector}${child.name}`);
824
+ }
825
+ }
826
+ }
827
+
828
+ const tree = buildTree(allPaths.keys());
829
+ const treeLines: string[] = [];
830
+ if (tree.children.size > 0) {
831
+ renderChildren(tree, "", treeLines);
832
+ } else {
833
+ treeLines.push("(无文件变更)");
834
+ }
835
+
836
+ // ── Count by type ─────────────────────────────────────────
837
+ let edits = 0,
838
+ news = 0,
839
+ deletes = 0;
840
+ for (const [fp, type] of allPaths) {
841
+ if (type === "edit") edits++;
842
+ else if (type === "new" || type === "output") news++;
843
+ else if (type === "delete") deletes++;
844
+ }
845
+
846
+ return {
847
+ edits,
848
+ news,
849
+ deletes,
850
+ treeText: treeLines.join("\n"),
851
+ };
852
+ }
853
+
854
+ /**
855
+ * Helper: build a human-readable task summary from the prompt.
856
+ * Extracts the first line/type tag from the prompt and produces a clean summary.
857
+ *
858
+ * Examples:
859
+ * Input: "[feat] 在 auth 模块中实现用户登录"
860
+ * Output: "feat - 在 auth 模块中实现用户登录"
861
+ *
862
+ * Input: "[fix] 修复 login.ts 中的 401 错误"
863
+ * Output: "fix - 修复 login.ts 中的 401 错误"
864
+ */
865
+ function extractTaskSummary(prompt: string): string {
866
+ const firstLine = prompt.split("\n").find((l) => l.trim()) ?? "";
867
+ // Match [feat] xxx or [fix] xxx or similar
868
+ const tagMatch = firstLine.match(/^\[([^\]]+)\]\s*(.+)/);
869
+ if (tagMatch) {
870
+ const tag = tagMatch[1]!.trim();
871
+ const rest = tagMatch[2]!.trim();
872
+ // If the rest looks like placeholder dots, try to find a better summary
873
+ if (rest.replace(/\.\.\./g, "").trim() === "" || rest === "...") {
874
+ // Try the second line or use the tag as fallback
875
+ const lines = prompt.split("\n").filter((l) => l.trim());
876
+ if (lines.length > 1) {
877
+ const secondLine = lines[1]!.replace(/^[*\s#]+/, "").trim();
878
+ if (secondLine && !secondLine.startsWith("**")) {
879
+ return `${tag} - ${secondLine.substring(0, 60)}`;
880
+ }
881
+ }
882
+ // Try to find any meaningful content in the prompt
883
+ for (const line of lines.slice(1, 5)) {
884
+ const cleaned = line.replace(/^[*\s#]+/, "").trim();
885
+ if (cleaned && cleaned.length > 5 && !cleaned.startsWith("**") && !cleaned.startsWith("`")) {
886
+ const summary = cleaned.length > 50 ? cleaned.substring(0, 47) + "..." : cleaned;
887
+ return `${tag} - ${summary}`;
888
+ }
889
+ }
890
+ return `${tag} - 工作流任务`;
891
+ }
892
+ return `${tag} - ${rest}`;
893
+ }
894
+ // Fallback: first meaningful line (up to 60 chars)
895
+ const cleaned = firstLine.replace(/^[*\s#]+/, "").trim();
896
+ return cleaned.length > 60 ? cleaned.substring(0, 57) + "..." : cleaned || "工作流任务";
897
+ }
898
+
899
+ /**
900
+ * Send a workflow completion message to the session for persistence.
901
+ * Design: 严格要求的完成后的新ui:
902
+ *
903
+ * [dev-workflow-result]
904
+ * [feat - 添加xxx功能]
905
+ *
906
+ * 🎉 工作流全部完成 (6m8s)
907
+ *
908
+ * ✅ 📋 生成实施计划 (1m33s)
909
+ * ✅ 🔧 实施代码 → 审查 (3m20s)
910
+ *
911
+ * 变动文件:
912
+ * ├── extensions
913
+ * │ ├── xxxx1.ts
914
+ * │ ├── xxxx2.ts
915
+ * ├── .pi-dev-output
916
+ * │ ├── pi-plans
917
+ * │ │ └── 20260519-2155-workflow-ui-async-refactor.md
918
+ * ├── tests
919
+ * │ ├── test-workflow-engine.mjs
920
+ *
921
+ * 完成 2/2 步子代理任务,修改 2 个文件,新增 8 个文件
922
+ */
923
+ export function sendWorkflowResult(
924
+ pi: ExtensionAPI,
925
+ state: WorkflowWidgetState,
926
+ prompt: string,
927
+ workflowType?: string,
928
+ ): void {
929
+ const totalDur = formatDurationFull(Date.now() - state.startedAt);
930
+ const doneCount = state.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
931
+ const failedCount = state.steps.filter((s) => s.status === "failed").length;
932
+ const total = state.steps.length;
933
+
934
+ const resultIcon = state.status === "done" ? "🎉" : state.status === "failed" ? "❌" : "⏹️";
935
+ const statusText = state.status === "done" ? "全部完成" : state.status === "failed" ? "部分失败" : "已取消";
936
+
937
+ // Count sub-agent runs (total sub-step executions)
938
+ let subAgentRuns = 0;
939
+ for (const s of state.steps) {
940
+ if (s.subSteps) subAgentRuns += s.subSteps.length;
941
+ }
942
+
943
+ // Extract file changes from step states
944
+ const fileChanges = extractFileChanges(state.steps);
945
+
946
+ // Build task summary from prompt
947
+ const taskSummary = extractTaskSummary(prompt);
948
+
949
+ // Build step summary lines
950
+ const stepSummaryParts: string[] = [];
951
+ for (const s of state.steps) {
952
+ const icon = s.status === "done" ? "✅" : s.status === "failed" ? "❌" : s.status === "skipped" ? "⏭️" : "⬜";
953
+ const durSuffix = s.durationMs != null ? ` (${formatDurationFull(s.durationMs)})` : "";
954
+ const errSuffix = s.status === "failed" && s.error ? ` — ${s.error}` : "";
955
+ stepSummaryParts.push(`${icon} **${s.label}**${durSuffix}${errSuffix}`);
956
+ }
957
+
958
+ const body = [
959
+ `[${taskSummary}]`,
960
+ "",
961
+ `${resultIcon} **工作流${statusText}** (${totalDur})`,
962
+ "",
963
+ stepSummaryParts.join("\n"),
964
+ "",
965
+ "变动文件:",
966
+ "```",
967
+ fileChanges.treeText,
968
+ "```",
969
+ "",
970
+ `完成 ${doneCount}/${total} 步子代理任务,修改 ${fileChanges.edits} 个文件,新增 ${fileChanges.news} 个文件` +
971
+ (fileChanges.deletes > 0 ? `,删除 ${fileChanges.deletes} 个文件` : "") +
972
+ (failedCount > 0 ? `,${failedCount} 步失败` : ""),
973
+ ].join("\n");
974
+
975
+ try {
976
+ pi.sendMessage({
977
+ customType: "dev-workflow-result",
978
+ content: body,
979
+ display: true,
980
+ details: {
981
+ status: state.status,
982
+ steps: state.steps,
983
+ durationMs: Date.now() - state.startedAt,
984
+ workflowType,
985
+ prompt,
986
+ taskSummary,
987
+ fileChanges: { edits: fileChanges.edits, news: fileChanges.news, deletes: fileChanges.deletes },
988
+ subAgentRuns,
989
+ },
990
+ });
991
+ } catch {
992
+ console.log(`[workflow] ${body}`);
993
+ }
994
+ }
995
+
996
+ // ── Dynamic progress update ──────────────────────────────────
997
+
998
+ /**
999
+ * Helper to build WorkflowWidgetState from step states.
1000
+ */
1001
+ export function buildWidgetState(
1002
+ mode: string,
1003
+ steps: WorkflowStepWidgetState[],
1004
+ currentStepIndex: number,
1005
+ startedAt: number,
1006
+ status: WorkflowWidgetState["status"],
1007
+ extra?: { toolCount?: number; tokenCount?: number },
1008
+ taskSummary?: string,
1009
+ ): WorkflowWidgetState {
1010
+ return {
1011
+ mode,
1012
+ steps,
1013
+ currentStepIndex,
1014
+ startedAt,
1015
+ status,
1016
+ updatedAt: new Date().toISOString(),
1017
+ toolCount: extra?.toolCount,
1018
+ tokenCount: extra?.tokenCount,
1019
+ taskSummary,
1020
+ };
1021
+ }
1022
+
1023
+ // ═══════════════════════════════════════════════════════════════
1024
+ // Extension factory (no-op — ui-helpers is a helper module)
1025
+ // ═══════════════════════════════════════════════════════════════
1026
+
1027
+ export default function (_pi: ExtensionAPI) {
1028
+ // ui-helpers is a helper module, imported by other extensions.
1029
+ }