@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.
- package/README.md +5 -11
- package/package.json +8 -11
- package/skills/workflow-script-format/SKILL.md +32 -13
- package/src/host/__tests__/pi-host.test.ts +23 -2
- package/src/host/pi-host.ts +61 -6
- package/src/index.ts +155 -528
- package/src/injectors/__tests__/engine-awareness.test.ts +5 -3
- package/src/injectors/__tests__/engine-section-stability.test.ts +48 -33
- package/src/injectors/__tests__/model-list-injector.test.ts +18 -21
- package/src/injectors/__tests__/subagent-list-injector.test.ts +54 -14
- package/src/injectors/__tests__/workflow-list-injector.test.ts +26 -12
- package/src/injectors/engine-awareness.ts +80 -8
- package/src/injectors/model-list-injector.ts +15 -58
- package/src/injectors/resource-list-injector.ts +209 -0
- package/src/injectors/subagent-list-injector.ts +53 -224
- package/src/injectors/workflow-list-injector.ts +36 -196
- package/src/interface/__tests__/detectors.test.ts +46 -35
- package/src/interface/__tests__/subagent-tool-path-guard.test.ts +42 -26
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +14 -3
- package/src/interface/__tests__/tool-workflow-run-builtin-name.test.ts +216 -0
- package/src/interface/__tests__/tool-workflow-script-generate.test.ts +17 -6
- package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +38 -2
- package/src/interface/bg-notify-render.ts +7 -17
- package/src/interface/command-actions.ts +5 -15
- package/src/interface/commands.ts +116 -76
- package/src/interface/format.ts +354 -245
- package/src/interface/gui-mappers.ts +18 -22
- package/src/interface/helpers.ts +9 -130
- package/src/interface/list-component.ts +164 -117
- package/src/interface/list-shared.ts +3 -3
- package/src/interface/list-view.ts +130 -73
- package/src/interface/subagent-actions.ts +50 -675
- package/src/interface/subagent-tool-schema.ts +7 -9
- package/src/interface/subagent-tool.ts +5 -4
- package/src/interface/subagents.ts +9 -8
- package/src/interface/tool-render.ts +5 -15
- package/src/interface/tool-workflow-script.ts +34 -75
- package/src/interface/tool-workflow.ts +62 -96
- package/src/interface/tui-kit.ts +305 -0
- package/src/interface/views/WorkflowsView.ts +270 -207
- 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 +144 -228
- package/agents/analyst.md +0 -61
- package/agents/coder.md +0 -70
- package/agents/debugger.md +0 -67
- package/agents/doc-reviewer.md +0 -50
- package/agents/explorer.md +0 -64
- package/agents/general-purpose.md +0 -32
- package/agents/orchestrator.md +0 -63
- package/agents/planner.md +0 -54
- package/agents/researcher.md +0 -65
- package/agents/reviewer.md +0 -74
- package/src/interface/views/format.ts +0 -320
package/src/interface/format.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// src/
|
|
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
|
-
//
|
|
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
|
// ============================================================
|
|
@@ -217,9 +183,8 @@ export function sanitizeLabel(label: string): string {
|
|
|
217
183
|
/**
|
|
218
184
|
* 取文本首个非空行(多行压成首行).
|
|
219
185
|
*
|
|
220
|
-
* 仅做"取首行"——不 sanitize
|
|
221
|
-
* (
|
|
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
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
*
|
|
407
|
-
*
|
|
408
|
-
* 问题:pi-tui 的 truncateToWidth 在省略号前插 `\x1b[0m`(全局 reset),
|
|
409
|
-
* 导致 contentBox 施加的背景色在省略号处断裂.
|
|
428
|
+
* 可显示的状态文本集合。
|
|
410
429
|
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
430
|
+
* 包含 RunStatus("running"|"done" 不直接显示,转 reason)+ DoneReason
|
|
431
|
+
* (completed/failed/aborted/budget_limited/time_limited)+ ExecutionTraceNode.status
|
|
432
|
+
* (含 "pending"——trace 节点的初始态)。
|
|
414
433
|
*
|
|
415
|
-
*
|
|
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
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
496
|
-
|
|
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
|
-
*
|
|
481
|
+
* Format elapsed time string from startedAt(workflow trace 风格:now 显式参数化,
|
|
482
|
+
* 缺 startedAt 时 "-" 前缀兜底)。秒→字符串形态**单实现**收敛到 formatElapsedSeconds,
|
|
483
|
+
* 经委托构造保证同输入同输出——不再靠注释对齐两份小时分支(post-convergence C4)。
|
|
501
484
|
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
|
|
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
|
-
*
|
|
509
|
-
*
|
|
510
|
-
* (
|
|
500
|
+
* tool_start → "→ {label}"
|
|
501
|
+
* tool_end → "← {label}"(done)/ "✗ {label}"(failed)
|
|
502
|
+
* turn_end → "∘ {label}"(turn 摘要)
|
|
503
|
+
* error → "✗ {label}"
|
|
511
504
|
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
505
|
+
* 对齐上方 subagents formatEventLine 的视觉风格,但语义域是 workflow trace
|
|
506
|
+
* (前缀符号不同、label 不 sanitize)——与 formatEventLine 是两个概念域的实现,
|
|
507
|
+
* 同文件共存故以 Trace 前缀区分命名。
|
|
514
508
|
*/
|
|
515
|
-
export function
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
+
}
|