@zhushanwen/pi-subagent-workflow 8.7.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 (56) hide show
  1. package/README.md +5 -11
  2. package/package.json +8 -11
  3. package/skills/workflow-script-format/SKILL.md +32 -13
  4. package/src/host/__tests__/pi-host.test.ts +23 -2
  5. package/src/host/pi-host.ts +61 -6
  6. package/src/index.ts +155 -528
  7. package/src/injectors/__tests__/engine-awareness.test.ts +5 -3
  8. package/src/injectors/__tests__/engine-section-stability.test.ts +48 -33
  9. package/src/injectors/__tests__/model-list-injector.test.ts +18 -21
  10. package/src/injectors/__tests__/subagent-list-injector.test.ts +54 -14
  11. package/src/injectors/__tests__/workflow-list-injector.test.ts +26 -12
  12. package/src/injectors/engine-awareness.ts +80 -8
  13. package/src/injectors/model-list-injector.ts +15 -58
  14. package/src/injectors/resource-list-injector.ts +209 -0
  15. package/src/injectors/subagent-list-injector.ts +53 -224
  16. package/src/injectors/workflow-list-injector.ts +36 -196
  17. package/src/interface/__tests__/detectors.test.ts +46 -35
  18. package/src/interface/__tests__/subagent-tool-path-guard.test.ts +42 -26
  19. package/src/interface/__tests__/subagent-tool-prompt.test.ts +14 -3
  20. package/src/interface/__tests__/tool-workflow-run-builtin-name.test.ts +216 -0
  21. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +17 -6
  22. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +38 -2
  23. package/src/interface/bg-notify-render.ts +7 -17
  24. package/src/interface/command-actions.ts +5 -15
  25. package/src/interface/commands.ts +116 -76
  26. package/src/interface/format.ts +354 -245
  27. package/src/interface/gui-mappers.ts +18 -22
  28. package/src/interface/helpers.ts +9 -130
  29. package/src/interface/list-component.ts +164 -117
  30. package/src/interface/list-shared.ts +3 -3
  31. package/src/interface/list-view.ts +130 -73
  32. package/src/interface/subagent-actions.ts +50 -675
  33. package/src/interface/subagent-tool-schema.ts +7 -9
  34. package/src/interface/subagent-tool.ts +5 -4
  35. package/src/interface/subagents.ts +9 -8
  36. package/src/interface/tool-render.ts +5 -15
  37. package/src/interface/tool-workflow-script.ts +34 -75
  38. package/src/interface/tool-workflow.ts +62 -96
  39. package/src/interface/tui-kit.ts +305 -0
  40. package/src/interface/views/WorkflowsView.ts +270 -207
  41. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +3 -3
  42. package/src/interface/views/__tests__/detail-content-session-file.test.ts +7 -4
  43. package/src/interface/views/detail-content.ts +16 -14
  44. package/src/interface/views/view-constants.ts +19 -0
  45. package/src/jsonl-run-store.ts +144 -228
  46. package/agents/analyst.md +0 -61
  47. package/agents/coder.md +0 -70
  48. package/agents/debugger.md +0 -67
  49. package/agents/doc-reviewer.md +0 -50
  50. package/agents/explorer.md +0 -64
  51. package/agents/general-purpose.md +0 -32
  52. package/agents/orchestrator.md +0 -63
  53. package/agents/planner.md +0 -54
  54. package/agents/researcher.md +0 -65
  55. package/agents/reviewer.md +0 -74
  56. package/src/interface/views/format.ts +0 -320
@@ -1,4 +1,4 @@
1
- // src/tui/format.ts
1
+ // src/interface/format.ts
2
2
  //
3
3
  // 纯格式化函数.零 Pi 依赖、零 runtime 依赖,可单测.
4
4
  //
@@ -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
  // ============================================================
@@ -217,9 +183,8 @@ export function sanitizeLabel(label: string): string {
217
183
  /**
218
184
  * 取文本首个非空行(多行压成首行).
219
185
  *
220
- * 仅做"取首行"——不 sanitize.三处调用方的 sanitize 末步不同
221
- * (tool-render 调 sanitizeLabel、bg-notify-render 压 \r\t、list-view 不处理),
222
- * 故共享此基础函数,各自按需 wrap.
186
+ * 仅做"取首行"——不 sanitize.需要压平残余 \r/\t 的调用方用 firstLineSanitized
187
+ * (下方);list-component 直接消费本函数(标题/错误行由外层 truncLine 再压平).
223
188
  *
224
189
  * firstLine("a\nb\nc") → "a"
225
190
  * firstLine("\n\nb") → "b"
@@ -230,6 +195,18 @@ export function firstLine(text?: string): string {
230
195
  return text.split("\n").find((l) => l.trim())?.trim() ?? "";
231
196
  }
232
197
 
198
+ /**
199
+ * firstLine + sanitize 组合:取首个非空行后压平残余 \r/\t.
200
+ *
201
+ * tool-render(content 错误回显)与 bg-notify-render(结果/错误正文)共用,
202
+ * 取代两侧各自维护的 wrapper.tool-render 旧版走 sanitizeLabel(\t→两空格)、
203
+ * bg-notify-render 旧版压 \r\t 为单空格——差异无测试钉住,统一取
204
+ * sanitizeLabel 口径(与 eventLog 行处理一致).
205
+ */
206
+ export function firstLineSanitized(text?: string): string {
207
+ return sanitizeLabel(firstLine(text));
208
+ }
209
+
233
210
  /**
234
211
  * 从 renderCall/execute 的 unknown args 安全提取 agent 名.
235
212
  * 类型守卫窄化(替代 `as { agent?: string }` 全可选断言).
@@ -292,78 +269,120 @@ export function formatEventLine(entry: AgentEventLogEntry, theme: ThemeLike): st
292
269
  }
293
270
  }
294
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
+
295
368
  /**
296
369
  * [STEP3] 格式化单个 toolCall 为展示行(对齐 nicobailon formatToolCall)。
297
370
  *
298
- * 返回不含前缀(`→ ` 由调用方加)。根据 toolName 提取关键参数格式化:
371
+ * 返回不含前缀(`→ ` 由调用方加)。根据 toolName 查表提取关键参数格式化:
299
372
  * bash → `$ <command 预览>`
300
373
  * read → `read <~路径:offset-limit>`
301
374
  * edit/write → `<op> <~路径>`
302
375
  * grep/find/ls → 对应格式
303
- * default → `<toolName> <argsJSON 预览>`
376
+ * 未命中(含 default)→ `<toolName> <argsJSON 预览>`
304
377
  */
305
378
  export function formatToolCall(
306
379
  toolName: string,
307
380
  args: Record<string, unknown>,
308
381
  theme: ThemeLike,
309
382
  ): string {
310
- const shortenPath = (p: string): string => {
311
- // 仅用于显示层路径缩写(~ 替换 home 前缀),不读取 pi 目录(TC9 合法命中)
312
- const home = os.homedir();
313
- return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
314
- };
315
-
316
- switch (toolName) {
317
- case "bash": {
318
- const command = (args.command as string) || "...";
319
- const preview = command.length > BASH_PREVIEW_MAX_CHARS ? `${command.slice(0, BASH_PREVIEW_MAX_CHARS)}...` : command;
320
- return theme.fg("muted", "$ ") + theme.fg("toolOutput", preview);
321
- }
322
- case "read": {
323
- const rawPath = (args.file_path || args.path || "...") as string;
324
- const filePath = shortenPath(rawPath);
325
- const offset = args.offset as number | undefined;
326
- const limit = args.limit as number | undefined;
327
- let text = theme.fg("accent", filePath);
328
- if (offset !== undefined || limit !== undefined) {
329
- const startLine = offset ?? 1;
330
- const endLine = limit !== undefined ? startLine + limit - 1 : "";
331
- text += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
332
- }
333
- return theme.fg("muted", "read ") + text;
334
- }
335
- case "write": {
336
- const rawPath = (args.file_path || args.path || "...") as string;
337
- const content = (args.content || "") as string;
338
- const lines = content.split("\n").length;
339
- let text = theme.fg("muted", "write ") + theme.fg("accent", shortenPath(rawPath));
340
- if (lines > 1) text += theme.fg("dim", ` (${lines} lines)`);
341
- return text;
342
- }
343
- case "edit": {
344
- const rawPath = (args.file_path || args.path || "...") as string;
345
- return theme.fg("muted", "edit ") + theme.fg("accent", shortenPath(rawPath));
346
- }
347
- case "ls": {
348
- const rawPath = (args.path || ".") as string;
349
- return theme.fg("muted", "ls ") + theme.fg("accent", shortenPath(rawPath));
350
- }
351
- case "find": {
352
- const pattern = (args.pattern || "*") as string;
353
- const rawPath = (args.path || ".") as string;
354
- return theme.fg("muted", "find ") + theme.fg("accent", pattern) + theme.fg("dim", ` in ${shortenPath(rawPath)}`);
355
- }
356
- case "grep": {
357
- const pattern = (args.pattern || "") as string;
358
- const rawPath = (args.path || ".") as string;
359
- return theme.fg("muted", "grep ") + theme.fg("accent", `/${pattern}/`) + theme.fg("dim", ` in ${shortenPath(rawPath)}`);
360
- }
361
- default: {
362
- const argsStr = JSON.stringify(args);
363
- const preview = argsStr.length > ARGS_PREVIEW_MAX_CHARS ? `${argsStr.slice(0, ARGS_PREVIEW_MAX_CHARS)}...` : argsStr;
364
- return theme.fg("accent", toolName) + theme.fg("dim", ` ${preview}`);
365
- }
366
- }
383
+ const formatter = TOOL_CALL_FORMATTERS.get(toolName);
384
+ if (formatter) return formatter(args, theme);
385
+ return formatUnknownToolCall(toolName, args, theme);
367
386
  }
368
387
 
369
388
  /**
@@ -385,157 +404,247 @@ export function formatDisplayItem(item: DisplayItem, theme: ThemeLike): string {
385
404
  }
386
405
 
387
406
  // ============================================================
388
- // 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 复用)。
389
413
  // ============================================================
390
414
 
391
- /**
392
- * SGR 序列 sticky regex(truncLine 专用)。
393
- *
394
- * lastIndex 置位后 exec/test 只在该位置匹配(等价 `flat.slice(pos).match(/^\x1b\[[0-9;]*m/)`,
395
- * 但零子串分配)。同步代码内「设 lastIndex → 立即用」,无跨调用残留。
396
- */
397
- const SGR_STICKY_RE = /\x1b\[[0-9;]*m/y;
415
+ // ── Workflow view 布局常量 ────────────────────────────────────
398
416
 
399
- /** 判断 s 在 pos 处是否开始一个 SGR 序列(非 SGR ESC——OSC/非 SGR CSI/裸 ESC——返回 false)。 */
400
- function isSgrStart(s: string, pos: number): boolean {
401
- SGR_STICKY_RE.lastIndex = pos;
402
- return SGR_STICKY_RE.test(s);
403
- }
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;
404
426
 
405
427
  /**
406
- * 截断文本到 maxWidth 可见宽度(带省略号 `…`,ANSI 安全).
407
- *
408
- * 问题:pi-tui 的 truncateToWidth 在省略号前插 `\x1b[0m`(全局 reset),
409
- * 导致 contentBox 施加的背景色在省略号处断裂.
428
+ * 可显示的状态文本集合。
410
429
  *
411
- * 解决:遍历追踪 active SGR styles,遇 `\x1b[0m` 清空、遇其他 `\x1b[..m` push,
412
- * 截断时 `result + activeStyles.join("") + "…"`——重应用 active 样式,背景不断裂.
413
- * 用 Intl.Segmenter grapheme 切分,正确处理 emoji/CJK/组合字符.
430
+ * 包含 RunStatus("running"|"done" 不直接显示,转 reason)+ DoneReason
431
+ * (completed/failed/aborted/budget_limited/time_limited)+ ExecutionTraceNode.status
432
+ * (含 "pending"——trace 节点的初始态)。
414
433
  *
415
- * 性能(IF12/#18):旧实现逐字符 `flat.slice(end).match(...)` 是 O(n²)(每字符
416
- * 一次子串分配 + 正则扫描);现改为 `indexOf("\x1b", ...)` 跳到下一 ESC,仅在 ESC
417
- * 位置做一次 sticky 判定——纯文本段零分配零正则。
418
- *
419
- * **非 SGR ESC 行为(等价关键,逐字节等价的定义点)**:OSC(\x1b]0;..\x07)、
420
- * 非 SGR CSI(\x1b[K)、裸 \x1b 后跟非 [ 字符——均不构成文本段边界,作为纯文本
421
- * 并入 textPortion 被 segmenter 消费(占 currentWidth、进 result),与旧实现
422
- * 「while 停点集合 = SGR 序列起点」完全一致。输出由 __fixtures__/truncline.snapshot.json
423
- * (改造前实现生成)逐字节锚定。
424
- *
425
- * 移植自 pi-subagents render.ts:44-89.
434
+ * 收窄自 string → 显式联合,编译器会在新增 status 时强制 switch 补齐分支。
426
435
  */
427
- export function truncLine(text: string, maxWidth: number): string {
428
- if (maxWidth <= 0) return "";
429
- // 剥离换行符(\n / \r):text 可能含多行 prompt / turn.text,\n 不占可见宽度
430
- // 但在终端会换行,导致单行渲染意外变成多行,破坏行对齐(list overlay 左右列错位、
431
- // tool block 行数跳变)。单行渲染入口必须保证无 \n。用空格替代(保留词边界可读性)。
432
- const flat = text.replace(/[\r\n]+/g, " ");
433
- if (visibleWidth(flat) <= maxWidth) return flat;
434
-
435
- const targetWidth = Math.max(0, maxWidth - 1);
436
- let result = "";
437
- let currentWidth = 0;
438
- let activeStyles: string[] = [];
439
- let i = 0;
440
-
441
- while (i < flat.length) {
442
- // 捕获 ANSI SGR 序列(sticky:只在 i 处匹配,等价旧 slice(i).match(/^.../m))
443
- SGR_STICKY_RE.lastIndex = i;
444
- const ansiMatch = SGR_STICKY_RE.exec(flat);
445
- if (ansiMatch) {
446
- const code = ansiMatch[0];
447
- result += code;
448
-
449
- if (code === "\x1b[0m" || code === "\x1b[m") {
450
- activeStyles = []; // reset → 清空栈
451
- } else {
452
- activeStyles.push(code);
453
- }
454
- i += code.length;
455
- continue;
456
- }
436
+ export type StatusText =
437
+ | RunStatus
438
+ | DoneReason
439
+ | "pending";
457
440
 
458
- // 找到下一段纯文本(非 ANSI)的边界:下一处 SGR 序列起点(或串尾)。
459
- // indexOf 跳到 ESC 候选位,仅对 ESC 位置做一次 sticky 判定;非 SGR ESC
460
- // (OSC/\x1b[K/裸 \x1b)不构成边界、并入文本段(end 推进到该 ESC 之后继续找),
461
- // 与旧逐字符 while 的停点集合逐字节一致。
462
- let end = flat.length;
463
- for (
464
- let escPos = flat.indexOf("\x1b", i);
465
- escPos !== -1;
466
- escPos = flat.indexOf("\x1b", escPos + 1)
467
- ) {
468
- if (isSgrStart(flat, escPos)) {
469
- end = escPos;
470
- break;
471
- }
472
- }
473
-
474
- // 按 grapheme 迭代这段文本,累加到 targetWidth
475
- const textPortion = flat.slice(i, end);
476
- for (const seg of segmenter.segment(textPortion)) {
477
- const grapheme = seg.segment;
478
- const graphemeWidth = visibleWidth(grapheme);
479
-
480
- if (currentWidth + graphemeWidth > targetWidth) {
481
- // 截断:重应用 active 样式 + 省略号 + reset。
482
- // reset 不可省——否则行尾颜色渗透到 padToVisible 的填充空格、乃至下一帧行,
483
- // 视觉上表现为颜色重影(被截断的着色延伸到行尾之外)。
484
- // 但纯文本输入(activeStyles 为空)不发 reset——\x1b[0m 是全局重置,
485
- // 会清除 theme.bg 施加的外层背景色(背景框内省略号后失去背景的根因)。
486
- return result + activeStyles.join("") + "…" + (activeStyles.length ? "\x1b[0m" : "");
487
- }
441
+ // ── Workflow status helpers ──────────────────────────────────
488
442
 
489
- result += grapheme;
490
- currentWidth += graphemeWidth;
491
- }
492
- 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";
493
452
  }
453
+ }
494
454
 
495
- // 理论上 visibleWidth 检查已提前返回,此行兜底
496
- 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), "●");
460
+ }
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
+ }
497
476
  }
498
477
 
478
+ // ── Workflow 时间 / 统计格式化 ────────────────────────────────
479
+
499
480
  /**
500
- * 把长文本按指定可见宽度拆成多行(word-wrap),完整展示不截断。
481
+ * Format elapsed time string from startedAt(workflow trace 风格:now 显式参数化,
482
+ * 缺 startedAt 时 "-" 前缀兜底)。秒→字符串形态**单实现**收敛到 formatElapsedSeconds,
483
+ * 经委托构造保证同输入同输出——不再靠注释对齐两份小时分支(post-convergence C4)。
501
484
  *
502
- * 用于 detail 模式完整展示长内容(task / output text)。detail 有翻屏能力,
503
- * 不应像预览那样截断成一行省略号——信息完整性优先。
504
- *
505
- * 输入为纯文本(不含 ANSI 颜色)。调用方对返回的每行单独着色,
506
- * 这样避免对 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 区用)。
507
499
  *
508
- * grapheme 迭代(CJK/emoji 安全):逐字素累加可见宽度,超 maxWidth 即断行。
509
- * CJK 可在任意字素间断(每个汉字占 2 列但可断),拉丁文不强制保留词边界
510
- * (detail 场景优先完整性,width 足够宽时自然在空格附近断)。
500
+ * tool_start → "→ {label}"
501
+ * tool_end → "← {label}"(done)/ "✗ {label}"(failed)
502
+ * turn_end → "∘ {label}"(turn 摘要)
503
+ * error → "✗ {label}"
511
504
  *
512
- * 原始 \n 保留为段落分隔(split 后每段独立 wrap,空行保留为 "")。
513
- * 与 truncLine 的扁平化不同:wrapText 的产物是多行,\n 是有意的段落边界。
505
+ * 对齐上方 subagents formatEventLine 的视觉风格,但语义域是 workflow trace
506
+ * (前缀符号不同、label 不 sanitize)——与 formatEventLine 是两个概念域的实现,
507
+ * 同文件共存故以 Trace 前缀区分命名。
514
508
  */
515
- export function wrapText(text: string, maxWidth: number): string[] {
516
- if (maxWidth <= 0) return [text];
517
- const result: string[] = [];
518
- for (const para of text.split(/\r?\n/)) {
519
- const trimmed = para.trim();
520
- if (!trimmed) {
521
- result.push("");
522
- continue;
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 曾各自内联此逻辑,提取后统一调用。
541
+ */
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);
523
580
  }
524
- let line = "";
525
- let lineWidth = 0;
526
- for (const seg of segmenter.segment(trimmed)) {
527
- const g = seg.segment;
528
- const gw = visibleWidth(g);
529
- if (lineWidth + gw > maxWidth && line.length > 0) {
530
- result.push(line);
531
- line = g;
532
- lineWidth = gw;
533
- } else {
534
- line += g;
535
- lineWidth += gw;
536
- }
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
+ });
537
604
  }
538
- if (line.length > 0) result.push(line);
539
605
  }
540
606
  return result;
541
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
+ }