@zhushanwen/pi-subagent-workflow 0.1.0
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/agents/context-builder.md +17 -0
- package/agents/general-purpose.md +16 -0
- package/agents/oracle.md +17 -0
- package/agents/planner.md +17 -0
- package/agents/researcher.md +17 -0
- package/agents/reviewer.md +17 -0
- package/agents/scout.md +17 -0
- package/agents/worker.md +16 -0
- package/examples/README.md +43 -0
- package/examples/chain.example.js +92 -0
- package/examples/map-reduce.example.js +99 -0
- package/examples/parallel.example.js +82 -0
- package/examples/scatter-gather.example.js +106 -0
- package/index.ts +1 -0
- package/package.json +66 -0
- package/skills/workflow-script-format/SKILL.md +328 -0
- package/src/execution/__tests__/agent-registry.test.ts +164 -0
- package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
- package/src/execution/__tests__/alive-store.test.ts +147 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
- package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
- package/src/execution/__tests__/config.test.ts +110 -0
- package/src/execution/__tests__/crash-recovery.test.ts +311 -0
- package/src/execution/__tests__/execute-nesting.test.ts +359 -0
- package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
- package/src/execution/__tests__/execution-record.test.ts +959 -0
- package/src/execution/__tests__/finalized-marker.test.ts +82 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
- package/src/execution/__tests__/format.test.ts +320 -0
- package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
- package/src/execution/__tests__/list-component.test.ts +347 -0
- package/src/execution/__tests__/model-resolver.test.ts +356 -0
- package/src/execution/__tests__/output-collector.test.ts +61 -0
- package/src/execution/__tests__/path-encoding.test.ts +75 -0
- package/src/execution/__tests__/pi-invocation.test.ts +73 -0
- package/src/execution/__tests__/record-store.test.ts +545 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
- package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
- package/src/execution/__tests__/sdk-contract.test.ts +272 -0
- package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
- package/src/execution/__tests__/session-file-gc.test.ts +247 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
- package/src/execution/__tests__/spawn-args.test.ts +244 -0
- package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
- package/src/execution/__tests__/subagent-service.test.ts +678 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
- package/src/execution/__tests__/temp-prompt.test.ts +53 -0
- package/src/execution/__tests__/timeout-integration.test.ts +381 -0
- package/src/execution/__tests__/tombstone-store.test.ts +73 -0
- package/src/execution/__tests__/tool-action.test.ts +330 -0
- package/src/execution/__tests__/turn-limiter.test.ts +65 -0
- package/src/execution/__tests__/worktree-manager.test.ts +423 -0
- package/src/execution/__tests__/worktree-registry.test.ts +161 -0
- package/src/execution/agent-registry.ts +252 -0
- package/src/execution/agent-result-mapper.ts +84 -0
- package/src/execution/alive-store.ts +92 -0
- package/src/execution/best-effort.ts +30 -0
- package/src/execution/concurrency-pool.ts +84 -0
- package/src/execution/config.ts +73 -0
- package/src/execution/execute-options-mapper.ts +86 -0
- package/src/execution/execution-record.ts +778 -0
- package/src/execution/finalized-marker.ts +51 -0
- package/src/execution/model-config-service.ts +225 -0
- package/src/execution/model-resolver.ts +247 -0
- package/src/execution/notifier.ts +168 -0
- package/src/execution/output-collector.ts +88 -0
- package/src/execution/path-encoding.ts +34 -0
- package/src/execution/pi-invocation.ts +70 -0
- package/src/execution/record-store.ts +350 -0
- package/src/execution/session-context-resolver.ts +64 -0
- package/src/execution/session-file-gc.ts +98 -0
- package/src/execution/session-reconstructor.ts +450 -0
- package/src/execution/session-runner.ts +725 -0
- package/src/execution/spawn-event-adapter.ts +150 -0
- package/src/execution/subagent-service.ts +973 -0
- package/src/execution/subprocess-agent-runner.ts +108 -0
- package/src/execution/temp-prompt.ts +57 -0
- package/src/execution/tombstone-store.ts +72 -0
- package/src/execution/turn-limiter.ts +88 -0
- package/src/execution/types.ts +634 -0
- package/src/execution/worktree-manager.ts +285 -0
- package/src/execution/worktree-registry.ts +144 -0
- package/src/index.ts +454 -0
- package/src/interface/bg-notify-render.ts +286 -0
- package/src/interface/commands.ts +157 -0
- package/src/interface/format.ts +501 -0
- package/src/interface/gui-adapter.ts +136 -0
- package/src/interface/helpers.ts +110 -0
- package/src/interface/list-component.ts +643 -0
- package/src/interface/list-shared.ts +84 -0
- package/src/interface/list-view.ts +373 -0
- package/src/interface/reentry-guard.ts +30 -0
- package/src/interface/subagent-actions.ts +294 -0
- package/src/interface/subagent-tool.ts +294 -0
- package/src/interface/subagents.ts +30 -0
- package/src/interface/tool-render.ts +333 -0
- package/src/interface/tool-workflow-script.ts +351 -0
- package/src/interface/tool-workflow.ts +485 -0
- package/src/interface/views/WorkflowsView.ts +944 -0
- package/src/interface/views/detail-content.ts +298 -0
- package/src/interface/views/format.ts +320 -0
- package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
- package/src/orchestration/__tests__/config-loader.test.ts +381 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
- package/src/orchestration/__tests__/script-lint.test.ts +347 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
- package/src/orchestration/agent-opts-resolver.ts +128 -0
- package/src/orchestration/concurrency-gate.ts +69 -0
- package/src/orchestration/config-loader.ts +313 -0
- package/src/orchestration/error-recovery.ts +578 -0
- package/src/orchestration/execute-agent-call.ts +174 -0
- package/src/orchestration/jsonl-run-store.ts +292 -0
- package/src/orchestration/launcher.ts +368 -0
- package/src/orchestration/lifecycle.ts +373 -0
- package/src/orchestration/models/__tests__/budget.test.ts +367 -0
- package/src/orchestration/models/agent-call.ts +76 -0
- package/src/orchestration/models/budget.ts +148 -0
- package/src/orchestration/models/ports.ts +165 -0
- package/src/orchestration/models/run-runtime.ts +91 -0
- package/src/orchestration/models/run-spec.ts +54 -0
- package/src/orchestration/models/run-state.ts +44 -0
- package/src/orchestration/models/trace.ts +102 -0
- package/src/orchestration/models/types.ts +242 -0
- package/src/orchestration/models/workflow-run.ts +275 -0
- package/src/orchestration/models/workflow-script-registry.ts +32 -0
- package/src/orchestration/models/workflow-script.ts +90 -0
- package/src/orchestration/node-ops.ts +192 -0
- package/src/orchestration/script-lint.ts +387 -0
- package/src/orchestration/skill-discovery.ts +60 -0
- package/src/orchestration/worker-handle.ts +115 -0
- package/src/orchestration/worker-host.ts +93 -0
- package/src/orchestration/worker-script-builder.ts +281 -0
- package/src/orchestration/workflow-files.ts +85 -0
- package/src/orchestration/workflow-script-registry-impl.ts +128 -0
- package/src/shared/__tests__/resource-discovery.test.ts +226 -0
- package/src/shared/agent-event.ts +13 -0
- package/src/shared/resource-discovery.ts +535 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// src/tui/format.ts
|
|
2
|
+
//
|
|
3
|
+
// 纯格式化函数.零 Pi 依赖、零 runtime 依赖,可单测.
|
|
4
|
+
//
|
|
5
|
+
// 分隔符语义体系(tui-format.md §1,impeccable 审查裁定):
|
|
6
|
+
// `·` 同级并列字段/thinking 图标;`()` 元数据分组;`›` 工具;`>` 输出;`·` thinking.
|
|
7
|
+
// 禁用 `│` 做 stats 分隔、`├─`/`└─` 做 eventLog 前缀.
|
|
8
|
+
//
|
|
9
|
+
// 截断(tui-format.md §5):truncLine 是 ANSI 安全的——追踪 active SGR,省略号前重应用,
|
|
10
|
+
// 否则背景色在省略号处断裂(contentBox 的 applyBg 被 `\x1b[0m` 抹掉).
|
|
11
|
+
// 移植自 pi-subagents render.ts:44-89.
|
|
12
|
+
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
|
|
15
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
16
|
+
|
|
17
|
+
import type { AgentEventLogEntry, DisplayItem, ExecutionStatus } from "../execution/types.ts";
|
|
18
|
+
import { DEFAULT_AGENT_NAME } from "../execution/types.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* ThemeLike:TUI 语义 token 着色接口(duck-typed,兼容 Pi Theme).
|
|
22
|
+
*
|
|
23
|
+
* 注意:Pi Theme **没有 `dim` 方法**——"dim" 是颜色 token,走 `fg("dim", text)`.
|
|
24
|
+
* 故本接口只声明 fg/bg/bold/underline,dim 文本一律 `fg("dim", ...)`.
|
|
25
|
+
*/
|
|
26
|
+
export interface ThemeLike {
|
|
27
|
+
bg(color: string, text: string): string;
|
|
28
|
+
fg(color: string, text: string): string;
|
|
29
|
+
bold(text: string): string;
|
|
30
|
+
underline(text: string): string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ============================================================
|
|
34
|
+
// 模块级常量(复用,勿在热路径 new)
|
|
35
|
+
// ============================================================
|
|
36
|
+
|
|
37
|
+
/** spinner 帧序列(Braille),seed-frame 驱动,不用 setInterval. */
|
|
38
|
+
const RUNNING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
39
|
+
|
|
40
|
+
/** grapheme 切分器(Unicode/emoji 安全).模块级共享,勿热路径 new. */
|
|
41
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
42
|
+
|
|
43
|
+
// formatTokens 阈值(三段式,与 demo 一致)
|
|
44
|
+
/** < 此值显示原值. */
|
|
45
|
+
const TOKEN_PLAIN_MAX = 1000;
|
|
46
|
+
/** < 此值显示 "N.Nk";≥ 此值显示 "Nk"(四舍五入). */
|
|
47
|
+
const TOKEN_DECIMAL_K_MAX = 10000;
|
|
48
|
+
|
|
49
|
+
// formatElapsedSeconds 阈值
|
|
50
|
+
const SECS_PER_MINUTE = 60;
|
|
51
|
+
const SECS_PER_HOUR = 3600;
|
|
52
|
+
|
|
53
|
+
// ============================================================
|
|
54
|
+
// Token / 时长格式化
|
|
55
|
+
// ============================================================
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 格式化 token 数(三段式,与 demo 一致).
|
|
59
|
+
*
|
|
60
|
+
* < 1000 → 原值("820")
|
|
61
|
+
* < 10000 → "N.Nk"(8200 → "8.2k")
|
|
62
|
+
* ≥ 10000 → "Nk" 四舍五入(23000 → "23k")
|
|
63
|
+
*/
|
|
64
|
+
export function formatTokens(n: number): string {
|
|
65
|
+
if (n < TOKEN_PLAIN_MAX) return String(n);
|
|
66
|
+
if (n < TOKEN_DECIMAL_K_MAX) return `${(n / TOKEN_PLAIN_MAX).toFixed(1)}k`;
|
|
67
|
+
return `${Math.round(n / TOKEN_PLAIN_MAX)}k`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 格式化整数秒时长(对话流 block + list overlay 共用).
|
|
72
|
+
* 数据源 details.elapsedSeconds 已是 Math.floor 过的整数秒.
|
|
73
|
+
*
|
|
74
|
+
* < 60 → "Xs"(12 → "12s")
|
|
75
|
+
* < 3600 → "Xm Ys"(72 → "1m12s")
|
|
76
|
+
* ≥ 3600 → "Xh Ym"
|
|
77
|
+
*/
|
|
78
|
+
export function formatElapsedSeconds(seconds: number): string {
|
|
79
|
+
if (seconds < SECS_PER_MINUTE) return `${seconds}s`;
|
|
80
|
+
if (seconds < SECS_PER_HOUR) {
|
|
81
|
+
const m = Math.floor(seconds / SECS_PER_MINUTE);
|
|
82
|
+
const s = seconds % SECS_PER_MINUTE;
|
|
83
|
+
return `${m}m${s}s`;
|
|
84
|
+
}
|
|
85
|
+
const h = Math.floor(seconds / SECS_PER_HOUR);
|
|
86
|
+
const m = Math.floor((seconds % SECS_PER_HOUR) / SECS_PER_MINUTE);
|
|
87
|
+
return `${h}h${m}m`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** sync id 的段数(run-${seq})。≤ 此值原样返回。 */
|
|
91
|
+
const SHORT_ID_SYNC_SEGMENTS = 2;
|
|
92
|
+
/** background id 取前 N 段(bg/${tag}/${seq})。 */
|
|
93
|
+
const SHORT_ID_BG_SEGMENTS = 3;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 从完整 record id 提取短编号用于列表展示.
|
|
97
|
+
*
|
|
98
|
+
* id 格式(subagent-service.ts:422 生成):
|
|
99
|
+
* - sync: `run-${seq}` (如 run-1) → 原样(2 段)
|
|
100
|
+
* - background: `bg-${tag}-${seq}-${ts}` (如 bg-f6f731-10-1719500000000)
|
|
101
|
+
* → 取前 3 段得 bg-f6f731-10(丢弃冗长时间戳)
|
|
102
|
+
*
|
|
103
|
+
* 按段数分支:sync(2 段)原样返回;background(≥3 段)取前 3 段(bg/tag/seq)。
|
|
104
|
+
* seq 进程内递增唯一,作为「编号」足够区分;完整 id(含时间戳)在右列预览给出供精确引用.
|
|
105
|
+
*/
|
|
106
|
+
export function shortId(id: string): string {
|
|
107
|
+
const segments = id.split("-");
|
|
108
|
+
if (segments.length <= SHORT_ID_SYNC_SEGMENTS) return id;
|
|
109
|
+
return segments.slice(0, SHORT_ID_BG_SEGMENTS).join("-");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 把文本 pad 到指定**可见**宽度(grapheme/emoji/CJK 安全).
|
|
114
|
+
*
|
|
115
|
+
* 用 visibleWidth 而非 `.length`——避免 ANSI 转义、emoji、宽字符(CJK 占 2 列)
|
|
116
|
+
* 把列对齐算错(dev guide §2.4 警告的坑).
|
|
117
|
+
*
|
|
118
|
+
* - 已 ≥ width → 原样返回(调用方负责先 truncLine 截断)
|
|
119
|
+
* - < width → 末尾补空格到可见宽度对齐
|
|
120
|
+
*
|
|
121
|
+
* 与 truncLine 配对:左/右列对齐时先 `truncLine(s, colWidth)` 再 `padToVisible(s, colWidth)`.
|
|
122
|
+
*/
|
|
123
|
+
export function padToVisible(text: string, width: number): string {
|
|
124
|
+
const w = visibleWidth(text);
|
|
125
|
+
if (w >= width) return text;
|
|
126
|
+
return text + " ".repeat(width - w);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 分段着色版 segFill:title 和 fill 都已着色(含 ANSI),拼接时各自 ANSI 延续.
|
|
131
|
+
*
|
|
132
|
+
* 解决 ANSI 嵌套失色问题:若用 `t.fg("c1", fill(title, "─", n))`,
|
|
133
|
+
* title 内的 `\x1b[0m` 会重置外层 c1,导致 title 之后的 `─` 失去 c1.
|
|
134
|
+
* 本函数改成 `title + fill.repeat(后)`,fill 整段保持着自己的 ANSI,不依赖外层包裹 → 全线着色一致.
|
|
135
|
+
*
|
|
136
|
+
* segFillColored(t.fg("accent"," Subagents "), t.fg("borderMuted","─"), 20)
|
|
137
|
+
* → accent(" Subagents ") + borderMuted(─×N),无嵌套
|
|
138
|
+
*
|
|
139
|
+
* 注意:fill 必须是「单字符着色」(如 `t.fg("borderMuted","─")`),visibleWidth=1.
|
|
140
|
+
* 调用方负责 title/fill 着色;本函数不接 theme.标题在前、填充在后.
|
|
141
|
+
*/
|
|
142
|
+
export function segFillColored(titleStyled: string | undefined, fillStyled: string, width: number): string {
|
|
143
|
+
if (width <= 0) return "";
|
|
144
|
+
const fillW = visibleWidth(fillStyled);
|
|
145
|
+
if (!titleStyled || fillW === 0) {
|
|
146
|
+
// 纯填充线:fillStyled.visibleWidth 应为 1,按 width 次重复
|
|
147
|
+
return fillStyled.repeat(width);
|
|
148
|
+
}
|
|
149
|
+
const tw = visibleWidth(titleStyled);
|
|
150
|
+
if (tw >= width) return truncLine(titleStyled, width);
|
|
151
|
+
const fillCount = width - tw;
|
|
152
|
+
return titleStyled + fillStyled.repeat(fillCount);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ============================================================
|
|
156
|
+
// 状态图标
|
|
157
|
+
// ============================================================
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* status → 图标 + 颜色 token.
|
|
161
|
+
*
|
|
162
|
+
* running → { icon: undefined, color: "accent" }
|
|
163
|
+
* icon 留空是因为 running 的 spinner 需 seed 驱动,
|
|
164
|
+
* 调用方用 detailsSeed(details) 算 seed 后调 spinnerGlyph(seed).
|
|
165
|
+
* done → { "✓", "success" }
|
|
166
|
+
* failed → { "✗", "error" }
|
|
167
|
+
* cancelled → { "■", "muted" }
|
|
168
|
+
*/
|
|
169
|
+
export function statusGlyph(status: ExecutionStatus): { icon: string | undefined; color: string } {
|
|
170
|
+
switch (status) {
|
|
171
|
+
case "running":
|
|
172
|
+
return { icon: undefined, color: "accent" };
|
|
173
|
+
case "done":
|
|
174
|
+
return { icon: "✓", color: "success" };
|
|
175
|
+
case "failed":
|
|
176
|
+
return { icon: "✗", color: "error" };
|
|
177
|
+
case "cancelled":
|
|
178
|
+
return { icon: "■", color: "muted" };
|
|
179
|
+
default:
|
|
180
|
+
// 防御:运行时 status 可能是意外值(SDK 投影异常/未来新增状态),兜底为 running 语义
|
|
181
|
+
return { icon: undefined, color: "accent" };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 生成 spinner 字形(seed 驱动,非定时器).
|
|
187
|
+
*
|
|
188
|
+
* 每次 onUpdate(真实事件)→ seed 单调增长 → 换帧;
|
|
189
|
+
* 静默期 seed 不变 → 冻结 → 换取滚动体验(修复 viewport 锚定 bug).
|
|
190
|
+
*/
|
|
191
|
+
export function spinnerGlyph(seed: number): string {
|
|
192
|
+
// 防御:seed 可能是 NaN(details 字段缺失时),回退首帧
|
|
193
|
+
if (!Number.isFinite(seed)) return RUNNING_FRAMES[0]!;
|
|
194
|
+
return RUNNING_FRAMES[Math.abs(seed) % RUNNING_FRAMES.length]!;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ============================================================
|
|
198
|
+
// eventLog 单行格式化
|
|
199
|
+
// ============================================================
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 压平 label 到单行(防 LLM 输出的 \r\n/\t 把单行展开成多行,破坏布局).
|
|
203
|
+
* 两层防御之一(另一层在 tool-render 的 buildRenderLines).
|
|
204
|
+
*/
|
|
205
|
+
export function sanitizeLabel(label: string): string {
|
|
206
|
+
return label.replace(/[\r\n]+/g, " ").replace(/\t/g, " ");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ============================================================
|
|
210
|
+
// 共享文本/参数提取 helper(tool-render / list-view / bg-notify-render / subagent-tool 复用)
|
|
211
|
+
// ============================================================
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 取文本首个非空行(多行压成首行).
|
|
215
|
+
*
|
|
216
|
+
* 仅做"取首行"——不 sanitize.三处调用方的 sanitize 末步不同
|
|
217
|
+
* (tool-render 调 sanitizeLabel、bg-notify-render 压 \r\t、list-view 不处理),
|
|
218
|
+
* 故共享此基础函数,各自按需 wrap.
|
|
219
|
+
*
|
|
220
|
+
* firstLine("a\nb\nc") → "a"
|
|
221
|
+
* firstLine("\n\nb") → "b"
|
|
222
|
+
* firstLine("") → ""
|
|
223
|
+
*/
|
|
224
|
+
export function firstLine(text?: string): string {
|
|
225
|
+
if (!text) return "";
|
|
226
|
+
return text.split("\n").find((l) => l.trim())?.trim() ?? "";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 从 renderCall/execute 的 unknown args 安全提取 agent 名.
|
|
231
|
+
* 类型守卫窄化(替代 `as { agent?: string }` 全可选断言).
|
|
232
|
+
* 无 agent 字段或非空字符串时兌底 DEFAULT_AGENT_NAME(与 service 层 resolveIdentity 一致,
|
|
233
|
+
* 保证 block 标题显示的名与实际加载的 agent.md 相符).
|
|
234
|
+
*/
|
|
235
|
+
export function extractAgentName(args: unknown): string {
|
|
236
|
+
if (typeof args === "object" && args !== null && "agent" in args) {
|
|
237
|
+
const v = (args as { agent: unknown }).agent;
|
|
238
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
239
|
+
}
|
|
240
|
+
return DEFAULT_AGENT_NAME;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 格式化单条 eventLog 条目(带类型图标 + 着色,不含 `⎿` 前缀——前缀由调用方加).
|
|
245
|
+
*
|
|
246
|
+
* 标签语义(tui-conversation.md §7):
|
|
247
|
+
* tool: tool_start/tool_end(尾部追加 ✓/✗)
|
|
248
|
+
* ── turn ── turn_end(仅 expanded)
|
|
249
|
+
* error: tool label + ✗
|
|
250
|
+
*
|
|
251
|
+
* text_output / thinking 类型已移除——完整内容收口在 record.turns[],
|
|
252
|
+
* eventLog 只承载离散语义事件。实时 text/thinking 进度由 currentActivity 行展示。
|
|
253
|
+
*
|
|
254
|
+
* 预处理统一用 sanitizeLabel(换行→空格、tab→2空格).
|
|
255
|
+
* 不做预截断——宽度截断全部交给外层调用点的 `truncLine(formatEventLine(...), width)`.
|
|
256
|
+
*/
|
|
257
|
+
export function formatEventLine(entry: AgentEventLogEntry, theme: ThemeLike): string {
|
|
258
|
+
const label = sanitizeLabel(entry.label);
|
|
259
|
+
|
|
260
|
+
switch (entry.type) {
|
|
261
|
+
case "tool_start":
|
|
262
|
+
return `tool: ${label}`;
|
|
263
|
+
|
|
264
|
+
case "tool_end": {
|
|
265
|
+
const mark = entry.status === "failed"
|
|
266
|
+
? ` ${theme.fg("error", "✗")}`
|
|
267
|
+
: ` ${theme.fg("success", "✓")}`;
|
|
268
|
+
return `tool: ${label}${mark}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
case "turn_end": {
|
|
272
|
+
// label = turn.text 摘要(getEventLog 派生,TURN_SUMMARY_MAX=80),无 text 时为 "turn"。
|
|
273
|
+
// 若丢弃 label 只显 "── turn ──",流式 text 在 turn 结束后完全消失(currentActivity
|
|
274
|
+
// 随 turn 闭合转为 undefined),用户无法回顾 subagent 说了什么。这里显示摘要保留可见性。
|
|
275
|
+
const summary = sanitizeLabel(entry.label);
|
|
276
|
+
if (!summary || summary === "turn") {
|
|
277
|
+
return theme.fg("dim", "── turn ──");
|
|
278
|
+
}
|
|
279
|
+
return theme.fg("toolOutput", summary);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
case "error":
|
|
283
|
+
// 错误条目:标签 + label + ✗
|
|
284
|
+
return `tool: ${label} ${theme.fg("error", "✗")}`;
|
|
285
|
+
|
|
286
|
+
default:
|
|
287
|
+
return label;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* [STEP3] 格式化单个 toolCall 为展示行(对齐 nicobailon formatToolCall)。
|
|
293
|
+
*
|
|
294
|
+
* 返回不含前缀(`→ ` 由调用方加)。根据 toolName 提取关键参数格式化:
|
|
295
|
+
* bash → `$ <command 预览>`
|
|
296
|
+
* read → `read <~路径:offset-limit>`
|
|
297
|
+
* edit/write → `<op> <~路径>`
|
|
298
|
+
* grep/find/ls → 对应格式
|
|
299
|
+
* default → `<toolName> <argsJSON 预览>`
|
|
300
|
+
*/
|
|
301
|
+
export function formatToolCall(
|
|
302
|
+
toolName: string,
|
|
303
|
+
args: Record<string, unknown>,
|
|
304
|
+
theme: ThemeLike,
|
|
305
|
+
): string {
|
|
306
|
+
const shortenPath = (p: string): string => {
|
|
307
|
+
const home = os.homedir();
|
|
308
|
+
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
switch (toolName) {
|
|
312
|
+
case "bash": {
|
|
313
|
+
const command = (args.command as string) || "...";
|
|
314
|
+
const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
|
|
315
|
+
return theme.fg("muted", "$ ") + theme.fg("toolOutput", preview);
|
|
316
|
+
}
|
|
317
|
+
case "read": {
|
|
318
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
319
|
+
const filePath = shortenPath(rawPath);
|
|
320
|
+
const offset = args.offset as number | undefined;
|
|
321
|
+
const limit = args.limit as number | undefined;
|
|
322
|
+
let text = theme.fg("accent", filePath);
|
|
323
|
+
if (offset !== undefined || limit !== undefined) {
|
|
324
|
+
const startLine = offset ?? 1;
|
|
325
|
+
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
|
326
|
+
text += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
|
|
327
|
+
}
|
|
328
|
+
return theme.fg("muted", "read ") + text;
|
|
329
|
+
}
|
|
330
|
+
case "write": {
|
|
331
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
332
|
+
const content = (args.content || "") as string;
|
|
333
|
+
const lines = content.split("\n").length;
|
|
334
|
+
let text = theme.fg("muted", "write ") + theme.fg("accent", shortenPath(rawPath));
|
|
335
|
+
if (lines > 1) text += theme.fg("dim", ` (${lines} lines)`);
|
|
336
|
+
return text;
|
|
337
|
+
}
|
|
338
|
+
case "edit": {
|
|
339
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
340
|
+
return theme.fg("muted", "edit ") + theme.fg("accent", shortenPath(rawPath));
|
|
341
|
+
}
|
|
342
|
+
case "ls": {
|
|
343
|
+
const rawPath = (args.path || ".") as string;
|
|
344
|
+
return theme.fg("muted", "ls ") + theme.fg("accent", shortenPath(rawPath));
|
|
345
|
+
}
|
|
346
|
+
case "find": {
|
|
347
|
+
const pattern = (args.pattern || "*") as string;
|
|
348
|
+
const rawPath = (args.path || ".") as string;
|
|
349
|
+
return theme.fg("muted", "find ") + theme.fg("accent", pattern) + theme.fg("dim", ` in ${shortenPath(rawPath)}`);
|
|
350
|
+
}
|
|
351
|
+
case "grep": {
|
|
352
|
+
const pattern = (args.pattern || "") as string;
|
|
353
|
+
const rawPath = (args.path || ".") as string;
|
|
354
|
+
return theme.fg("muted", "grep ") + theme.fg("accent", `/${pattern}/`) + theme.fg("dim", ` in ${shortenPath(rawPath)}`);
|
|
355
|
+
}
|
|
356
|
+
default: {
|
|
357
|
+
const argsStr = JSON.stringify(args);
|
|
358
|
+
const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
|
|
359
|
+
return theme.fg("accent", toolName) + theme.fg("dim", ` ${preview}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* [STEP3] 格式化单个 displayItem 为展示行(含 `→ ` 前缀)。
|
|
366
|
+
*
|
|
367
|
+
* toolCall:`→ <formatToolCall>` + 尾部 ✓/✗(done/failed 才加)。
|
|
368
|
+
* text:assistant 正文(compact 时调用方自行截断,这里返回原文)。
|
|
369
|
+
*/
|
|
370
|
+
export function formatDisplayItem(item: DisplayItem, theme: ThemeLike): string {
|
|
371
|
+
if (item.type === "text") {
|
|
372
|
+
return theme.fg("toolOutput", item.text ?? "");
|
|
373
|
+
}
|
|
374
|
+
const name = item.name ?? "unknown";
|
|
375
|
+
const args = item.args ?? {};
|
|
376
|
+
const base = `${theme.fg("muted", "→ ")}${formatToolCall(name, args, theme)}`;
|
|
377
|
+
if (item.status === "done") return `${base} ${theme.fg("success", "✓")}`;
|
|
378
|
+
if (item.status === "failed") return `${base} ${theme.fg("error", "✗")}`;
|
|
379
|
+
return base;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ============================================================
|
|
383
|
+
// ANSI 安全截断
|
|
384
|
+
// ============================================================
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* 截断文本到 maxWidth 可见宽度(带省略号 `…`,ANSI 安全).
|
|
388
|
+
*
|
|
389
|
+
* 问题:pi-tui 的 truncateToWidth 在省略号前插 `\x1b[0m`(全局 reset),
|
|
390
|
+
* 导致 contentBox 施加的背景色在省略号处断裂.
|
|
391
|
+
*
|
|
392
|
+
* 解决:遍历追踪 active SGR styles,遇 `\x1b[0m` 清空、遇其他 `\x1b[..m` push,
|
|
393
|
+
* 截断时 `result + activeStyles.join("") + "…"`——重应用 active 样式,背景不断裂.
|
|
394
|
+
* 用 Intl.Segmenter grapheme 切分,正确处理 emoji/CJK/组合字符.
|
|
395
|
+
*
|
|
396
|
+
* 移植自 pi-subagents render.ts:44-89.
|
|
397
|
+
*/
|
|
398
|
+
export function truncLine(text: string, maxWidth: number): string {
|
|
399
|
+
if (maxWidth <= 0) return "";
|
|
400
|
+
// 剥离换行符(\n / \r):text 可能含多行 prompt / turn.text,\n 不占可见宽度
|
|
401
|
+
// 但在终端会换行,导致单行渲染意外变成多行,破坏行对齐(list overlay 左右列错位、
|
|
402
|
+
// tool block 行数跳变)。单行渲染入口必须保证无 \n。用空格替代(保留词边界可读性)。
|
|
403
|
+
const flat = text.replace(/[\r\n]+/g, " ");
|
|
404
|
+
if (visibleWidth(flat) <= maxWidth) return flat;
|
|
405
|
+
|
|
406
|
+
const targetWidth = Math.max(0, maxWidth - 1);
|
|
407
|
+
let result = "";
|
|
408
|
+
let currentWidth = 0;
|
|
409
|
+
let activeStyles: string[] = [];
|
|
410
|
+
let i = 0;
|
|
411
|
+
|
|
412
|
+
while (i < flat.length) {
|
|
413
|
+
// 捕获 ANSI SGR 序列
|
|
414
|
+
const ansiMatch = flat.slice(i).match(/^\x1b\[[0-9;]*m/);
|
|
415
|
+
if (ansiMatch) {
|
|
416
|
+
const code = ansiMatch[0];
|
|
417
|
+
result += code;
|
|
418
|
+
|
|
419
|
+
if (code === "\x1b[0m" || code === "\x1b[m") {
|
|
420
|
+
activeStyles = []; // reset → 清空栈
|
|
421
|
+
} else {
|
|
422
|
+
activeStyles.push(code);
|
|
423
|
+
}
|
|
424
|
+
i += code.length;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 找到下一段纯文本(非 ANSI)的边界
|
|
429
|
+
let end = i;
|
|
430
|
+
while (end < flat.length && !flat.slice(end).match(/^\x1b\[[0-9;]*m/)) {
|
|
431
|
+
end++;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 按 grapheme 迭代这段文本,累加到 targetWidth
|
|
435
|
+
const textPortion = flat.slice(i, end);
|
|
436
|
+
for (const seg of segmenter.segment(textPortion)) {
|
|
437
|
+
const grapheme = seg.segment;
|
|
438
|
+
const graphemeWidth = visibleWidth(grapheme);
|
|
439
|
+
|
|
440
|
+
if (currentWidth + graphemeWidth > targetWidth) {
|
|
441
|
+
// 截断:重应用 active 样式 + 省略号 + reset。
|
|
442
|
+
// reset 不可省——否则行尾颜色渗透到 padToVisible 的填充空格、乃至下一帧行,
|
|
443
|
+
// 视觉上表现为颜色重影(被截断的着色延伸到行尾之外)。
|
|
444
|
+
// 但纯文本输入(activeStyles 为空)不发 reset——\x1b[0m 是全局重置,
|
|
445
|
+
// 会清除 theme.bg 施加的外层背景色(背景框内省略号后失去背景的根因)。
|
|
446
|
+
return result + activeStyles.join("") + "…" + (activeStyles.length ? "\x1b[0m" : "");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
result += grapheme;
|
|
450
|
+
currentWidth += graphemeWidth;
|
|
451
|
+
}
|
|
452
|
+
i = end;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 理论上 visibleWidth 检查已提前返回,此行兜底
|
|
456
|
+
return result + activeStyles.join("") + "…" + (activeStyles.length ? "\x1b[0m" : "");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* 把长文本按指定可见宽度拆成多行(word-wrap),完整展示不截断。
|
|
461
|
+
*
|
|
462
|
+
* 用于 detail 模式完整展示长内容(task / output text)。detail 有翻屏能力,
|
|
463
|
+
* 不应像预览那样截断成一行省略号——信息完整性优先。
|
|
464
|
+
*
|
|
465
|
+
* 输入为纯文本(不含 ANSI 颜色)。调用方对返回的每行单独着色,
|
|
466
|
+
* 这样避免对 ANSI 文本做复杂 wrap(SGR 状态跨行续重应用)。
|
|
467
|
+
*
|
|
468
|
+
* grapheme 迭代(CJK/emoji 安全):逐字素累加可见宽度,超 maxWidth 即断行。
|
|
469
|
+
* CJK 可在任意字素间断(每个汉字占 2 列但可断),拉丁文不强制保留词边界
|
|
470
|
+
* (detail 场景优先完整性,width 足够宽时自然在空格附近断)。
|
|
471
|
+
*
|
|
472
|
+
* 原始 \n 保留为段落分隔(split 后每段独立 wrap,空行保留为 "")。
|
|
473
|
+
* 与 truncLine 的扁平化不同:wrapText 的产物是多行,\n 是有意的段落边界。
|
|
474
|
+
*/
|
|
475
|
+
export function wrapText(text: string, maxWidth: number): string[] {
|
|
476
|
+
if (maxWidth <= 0) return [text];
|
|
477
|
+
const result: string[] = [];
|
|
478
|
+
for (const para of text.split(/\r?\n/)) {
|
|
479
|
+
const trimmed = para.trim();
|
|
480
|
+
if (!trimmed) {
|
|
481
|
+
result.push("");
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
let line = "";
|
|
485
|
+
let lineWidth = 0;
|
|
486
|
+
for (const seg of segmenter.segment(trimmed)) {
|
|
487
|
+
const g = seg.segment;
|
|
488
|
+
const gw = visibleWidth(g);
|
|
489
|
+
if (lineWidth + gw > maxWidth && line.length > 0) {
|
|
490
|
+
result.push(line);
|
|
491
|
+
line = g;
|
|
492
|
+
lineWidth = gw;
|
|
493
|
+
} else {
|
|
494
|
+
line += g;
|
|
495
|
+
lineWidth += gw;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (line.length > 0) result.push(line);
|
|
499
|
+
}
|
|
500
|
+
return result;
|
|
501
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GUI 协议适配层 —— TUI/RPC 双模渲染支持。
|
|
3
|
+
*
|
|
4
|
+
* 封装 @xyz-agent/extension-protocol 的 helpers,供 subagent/workflow/bg-notify 使用。
|
|
5
|
+
* 当 extension-protocol 包可用后,替换 import 即可(stub 接口完全对齐)。
|
|
6
|
+
*
|
|
7
|
+
* 协议核心:
|
|
8
|
+
* - isGuiCapable(ctx) → RPC 模式返回 true
|
|
9
|
+
* - guiComponent(type, props) → 构造结构化 GuiComponent
|
|
10
|
+
* - guiResult(component) → 包装为 GuiRenderResult,放进 details.__gui__
|
|
11
|
+
*
|
|
12
|
+
* 渲染入口:
|
|
13
|
+
* - tool result: details.__gui__(execute 返回时构造)
|
|
14
|
+
* - message: details.__gui__(sendMessage 时附带)
|
|
15
|
+
*
|
|
16
|
+
* @see docs/extensions/gui-protocol-guide.md
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// ============================================================
|
|
20
|
+
// 类型(对齐 @xyz-agent/extension-protocol)
|
|
21
|
+
// ============================================================
|
|
22
|
+
|
|
23
|
+
/** GuiContext —— Pi ExtensionContext 的结构化子集。 */
|
|
24
|
+
export interface GuiContext {
|
|
25
|
+
hasUI?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** GuiComponent 类型枚举(本扩展实际使用的子集)。 */
|
|
29
|
+
export type GuiComponentType =
|
|
30
|
+
| "task-list"
|
|
31
|
+
| "workflow-runs"
|
|
32
|
+
| "subagent-trace"
|
|
33
|
+
| "stats-line";
|
|
34
|
+
|
|
35
|
+
/** 各类型对应的 props 形状。 */
|
|
36
|
+
export interface TaskListProps {
|
|
37
|
+
title: string;
|
|
38
|
+
items: Array<{
|
|
39
|
+
label: string;
|
|
40
|
+
status: "pending" | "in_progress" | "completed" | "failed" | "cancelled";
|
|
41
|
+
detail?: string;
|
|
42
|
+
}>;
|
|
43
|
+
summary?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface WorkflowRunsProps {
|
|
47
|
+
runs: Array<{
|
|
48
|
+
runId: string;
|
|
49
|
+
name: string;
|
|
50
|
+
status: string;
|
|
51
|
+
reason?: string;
|
|
52
|
+
durationMs?: number;
|
|
53
|
+
error?: string;
|
|
54
|
+
}>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SubagentTraceProps {
|
|
58
|
+
agent: string;
|
|
59
|
+
status: "running" | "done" | "failed" | "cancelled";
|
|
60
|
+
stats?: {
|
|
61
|
+
turns?: number;
|
|
62
|
+
tokens?: number;
|
|
63
|
+
durationMs?: number;
|
|
64
|
+
};
|
|
65
|
+
result?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface StatsLineProps {
|
|
69
|
+
items: Array<{
|
|
70
|
+
label: string;
|
|
71
|
+
value: string;
|
|
72
|
+
severity?: "ok" | "warn" | "danger";
|
|
73
|
+
}>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type GuiComponentProps = {
|
|
77
|
+
"task-list": TaskListProps;
|
|
78
|
+
"workflow-runs": WorkflowRunsProps;
|
|
79
|
+
"subagent-trace": SubagentTraceProps;
|
|
80
|
+
"stats-line": StatsLineProps;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export interface GuiComponent<T extends GuiComponentType = GuiComponentType> {
|
|
84
|
+
type: T;
|
|
85
|
+
props: GuiComponentProps[T];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface GuiRenderResult {
|
|
89
|
+
v: 1;
|
|
90
|
+
component: GuiComponent;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ============================================================
|
|
94
|
+
// Helpers(stub 实现,对齐 @xyz-agent/extension-protocol)
|
|
95
|
+
// ============================================================
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 检测当前环境是否支持 GUI 渲染(RPC 模式)。
|
|
99
|
+
* Pi TUI 模式下 hasUI 为 true,RPC 模式下为 false。
|
|
100
|
+
*/
|
|
101
|
+
export function isGuiCapable(ctx: GuiContext): boolean {
|
|
102
|
+
return ctx.hasUI === false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 构造 GuiComponent,带类型推断。
|
|
107
|
+
*/
|
|
108
|
+
export function guiComponent<T extends GuiComponentType>(
|
|
109
|
+
type: T,
|
|
110
|
+
props: GuiComponentProps[T],
|
|
111
|
+
): GuiComponent<T> {
|
|
112
|
+
return { type, props };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 构造 GuiRenderResult,放进 details.__gui__。
|
|
117
|
+
* 递归删除 undefined 字段。
|
|
118
|
+
*/
|
|
119
|
+
export function guiResult(component: GuiComponent): GuiRenderResult {
|
|
120
|
+
return { v: 1, component: stripUndefined(component) as GuiComponent };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 递归删除 undefined 字段(JSON.stringify 会丢弃 undefined,但数组中会变 null)。
|
|
125
|
+
*/
|
|
126
|
+
function stripUndefined(obj: unknown): unknown {
|
|
127
|
+
if (obj === null || typeof obj !== "object") return obj;
|
|
128
|
+
if (Array.isArray(obj)) return obj.map(stripUndefined);
|
|
129
|
+
const result: Record<string, unknown> = {};
|
|
130
|
+
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
131
|
+
if (value !== undefined) {
|
|
132
|
+
result[key] = stripUndefined(value);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
136
|
+
}
|