@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.
- package/package.json +8 -8
- package/src/host/pi-host.ts +5 -4
- package/src/index.ts +145 -464
- package/src/injectors/__tests__/engine-awareness.test.ts +4 -2
- package/src/injectors/__tests__/engine-section-stability.test.ts +42 -29
- package/src/injectors/engine-awareness.ts +80 -4
- package/src/injectors/resource-list-injector.ts +209 -0
- package/src/injectors/subagent-list-injector.ts +30 -143
- package/src/injectors/workflow-list-injector.ts +23 -150
- package/src/interface/__tests__/detectors.test.ts +1 -1
- package/src/interface/__tests__/subagent-tool-path-guard.test.ts +42 -26
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +10 -2
- package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +1 -1
- package/src/interface/bg-notify-render.ts +4 -4
- package/src/interface/commands.ts +115 -75
- package/src/interface/format.ts +339 -241
- package/src/interface/helpers.ts +2 -2
- package/src/interface/list-component.ts +163 -116
- package/src/interface/list-shared.ts +2 -2
- package/src/interface/list-view.ts +129 -72
- package/src/interface/subagent-actions.ts +5 -5
- package/src/interface/subagent-tool-schema.ts +6 -5
- package/src/interface/subagent-tool.ts +4 -3
- package/src/interface/subagents.ts +8 -7
- package/src/interface/tool-render.ts +2 -2
- package/src/interface/tool-workflow-script.ts +15 -14
- package/src/interface/tool-workflow.ts +14 -11
- package/src/interface/tui-kit.ts +305 -0
- package/src/interface/views/WorkflowsView.ts +267 -196
- package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +3 -3
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +7 -4
- package/src/interface/views/detail-content.ts +16 -14
- package/src/interface/views/view-constants.ts +19 -0
- package/src/jsonl-run-store.ts +11 -7
- package/src/interface/views/format.ts +0 -281
package/src/interface/format.ts
CHANGED
|
@@ -6,16 +6,28 @@
|
|
|
6
6
|
// `·` 同级并列字段/thinking 图标;`()` 元数据分组;`›` 工具;`>` 输出;`·` thinking.
|
|
7
7
|
// 禁用 `│` 做 stats 分隔、`├─`/`└─` 做 eventLog 前缀.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
18
|
-
import { DEFAULT_AGENT_NAME } from "@zhushanwen/subagent-core
|
|
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
|
|
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
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
*
|
|
428
|
+
* 可显示的状态文本集合。
|
|
418
429
|
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
430
|
+
* 包含 RunStatus("running"|"done" 不直接显示,转 reason)+ DoneReason
|
|
431
|
+
* (completed/failed/aborted/budget_limited/time_limited)+ ExecutionTraceNode.status
|
|
432
|
+
* (含 "pending"——trace 节点的初始态)。
|
|
421
433
|
*
|
|
422
|
-
*
|
|
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
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
507
|
-
|
|
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
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
* 不应像预览那样截断成一行省略号——信息完整性优先。
|
|
481
|
+
* Format elapsed time string from startedAt(workflow trace 风格:now 显式参数化,
|
|
482
|
+
* 缺 startedAt 时 "-" 前缀兜底)。秒→字符串形态**单实现**收敛到 formatElapsedSeconds,
|
|
483
|
+
* 经委托构造保证同输入同输出——不再靠注释对齐两份小时分支(post-convergence C4)。
|
|
515
484
|
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
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
|
-
*
|
|
520
|
-
*
|
|
521
|
-
* (
|
|
500
|
+
* tool_start → "→ {label}"
|
|
501
|
+
* tool_end → "← {label}"(done)/ "✗ {label}"(failed)
|
|
502
|
+
* turn_end → "∘ {label}"(turn 摘要)
|
|
503
|
+
* error → "✗ {label}"
|
|
522
504
|
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
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
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
+
}
|
package/src/interface/helpers.ts
CHANGED
|
@@ -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
|
|
15
|
+
import { boundedPrettySerialize } from "@zhushanwen/subagent-core";
|
|
16
16
|
|
|
17
|
-
import type { WorkflowRun } from "@zhushanwen/subagent-core
|
|
17
|
+
import type { WorkflowRun } from "@zhushanwen/subagent-core";
|
|
18
18
|
import {
|
|
19
19
|
guiComponent,
|
|
20
20
|
type GuiContext,
|