@zhushanwen/pi-subagent-workflow 8.8.0 → 8.8.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.
Files changed (35) hide show
  1. package/package.json +8 -8
  2. package/src/host/pi-host.ts +5 -4
  3. package/src/index.ts +145 -464
  4. package/src/injectors/__tests__/engine-awareness.test.ts +4 -2
  5. package/src/injectors/__tests__/engine-section-stability.test.ts +42 -29
  6. package/src/injectors/engine-awareness.ts +80 -4
  7. package/src/injectors/resource-list-injector.ts +209 -0
  8. package/src/injectors/subagent-list-injector.ts +30 -143
  9. package/src/injectors/workflow-list-injector.ts +23 -150
  10. package/src/interface/__tests__/detectors.test.ts +1 -1
  11. package/src/interface/__tests__/subagent-tool-path-guard.test.ts +42 -26
  12. package/src/interface/__tests__/subagent-tool-prompt.test.ts +10 -2
  13. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +1 -1
  14. package/src/interface/bg-notify-render.ts +4 -4
  15. package/src/interface/commands.ts +115 -75
  16. package/src/interface/format.ts +339 -241
  17. package/src/interface/helpers.ts +2 -2
  18. package/src/interface/list-component.ts +163 -116
  19. package/src/interface/list-shared.ts +2 -2
  20. package/src/interface/list-view.ts +129 -72
  21. package/src/interface/subagent-actions.ts +5 -5
  22. package/src/interface/subagent-tool-schema.ts +6 -5
  23. package/src/interface/subagent-tool.ts +4 -3
  24. package/src/interface/subagents.ts +8 -7
  25. package/src/interface/tool-render.ts +2 -2
  26. package/src/interface/tool-workflow-script.ts +15 -14
  27. package/src/interface/tool-workflow.ts +14 -11
  28. package/src/interface/tui-kit.ts +305 -0
  29. package/src/interface/views/WorkflowsView.ts +267 -196
  30. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +3 -3
  31. package/src/interface/views/__tests__/detail-content-session-file.test.ts +7 -4
  32. package/src/interface/views/detail-content.ts +16 -14
  33. package/src/interface/views/view-constants.ts +19 -0
  34. package/src/jsonl-run-store.ts +11 -7
  35. package/src/interface/views/format.ts +0 -281
@@ -6,16 +6,28 @@
6
6
  // `·` 同级并列字段/thinking 图标;`()` 元数据分组;`›` 工具;`>` 输出;`·` thinking.
7
7
  // 禁用 `│` 做 stats 分隔、`├─`/`└─` 做 eventLog 前缀.
8
8
  //
9
- // 截断(tui-format.md §5):truncLine 是 ANSI 安全的——追踪 active SGR,省略号前重应用,
10
- // 否则背景色在省略号处断裂(contentBox 的 applyBg 被 `\x1b[0m` 抹掉).
11
- // 移植自 pi-subagents render.ts:44-89.
9
+ // 截断/填充/换行(truncLine / padToVisible / segFillColored / wrapText)已迁
10
+ // ./tui-kit.ts(post-convergence C4 零依赖叶),本文件 re-export 维持既有导入面.
12
11
 
13
12
  import os from "node:os";
14
13
 
15
- import { visibleWidth } from "@earendil-works/pi-tui";
14
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
16
15
 
17
- import type { AgentEventLogEntry, DisplayItem, ExecutionStatus } from "@zhushanwen/subagent-core/execution/types.ts";
18
- import { DEFAULT_AGENT_NAME } from "@zhushanwen/subagent-core/execution/types.ts";
16
+ import type { AgentEventLogEntry, DisplayItem, ExecutionStatus } from "@zhushanwen/subagent-core";
17
+ import { DEFAULT_AGENT_NAME } from "@zhushanwen/subagent-core";
18
+ import type {
19
+ DoneReason,
20
+ ExecutionTraceNode,
21
+ RunStatus,
22
+ ToolCallEntry,
23
+ } from "@zhushanwen/subagent-core";
24
+ import { displayAgentName } from "@zhushanwen/subagent-core";
25
+
26
+ import { padToVisible, segFillColored, truncLine, wrapText } from "./tui-kit.ts";
27
+
28
+ // ANSI 可见宽度布局家族单定义在 ./tui-kit.ts(零依赖叶);此处 re-export 保持
29
+ // 既有消费方(tool-render / bg-notify-render / 测试等)导入面零改动。
30
+ export { padToVisible, segFillColored, truncLine, wrapText };
19
31
 
20
32
  /**
21
33
  * ThemeLike:TUI 语义 token 着色接口(duck-typed,兼容 Pi Theme).
@@ -37,9 +49,6 @@ export interface ThemeLike {
37
49
  /** spinner 帧序列(Braille),seed-frame 驱动,不用 setInterval. */
38
50
  const RUNNING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
39
51
 
40
- /** grapheme 切分器(Unicode/emoji 安全).模块级共享,勿热路径 new. */
41
- const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
42
-
43
52
  // formatTokens 阈值(三段式,与 demo 一致)
44
53
  /** < 此值显示原值. */
45
54
  const TOKEN_PLAIN_MAX = 1000;
@@ -119,49 +128,6 @@ export function shortId(id: string): string {
119
128
  return segments.slice(0, SHORT_ID_BG_SEGMENTS).join("-");
120
129
  }
121
130
 
122
- /**
123
- * 把文本 pad 到指定**可见**宽度(grapheme/emoji/CJK 安全).
124
- *
125
- * 用 visibleWidth 而非 `.length`——避免 ANSI 转义、emoji、宽字符(CJK 占 2 列)
126
- * 把列对齐算错(dev guide §2.4 警告的坑).
127
- *
128
- * - 已 ≥ width → 原样返回(调用方负责先 truncLine 截断)
129
- * - < width → 末尾补空格到可见宽度对齐
130
- *
131
- * 与 truncLine 配对:左/右列对齐时先 `truncLine(s, colWidth)` 再 `padToVisible(s, colWidth)`.
132
- */
133
- export function padToVisible(text: string, width: number): string {
134
- const w = visibleWidth(text);
135
- if (w >= width) return text;
136
- return text + " ".repeat(width - w);
137
- }
138
-
139
- /**
140
- * 分段着色版 segFill:title 和 fill 都已着色(含 ANSI),拼接时各自 ANSI 延续.
141
- *
142
- * 解决 ANSI 嵌套失色问题:若用 `t.fg("c1", fill(title, "─", n))`,
143
- * title 内的 `\x1b[0m` 会重置外层 c1,导致 title 之后的 `─` 失去 c1.
144
- * 本函数改成 `title + fill.repeat(后)`,fill 整段保持着自己的 ANSI,不依赖外层包裹 → 全线着色一致.
145
- *
146
- * segFillColored(t.fg("accent"," Subagents "), t.fg("borderMuted","─"), 20)
147
- * → accent(" Subagents ") + borderMuted(─×N),无嵌套
148
- *
149
- * 注意:fill 必须是「单字符着色」(如 `t.fg("borderMuted","─")`),visibleWidth=1.
150
- * 调用方负责 title/fill 着色;本函数不接 theme.标题在前、填充在后.
151
- */
152
- export function segFillColored(titleStyled: string | undefined, fillStyled: string, width: number): string {
153
- if (width <= 0) return "";
154
- const fillW = visibleWidth(fillStyled);
155
- if (!titleStyled || fillW === 0) {
156
- // 纯填充线:fillStyled.visibleWidth 应为 1,按 width 次重复
157
- return fillStyled.repeat(width);
158
- }
159
- const tw = visibleWidth(titleStyled);
160
- if (tw >= width) return truncLine(titleStyled, width);
161
- const fillCount = width - tw;
162
- return titleStyled + fillStyled.repeat(fillCount);
163
- }
164
-
165
131
  // ============================================================
166
132
  // 状态图标
167
133
  // ============================================================
@@ -303,78 +269,120 @@ export function formatEventLine(entry: AgentEventLogEntry, theme: ThemeLike): st
303
269
  }
304
270
  }
305
271
 
272
+ // ── formatToolCall 表驱动分发(形态 2)────────────────────────
273
+ // 每个 toolName 的 case 体提为独立格式化函数,主函数留查表 + 落空语义
274
+ // (落空 = 原 default 分支逐字节一致;查表用 Map 避免原型链键误命中,
275
+ // 如 toolName === "constructor" 时普通对象下标会取到 Object.prototype)。
276
+
277
+ /** formatToolCall 单工具格式化器签名(同步——保持原 switch case 同步语义)。 */
278
+ type ToolCallFormatter = (args: Record<string, unknown>, theme: ThemeLike) => string;
279
+
280
+ /** 显示层路径缩写:~ 替换 home 前缀(仅显示层,不读取 pi 目录,TC9 合法命中)。 */
281
+ function shortenHomePath(p: string): string {
282
+ const home = os.homedir();
283
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
284
+ }
285
+
286
+ /** read/write/edit 共用的路径参数解析(file_path 优先,path 兜底,最终 "..." 兜底)。 */
287
+ function toolPathArg(args: Record<string, unknown>): string {
288
+ return (args.file_path || args.path || "...") as string;
289
+ }
290
+
291
+ /** bash → `$ <command 预览>`。 */
292
+ function formatBashToolCall(args: Record<string, unknown>, theme: ThemeLike): string {
293
+ const command = (args.command as string) || "...";
294
+ const preview = command.length > BASH_PREVIEW_MAX_CHARS ? `${command.slice(0, BASH_PREVIEW_MAX_CHARS)}...` : command;
295
+ return theme.fg("muted", "$ ") + theme.fg("toolOutput", preview);
296
+ }
297
+
298
+ /** read 的 `:start-end` 行号后缀文本(offset/limit 均缺省时返回空串——外层据此跳过着色)。 */
299
+ function readLineRangeText(args: Record<string, unknown>): string {
300
+ const offset = args.offset as number | undefined;
301
+ const limit = args.limit as number | undefined;
302
+ if (offset === undefined && limit === undefined) return "";
303
+ const startLine = offset ?? 1;
304
+ const endLine = limit !== undefined ? startLine + limit - 1 : "";
305
+ return `:${startLine}${endLine ? `-${endLine}` : ""}`;
306
+ }
307
+
308
+ /** read → `read <~路径:offset-limit>`。 */
309
+ function formatReadToolCall(args: Record<string, unknown>, theme: ThemeLike): string {
310
+ let text = theme.fg("accent", shortenHomePath(toolPathArg(args)));
311
+ const range = readLineRangeText(args);
312
+ if (range) text += theme.fg("warning", range);
313
+ return theme.fg("muted", "read ") + text;
314
+ }
315
+
316
+ /** write → `write <~路径> (N lines)`。 */
317
+ function formatWriteToolCall(args: Record<string, unknown>, theme: ThemeLike): string {
318
+ const content = (args.content || "") as string;
319
+ const lines = content.split("\n").length;
320
+ let text = theme.fg("muted", "write ") + theme.fg("accent", shortenHomePath(toolPathArg(args)));
321
+ if (lines > 1) text += theme.fg("dim", ` (${lines} lines)`);
322
+ return text;
323
+ }
324
+
325
+ /** edit → `edit <~路径>`。 */
326
+ function formatEditToolCall(args: Record<string, unknown>, theme: ThemeLike): string {
327
+ return theme.fg("muted", "edit ") + theme.fg("accent", shortenHomePath(toolPathArg(args)));
328
+ }
329
+
330
+ /** ls → `ls <~路径>`。 */
331
+ function formatLsToolCall(args: Record<string, unknown>, theme: ThemeLike): string {
332
+ const rawPath = (args.path || ".") as string;
333
+ return theme.fg("muted", "ls ") + theme.fg("accent", shortenHomePath(rawPath));
334
+ }
335
+
336
+ /** find → `find <pattern> in <~路径>`。 */
337
+ function formatFindToolCall(args: Record<string, unknown>, theme: ThemeLike): string {
338
+ const pattern = (args.pattern || "*") as string;
339
+ const rawPath = (args.path || ".") as string;
340
+ return theme.fg("muted", "find ") + theme.fg("accent", pattern) + theme.fg("dim", ` in ${shortenHomePath(rawPath)}`);
341
+ }
342
+
343
+ /** grep → `grep /<pattern>/ in <~路径>`。 */
344
+ function formatGrepToolCall(args: Record<string, unknown>, theme: ThemeLike): string {
345
+ const pattern = (args.pattern || "") as string;
346
+ const rawPath = (args.path || ".") as string;
347
+ return theme.fg("muted", "grep ") + theme.fg("accent", `/${pattern}/`) + theme.fg("dim", ` in ${shortenHomePath(rawPath)}`);
348
+ }
349
+
350
+ /** default 落空语义:`<toolName> <argsJSON 预览>`(与原 switch default 逐字节一致)。 */
351
+ function formatUnknownToolCall(toolName: string, args: Record<string, unknown>, theme: ThemeLike): string {
352
+ const argsStr = JSON.stringify(args);
353
+ const preview = argsStr.length > ARGS_PREVIEW_MAX_CHARS ? `${argsStr.slice(0, ARGS_PREVIEW_MAX_CHARS)}...` : argsStr;
354
+ return theme.fg("accent", toolName) + theme.fg("dim", ` ${preview}`);
355
+ }
356
+
357
+ /** toolName → 格式化函数查找表(Map:杜绝 Object 原型链键误命中)。 */
358
+ const TOOL_CALL_FORMATTERS: ReadonlyMap<string, ToolCallFormatter> = new Map([
359
+ ["bash", formatBashToolCall],
360
+ ["read", formatReadToolCall],
361
+ ["write", formatWriteToolCall],
362
+ ["edit", formatEditToolCall],
363
+ ["ls", formatLsToolCall],
364
+ ["find", formatFindToolCall],
365
+ ["grep", formatGrepToolCall],
366
+ ]);
367
+
306
368
  /**
307
369
  * [STEP3] 格式化单个 toolCall 为展示行(对齐 nicobailon formatToolCall)。
308
370
  *
309
- * 返回不含前缀(`→ ` 由调用方加)。根据 toolName 提取关键参数格式化:
371
+ * 返回不含前缀(`→ ` 由调用方加)。根据 toolName 查表提取关键参数格式化:
310
372
  * bash → `$ <command 预览>`
311
373
  * read → `read <~路径:offset-limit>`
312
374
  * edit/write → `<op> <~路径>`
313
375
  * grep/find/ls → 对应格式
314
- * default → `<toolName> <argsJSON 预览>`
376
+ * 未命中(含 default)→ `<toolName> <argsJSON 预览>`
315
377
  */
316
378
  export function formatToolCall(
317
379
  toolName: string,
318
380
  args: Record<string, unknown>,
319
381
  theme: ThemeLike,
320
382
  ): string {
321
- const shortenPath = (p: string): string => {
322
- // 仅用于显示层路径缩写(~ 替换 home 前缀),不读取 pi 目录(TC9 合法命中)
323
- const home = os.homedir();
324
- return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
325
- };
326
-
327
- switch (toolName) {
328
- case "bash": {
329
- const command = (args.command as string) || "...";
330
- const preview = command.length > BASH_PREVIEW_MAX_CHARS ? `${command.slice(0, BASH_PREVIEW_MAX_CHARS)}...` : command;
331
- return theme.fg("muted", "$ ") + theme.fg("toolOutput", preview);
332
- }
333
- case "read": {
334
- const rawPath = (args.file_path || args.path || "...") as string;
335
- const filePath = shortenPath(rawPath);
336
- const offset = args.offset as number | undefined;
337
- const limit = args.limit as number | undefined;
338
- let text = theme.fg("accent", filePath);
339
- if (offset !== undefined || limit !== undefined) {
340
- const startLine = offset ?? 1;
341
- const endLine = limit !== undefined ? startLine + limit - 1 : "";
342
- text += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
343
- }
344
- return theme.fg("muted", "read ") + text;
345
- }
346
- case "write": {
347
- const rawPath = (args.file_path || args.path || "...") as string;
348
- const content = (args.content || "") as string;
349
- const lines = content.split("\n").length;
350
- let text = theme.fg("muted", "write ") + theme.fg("accent", shortenPath(rawPath));
351
- if (lines > 1) text += theme.fg("dim", ` (${lines} lines)`);
352
- return text;
353
- }
354
- case "edit": {
355
- const rawPath = (args.file_path || args.path || "...") as string;
356
- return theme.fg("muted", "edit ") + theme.fg("accent", shortenPath(rawPath));
357
- }
358
- case "ls": {
359
- const rawPath = (args.path || ".") as string;
360
- return theme.fg("muted", "ls ") + theme.fg("accent", shortenPath(rawPath));
361
- }
362
- case "find": {
363
- const pattern = (args.pattern || "*") as string;
364
- const rawPath = (args.path || ".") as string;
365
- return theme.fg("muted", "find ") + theme.fg("accent", pattern) + theme.fg("dim", ` in ${shortenPath(rawPath)}`);
366
- }
367
- case "grep": {
368
- const pattern = (args.pattern || "") as string;
369
- const rawPath = (args.path || ".") as string;
370
- return theme.fg("muted", "grep ") + theme.fg("accent", `/${pattern}/`) + theme.fg("dim", ` in ${shortenPath(rawPath)}`);
371
- }
372
- default: {
373
- const argsStr = JSON.stringify(args);
374
- const preview = argsStr.length > ARGS_PREVIEW_MAX_CHARS ? `${argsStr.slice(0, ARGS_PREVIEW_MAX_CHARS)}...` : argsStr;
375
- return theme.fg("accent", toolName) + theme.fg("dim", ` ${preview}`);
376
- }
377
- }
383
+ const formatter = TOOL_CALL_FORMATTERS.get(toolName);
384
+ if (formatter) return formatter(args, theme);
385
+ return formatUnknownToolCall(toolName, args, theme);
378
386
  }
379
387
 
380
388
  /**
@@ -396,157 +404,247 @@ export function formatDisplayItem(item: DisplayItem, theme: ThemeLike): string {
396
404
  }
397
405
 
398
406
  // ============================================================
399
- // ANSI 安全截断
407
+ // Workflow view 格式化(WorkflowsView / detail-content 专用差异段)
408
+ //
409
+ // 自 views/format.ts 并入(D7-① 双轨合并):workflow 视图特有的 badge/phase/
410
+ // trace 行格式化在此作差异段保留。并入时收敛的同构构件——ThemeLike、
411
+ // formatElapsedSeconds(本文件版含小时分支,>1h 显示 "1h15m" 而非 "75m30s")、
412
+ // segFillColored、padToVisible(后者已随 C4 迁 ./tui-kit.ts,经 re-export 复用)。
400
413
  // ============================================================
401
414
 
402
- /**
403
- * SGR 序列 sticky regex(truncLine 专用)。
404
- *
405
- * lastIndex 置位后 exec/test 只在该位置匹配(等价 `flat.slice(pos).match(/^\x1b\[[0-9;]*m/)`,
406
- * 但零子串分配)。同步代码内「设 lastIndex → 立即用」,无跨调用残留。
407
- */
408
- const SGR_STICKY_RE = /\x1b\[[0-9;]*m/y;
415
+ // ── Workflow view 布局常量 ────────────────────────────────────
409
416
 
410
- /** 判断 s 在 pos 处是否开始一个 SGR 序列(非 SGR ESC——OSC/非 SGR CSI/裸 ESC——返回 false)。 */
411
- function isSgrStart(s: string, pos: number): boolean {
412
- SGR_STICKY_RE.lastIndex = pos;
413
- return SGR_STICKY_RE.test(s);
414
- }
417
+ // views 专属布局常量(SIDEBAR_WIDTH / PROMPT_FOLD_LINES / OUTPUT_TRUNCATE_BYTES /
418
+ // BOX_BORDER_CHARS / BUDGET_TOKENS_DIVISOR / MAX_TOOL_CALLS_DISPLAY)已沉回
419
+ // ./views/view-constants.ts(就近消费方,post-convergence C4);
420
+ // 终端兜底零件(终端行数兜底、翻页兜底步长)单定义在 ./tui-kit.ts。
421
+ // ELLIPSIS 留此:formatActivityLine / formatPhaseLine(本文件)亦消费,非 views 专属。
422
+ export const ELLIPSIS = "\u2026"; // U+2026
423
+
424
+ /** formatElapsed 的毫秒→秒换算。 */
425
+ const MS_PER_SEC = 1000;
415
426
 
416
427
  /**
417
- * 截断文本到 maxWidth 可见宽度(带省略号 `…`,ANSI 安全).
428
+ * 可显示的状态文本集合。
418
429
  *
419
- * 问题:pi-tui 的 truncateToWidth 在省略号前插 `\x1b[0m`(全局 reset),
420
- * 导致 contentBox 施加的背景色在省略号处断裂.
430
+ * 包含 RunStatus("running"|"done" 不直接显示,转 reason)+ DoneReason
431
+ * (completed/failed/aborted/budget_limited/time_limited)+ ExecutionTraceNode.status
432
+ * (含 "pending"——trace 节点的初始态)。
421
433
  *
422
- * 解决:遍历追踪 active SGR styles,遇 `\x1b[0m` 清空、遇其他 `\x1b[..m` push,
423
- * 截断时 `result + activeStyles.join("") + "…"`——重应用 active 样式,背景不断裂.
424
- * 用 Intl.Segmenter grapheme 切分,正确处理 emoji/CJK/组合字符.
425
- *
426
- * 性能(IF12/#18):旧实现逐字符 `flat.slice(end).match(...)` 是 O(n²)(每字符
427
- * 一次子串分配 + 正则扫描);现改为 `indexOf("\x1b", ...)` 跳到下一 ESC,仅在 ESC
428
- * 位置做一次 sticky 判定——纯文本段零分配零正则。
429
- *
430
- * **非 SGR ESC 行为(等价关键,逐字节等价的定义点)**:OSC(\x1b]0;..\x07)、
431
- * 非 SGR CSI(\x1b[K)、裸 \x1b 后跟非 [ 字符——均不构成文本段边界,作为纯文本
432
- * 并入 textPortion 被 segmenter 消费(占 currentWidth、进 result),与旧实现
433
- * 「while 停点集合 = SGR 序列起点」完全一致。输出由 __fixtures__/truncline.snapshot.json
434
- * (改造前实现生成)逐字节锚定。
435
- *
436
- * 移植自 pi-subagents render.ts:44-89.
434
+ * 收窄自 string → 显式联合,编译器会在新增 status 时强制 switch 补齐分支。
437
435
  */
438
- export function truncLine(text: string, maxWidth: number): string {
439
- if (maxWidth <= 0) return "";
440
- // 剥离换行符(\n / \r):text 可能含多行 prompt / turn.text,\n 不占可见宽度
441
- // 但在终端会换行,导致单行渲染意外变成多行,破坏行对齐(list overlay 左右列错位、
442
- // tool block 行数跳变)。单行渲染入口必须保证无 \n。用空格替代(保留词边界可读性)。
443
- const flat = text.replace(/[\r\n]+/g, " ");
444
- if (visibleWidth(flat) <= maxWidth) return flat;
445
-
446
- const targetWidth = Math.max(0, maxWidth - 1);
447
- let result = "";
448
- let currentWidth = 0;
449
- let activeStyles: string[] = [];
450
- let i = 0;
451
-
452
- while (i < flat.length) {
453
- // 捕获 ANSI SGR 序列(sticky:只在 i 处匹配,等价旧 slice(i).match(/^.../m))
454
- SGR_STICKY_RE.lastIndex = i;
455
- const ansiMatch = SGR_STICKY_RE.exec(flat);
456
- if (ansiMatch) {
457
- const code = ansiMatch[0];
458
- result += code;
459
-
460
- if (code === "\x1b[0m" || code === "\x1b[m") {
461
- activeStyles = []; // reset → 清空栈
462
- } else {
463
- activeStyles.push(code);
464
- }
465
- i += code.length;
466
- continue;
467
- }
436
+ export type StatusText =
437
+ | RunStatus
438
+ | DoneReason
439
+ | "pending";
468
440
 
469
- // 找到下一段纯文本(非 ANSI)的边界:下一处 SGR 序列起点(或串尾)。
470
- // indexOf 跳到 ESC 候选位,仅对 ESC 位置做一次 sticky 判定;非 SGR ESC
471
- // (OSC/\x1b[K/裸 \x1b)不构成边界、并入文本段(end 推进到该 ESC 之后继续找),
472
- // 与旧逐字符 while 的停点集合逐字节一致。
473
- let end = flat.length;
474
- for (
475
- let escPos = flat.indexOf("\x1b", i);
476
- escPos !== -1;
477
- escPos = flat.indexOf("\x1b", escPos + 1)
478
- ) {
479
- if (isSgrStart(flat, escPos)) {
480
- end = escPos;
481
- break;
482
- }
483
- }
441
+ // ── Workflow status helpers ──────────────────────────────────
484
442
 
485
- // 按 grapheme 迭代这段文本,累加到 targetWidth
486
- const textPortion = flat.slice(i, end);
487
- for (const seg of segmenter.segment(textPortion)) {
488
- const grapheme = seg.segment;
489
- const graphemeWidth = visibleWidth(grapheme);
490
-
491
- if (currentWidth + graphemeWidth > targetWidth) {
492
- // 截断:重应用 active 样式 + 省略号 + reset。
493
- // reset 不可省——否则行尾颜色渗透到 padToVisible 的填充空格、乃至下一帧行,
494
- // 视觉上表现为颜色重影(被截断的着色延伸到行尾之外)。
495
- // 但纯文本输入(activeStyles 为空)不发 reset——\x1b[0m 是全局重置,
496
- // 会清除 theme.bg 施加的外层背景色(背景框内省略号后失去背景的根因)。
497
- return result + activeStyles.join("") + "…" + (activeStyles.length ? "\x1b[0m" : "");
498
- }
499
-
500
- result += grapheme;
501
- currentWidth += graphemeWidth;
502
- }
503
- i = end;
443
+ /** status → 语义颜色 token(用于给任意文本染色,不含符号)。 */
444
+ function statusColorToken(
445
+ status: StatusText,
446
+ ): "success" | "warning" | "error" | "muted" {
447
+ switch (status) {
448
+ case "completed": return "success";
449
+ case "running": return "warning";
450
+ case "failed": case "aborted": return "error";
451
+ default: return "muted";
504
452
  }
453
+ }
505
454
 
506
- // 理论上 visibleWidth 检查已提前返回,此行兜底
507
- return result + activeStyles.join("") + "…" + (activeStyles.length ? "\x1b[0m" : "");
455
+ export function statusDotStr(
456
+ status: StatusText,
457
+ theme: ThemeLike,
458
+ ): string {
459
+ return theme.fg(statusColorToken(status), "●");
508
460
  }
509
461
 
462
+ /** Format a status badge with color for the header area. */
463
+ export function formatStatusBadge(
464
+ status: StatusText,
465
+ theme: ThemeLike,
466
+ ): string {
467
+ switch (status) {
468
+ case "running": return theme.fg("warning", "\u25CF running");
469
+ case "completed": return theme.fg("success", "\u2713 completed");
470
+ case "failed": return theme.fg("error", "\u2717 failed");
471
+ case "aborted": return theme.fg("error", "\u2717 aborted");
472
+ case "budget_limited": return theme.fg("error", "\u26A0 budget");
473
+ case "time_limited": return theme.fg("error", "\u26A0 timeout");
474
+ default: return theme.fg("muted", status);
475
+ }
476
+ }
477
+
478
+ // ── Workflow 时间 / 统计格式化 ────────────────────────────────
479
+
510
480
  /**
511
- * 把长文本按指定可见宽度拆成多行(word-wrap),完整展示不截断。
512
- *
513
- * 用于 detail 模式完整展示长内容(task / output text)。detail 有翻屏能力,
514
- * 不应像预览那样截断成一行省略号——信息完整性优先。
481
+ * Format elapsed time string from startedAt(workflow trace 风格:now 显式参数化,
482
+ * 缺 startedAt 时 "-" 前缀兜底)。秒→字符串形态**单实现**收敛到 formatElapsedSeconds,
483
+ * 经委托构造保证同输入同输出——不再靠注释对齐两份小时分支(post-convergence C4)。
515
484
  *
516
- * 输入为纯文本(不含 ANSI 颜色)。调用方对返回的每行单独着色,
517
- * 这样避免对 ANSI 文本做复杂 wrap(SGR 状态跨行续重应用)。
485
+ * < 1s → "0s"
486
+ * < 60s → "Xs"
487
+ * < 3600s → "XmYs" ("45m30s")
488
+ * ≥ 3600s → "XhYm" ("1h15m")
489
+ */
490
+ export function formatElapsed(startedAt?: string, now: number = Date.now()): string {
491
+ if (!startedAt) return "-";
492
+ const secs = Math.floor((now - new Date(startedAt).getTime()) / MS_PER_SEC);
493
+ // 钳 0:亚秒(floor=0)与时钟回拨(负值)同走 "0s"(原 `ms < MS_PER_SEC` 分支语义)
494
+ return formatElapsedSeconds(Math.max(0, secs));
495
+ }
496
+
497
+ /**
498
+ * Format a live eventLog entry(live 路径 Activity 区用)。
518
499
  *
519
- * grapheme 迭代(CJK/emoji 安全):逐字素累加可见宽度,超 maxWidth 即断行。
520
- * CJK 可在任意字素间断(每个汉字占 2 列但可断),拉丁文不强制保留词边界
521
- * (detail 场景优先完整性,width 足够宽时自然在空格附近断)。
500
+ * tool_start → "→ {label}"
501
+ * tool_end → "← {label}"(done)/ "✗ {label}"(failed)
502
+ * turn_end → "∘ {label}"(turn 摘要)
503
+ * error → "✗ {label}"
522
504
  *
523
- * 原始 \n 保留为段落分隔(split 后每段独立 wrap,空行保留为 "")。
524
- * 与 truncLine 的扁平化不同:wrapText 的产物是多行,\n 是有意的段落边界。
505
+ * 对齐上方 subagents formatEventLine 的视觉风格,但语义域是 workflow trace
506
+ * (前缀符号不同、label 不 sanitize)——与 formatEventLine 是两个概念域的实现,
507
+ * 同文件共存故以 Trace 前缀区分命名。
508
+ */
509
+ export function formatTraceEventLine(entry: AgentEventLogEntry, theme: ThemeLike): string {
510
+ switch (entry.type) {
511
+ case "tool_start":
512
+ return `→ ${entry.label}`;
513
+ case "tool_end":
514
+ return entry.status === "failed"
515
+ ? theme.fg("error", `✗ ${entry.label}`)
516
+ : `✓ ${entry.label}`;
517
+ case "turn_end":
518
+ return theme.fg("dim", `∘ ${entry.label}`);
519
+ case "error":
520
+ return theme.fg("error", `✗ ${entry.label}`);
521
+ default:
522
+ return entry.label;
523
+ }
524
+ }
525
+
526
+ /** Format token + tool call statistics. */
527
+ export function formatTokenStat(
528
+ usage?: { input: number; output: number },
529
+ toolCalls?: ToolCallEntry[],
530
+ elapsed?: string,
531
+ ): string {
532
+ const tokens = usage ? usage.input + usage.output : 0;
533
+ const tools = toolCalls?.length ?? 0;
534
+ const base = `${tokens} tok · ${tools} tool calls`;
535
+ return elapsed ? `${base} · ${elapsed}` : base;
536
+ }
537
+
538
+ /**
539
+ * renderResult 的文本兜底:从 result.content[0] 提取纯文本。
540
+ * 多处 tool 的 renderResult 曾各自内联此逻辑,提取后统一调用。
525
541
  */
526
- export function wrapText(text: string, maxWidth: number): string[] {
527
- if (maxWidth <= 0) return [text];
528
- const result: string[] = [];
529
- for (const para of text.split(/\r?\n/)) {
530
- const trimmed = para.trim();
531
- if (!trimmed) {
532
- result.push("");
533
- continue;
542
+ export function renderTextFallback(
543
+ result: { content?: Array<{ type: string; text?: string }> },
544
+ ): string {
545
+ const first = result.content?.[0];
546
+ return first?.type === "text" ? (first.text ?? "") : "";
547
+ }
548
+
549
+ /** Format a single activity line: ToolName(argsPreview). */
550
+ export function formatActivityLine(entry: ToolCallEntry, maxWidth: number): string {
551
+ // 语义阈值与开销:低于此宽度只显名称;括号占 2 字符 (name)。
552
+ const MIN_ACTIVITY_WIDTH = 10;
553
+ const PARENS_OVERHEAD = 2;
554
+ if (maxWidth < MIN_ACTIVITY_WIDTH) return entry.name;
555
+ const argsBudget = maxWidth - entry.name.length - PARENS_OVERHEAD;
556
+ if (argsBudget <= 0) return truncateToWidth(entry.name, maxWidth);
557
+ const truncated = entry.input.length > argsBudget
558
+ ? entry.input.slice(0, argsBudget - 1) + ELLIPSIS
559
+ : entry.input;
560
+ return `${entry.name}(${truncated})`;
561
+ }
562
+
563
+ // ── Workflow phase group(filters empty phases)──────────────
564
+
565
+ export interface PhaseGroup {
566
+ name: string;
567
+ nodes: ExecutionTraceNode[];
568
+ doneCount: number;
569
+ }
570
+
571
+ /** Group trace nodes by phase. Nodes without phase go to "(no phase)". */
572
+ function groupByPhase(nodes: ExecutionTraceNode[]): Map<string, ExecutionTraceNode[]> {
573
+ const map = new Map<string, ExecutionTraceNode[]>();
574
+ for (const node of nodes) {
575
+ const phase = node.phase || "(default)";
576
+ let arr = map.get(phase);
577
+ if (!arr) {
578
+ arr = [];
579
+ map.set(phase, arr);
534
580
  }
535
- let line = "";
536
- let lineWidth = 0;
537
- for (const seg of segmenter.segment(trimmed)) {
538
- const g = seg.segment;
539
- const gw = visibleWidth(g);
540
- if (lineWidth + gw > maxWidth && line.length > 0) {
541
- result.push(line);
542
- line = g;
543
- lineWidth = gw;
544
- } else {
545
- line += g;
546
- lineWidth += gw;
547
- }
581
+ arr.push(node);
582
+ }
583
+ // Sort within each phase by stepIndex ascending (FR-3.2)
584
+ for (const arr of map.values()) {
585
+ arr.sort((a, b) => a.stepIndex - b.stepIndex);
586
+ }
587
+ return map;
588
+ }
589
+
590
+ /** The fallback phase name when node has no explicit phase. */
591
+ const NO_PHASE = "(default)";
592
+
593
+ /** Build phase groups. Nodes without a phase are placed in an unnamed group. */
594
+ export function buildPhaseGroups(nodes: ExecutionTraceNode[]): PhaseGroup[] {
595
+ const map = groupByPhase(nodes);
596
+ const result: PhaseGroup[] = [];
597
+ for (const [name, phaseNodes] of map) {
598
+ if (phaseNodes.length > 0) {
599
+ result.push({
600
+ name: name === NO_PHASE ? "" : name,
601
+ nodes: phaseNodes,
602
+ doneCount: phaseNodes.filter((n) => n.status === "completed").length,
603
+ });
548
604
  }
549
- if (line.length > 0) result.push(line);
550
605
  }
551
606
  return result;
552
607
  }
608
+
609
+ // ── Workflow sidebar phase line formatter ────────────────────
610
+
611
+ export function formatPhaseLine(
612
+ pg: PhaseGroup,
613
+ idx: number,
614
+ isSelected: boolean,
615
+ theme: ThemeLike,
616
+ maxWidth: number,
617
+ ): string {
618
+ const pointer = isSelected ? "❯ " : " ";
619
+ const dot = statusDotStr(pg.doneCount === pg.nodes.length ? "completed" : "running", theme);
620
+ const name = pg.name || "(unnamed)";
621
+ const label = `${idx + 1} ${name} ${pg.doneCount}/${pg.nodes.length}`;
622
+ // pointer(2) + dot(1) + space(1)
623
+ const PHASE_PREFIX_WIDTH = 4;
624
+ const budget = maxWidth - PHASE_PREFIX_WIDTH;
625
+ const truncated = visibleWidth(label) > budget
626
+ ? truncateToWidth(label, budget - 1) + ELLIPSIS
627
+ : label;
628
+ return `${pointer}${dot} ${truncated}`;
629
+ }
630
+
631
+ // ── Workflow agent one-liner for overview right panel ────────
632
+
633
+ const TOKEN_K = 1000;
634
+
635
+ export function formatAgentOneLiner(node: ExecutionTraceNode, theme: ThemeLike): string {
636
+ const dot = statusDotStr(node.status, theme);
637
+ const elapsed = formatElapsed(
638
+ node.startedAt,
639
+ node.completedAt ? new Date(node.completedAt).getTime() : Date.now(),
640
+ );
641
+ const tok = node.result?.usage;
642
+ const tokStr = tok
643
+ ? `${Math.round((tok.input + tok.output) / TOKEN_K)}k tok`
644
+ : "";
645
+ const tcCount = node.result?.toolCalls?.length ?? 0;
646
+ const parts = [dot, displayAgentName(node.agent), node.model];
647
+ if (tokStr) parts.push(`${tokStr} · ${tcCount} tools`);
648
+ parts.push(elapsed);
649
+ return parts.join(" ");
650
+ }
@@ -12,9 +12,9 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
 
13
13
  // bounded JSON pretty 序列化(IF13/#19,TC5/ES5)已下沉 core shared
14
14
  // (u-core-atomic 逐字平移,输出与原本地实现字节一致;本地实现已删)。
15
- import { boundedPrettySerialize } from "@zhushanwen/subagent-core/shared/bounded-serialize.ts";
15
+ import { boundedPrettySerialize } from "@zhushanwen/subagent-core";
16
16
 
17
- import type { WorkflowRun } from "@zhushanwen/subagent-core/orchestration/models/workflow-run.ts";
17
+ import type { WorkflowRun } from "@zhushanwen/subagent-core";
18
18
  import {
19
19
  guiComponent,
20
20
  type GuiContext,