@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
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// src/interface/tui-kit.ts
|
|
2
|
+
//
|
|
3
|
+
// TUI 终端零件 kit(post-convergence C4):全屏视图族的共享零件单点——
|
|
4
|
+
// 终端探测常量 / termRows() / 边框着色家族(b/dash/dashes/titleBorder/plainBorder/walled,
|
|
5
|
+
// 自由函数形态,WorkflowsView 形态胜出)/ ANSI 可见宽度文本布局家族
|
|
6
|
+
// (truncLine/wrapText/padToVisible/segFillColored,自 format.ts 迁入)。
|
|
7
|
+
//
|
|
8
|
+
// 零依赖叶(D7):不 import format.ts 与其他壳 module——边框家族的填充/对齐
|
|
9
|
+
// 需要 ANSI 可见宽度计算(titleBorder→segFillColored→truncLine、walled→padToVisible),
|
|
10
|
+
// 该布局家族整体随迁本文件,否则 kit 须反向依赖 format.ts 或复制实现(双定义)。
|
|
11
|
+
// 唯一外部依赖 = pi-tui 的 visibleWidth(纯函数,无内部耦合)。
|
|
12
|
+
//
|
|
13
|
+
// 布局家族原定义与行为(含 __fixtures__/truncline.snapshot.json 逐字节锚定)不变,
|
|
14
|
+
// format.ts 经 re-export 保持既有导入面(bg-notify-render / tool-render / 测试零改动)。
|
|
15
|
+
|
|
16
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
17
|
+
|
|
18
|
+
// ============================================================
|
|
19
|
+
// 终端探测常量(单定义)
|
|
20
|
+
// ============================================================
|
|
21
|
+
|
|
22
|
+
/** terminal.rows 读不到时的兜底行数(防 duck-type 失败)。 */
|
|
23
|
+
const TERM_ROWS_FALLBACK = 24;
|
|
24
|
+
/** PgUp/PgDn 翻页默认步长(viewport 信息缺失时兜底,防 NaN)。 */
|
|
25
|
+
export const PAGE_SCROLL_DEFAULT = 10;
|
|
26
|
+
|
|
27
|
+
/** terminal.rows 的最小 duck-type 面(list-shared TuiLike / WorkflowsView TuiLike 均结构兼容)。 */
|
|
28
|
+
export interface TerminalRowsSource {
|
|
29
|
+
terminal?: { rows?: unknown };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 安全读 terminal.rows(兜底防 duck-type 失败)。 */
|
|
33
|
+
export function termRows(tui: TerminalRowsSource): number {
|
|
34
|
+
const rows = tui.terminal?.rows;
|
|
35
|
+
return typeof rows === "number" && rows > 0 ? rows : TERM_ROWS_FALLBACK;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ============================================================
|
|
39
|
+
// 边框着色 helper 家族(统一 borderMuted,避 ANSI 嵌套失色)
|
|
40
|
+
// ============================================================
|
|
41
|
+
|
|
42
|
+
/** 边框着色所需的最小主题面(结构兼容 format.ts 的 ThemeLike / Pi Theme)。 */
|
|
43
|
+
export interface BorderTheme {
|
|
44
|
+
fg(color: string, text: string): string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 着色单个框线字符(borderMuted)。所有 ╭╮╰╯├┤┬┴─│ 统一走这里。 */
|
|
48
|
+
export function b(theme: BorderTheme, s: string): string {
|
|
49
|
+
return theme.fg("borderMuted", s);
|
|
50
|
+
}
|
|
51
|
+
/** 着色单字符填充用的 `─`(供 segFillColored 的 fillStyled)。 */
|
|
52
|
+
export function dash(theme: BorderTheme): string {
|
|
53
|
+
return theme.fg("borderMuted", "─");
|
|
54
|
+
}
|
|
55
|
+
/** 满宽 `─` 填充串(borderMuted)。n 次单字符着色,ANSI 自然延续。 */
|
|
56
|
+
export function dashes(theme: BorderTheme, n: number): string {
|
|
57
|
+
return dash(theme).repeat(Math.max(0, n));
|
|
58
|
+
}
|
|
59
|
+
/** 顶/底框行:`╭` + 着色标题填充 + `╮`(或 ╰╯)。每段独立着色,无嵌套。 */
|
|
60
|
+
export function titleBorder(
|
|
61
|
+
theme: BorderTheme,
|
|
62
|
+
left: string,
|
|
63
|
+
titleStyled: string,
|
|
64
|
+
right: string,
|
|
65
|
+
contentWidth: number,
|
|
66
|
+
): string {
|
|
67
|
+
return b(theme, left) + segFillColored(titleStyled, dash(theme), contentWidth) + b(theme, right);
|
|
68
|
+
}
|
|
69
|
+
/** 纯线顶/底框(无标题):`╭` + `─`×W + `╮`。 */
|
|
70
|
+
export function plainBorder(theme: BorderTheme, left: string, right: string, contentWidth: number): string {
|
|
71
|
+
return b(theme, left) + dashes(theme, contentWidth) + b(theme, right);
|
|
72
|
+
}
|
|
73
|
+
/** 内容行墙:`│` + 内容(pad 到 contentWidth) + `│`,墙字符 borderMuted。 */
|
|
74
|
+
export function walled(theme: BorderTheme, content: string, contentWidth: number): string {
|
|
75
|
+
return `${b(theme, "│")}${padToVisible(content, contentWidth)}${b(theme, "│")}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ============================================================
|
|
79
|
+
// ANSI 可见宽度文本布局家族(自 format.ts 原样迁入)
|
|
80
|
+
// ============================================================
|
|
81
|
+
|
|
82
|
+
/** grapheme 切分器(Unicode/emoji 安全).模块级共享,勿热路径 new. */
|
|
83
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 把文本 pad 到指定**可见**宽度(grapheme/emoji/CJK 安全).
|
|
87
|
+
*
|
|
88
|
+
* 用 visibleWidth 而非 `.length`——避免 ANSI 转义、emoji、宽字符(CJK 占 2 列)
|
|
89
|
+
* 把列对齐算错(dev guide §2.4 警告的坑).
|
|
90
|
+
*
|
|
91
|
+
* - 已 ≥ width → 原样返回(调用方负责先 truncLine 截断)
|
|
92
|
+
* - < width → 末尾补空格到可见宽度对齐
|
|
93
|
+
*
|
|
94
|
+
* 与 truncLine 配对:左/右列对齐时先 `truncLine(s, colWidth)` 再 `padToVisible(s, colWidth)`.
|
|
95
|
+
*/
|
|
96
|
+
export function padToVisible(text: string, width: number): string {
|
|
97
|
+
const w = visibleWidth(text);
|
|
98
|
+
if (w >= width) return text;
|
|
99
|
+
return text + " ".repeat(width - w);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 分段着色版 segFill:title 和 fill 都已着色(含 ANSI),拼接时各自 ANSI 延续.
|
|
104
|
+
*
|
|
105
|
+
* 解决 ANSI 嵌套失色问题:若用 `t.fg("c1", fill(title, "─", n))`,
|
|
106
|
+
* title 内的 `\x1b[0m` 会重置外层 c1,导致 title 之后的 `─` 失去 c1.
|
|
107
|
+
* 本函数改成 `title + fill.repeat(后)`,fill 整段保持着自己的 ANSI,不依赖外层包裹 → 全线着色一致.
|
|
108
|
+
*
|
|
109
|
+
* segFillColored(t.fg("accent"," Subagents "), t.fg("borderMuted","─"), 20)
|
|
110
|
+
* → accent(" Subagents ") + borderMuted(─×N),无嵌套
|
|
111
|
+
*
|
|
112
|
+
* 注意:fill 必须是「单字符着色」(如 `t.fg("borderMuted","─")`),visibleWidth=1.
|
|
113
|
+
* 调用方负责 title/fill 着色;本函数不接 theme.标题在前、填充在后.
|
|
114
|
+
*/
|
|
115
|
+
export function segFillColored(titleStyled: string | undefined, fillStyled: string, width: number): string {
|
|
116
|
+
if (width <= 0) return "";
|
|
117
|
+
const fillW = visibleWidth(fillStyled);
|
|
118
|
+
if (!titleStyled || fillW === 0) {
|
|
119
|
+
// 纯填充线:fillStyled.visibleWidth 应为 1,按 width 次重复
|
|
120
|
+
return fillStyled.repeat(width);
|
|
121
|
+
}
|
|
122
|
+
const tw = visibleWidth(titleStyled);
|
|
123
|
+
if (tw >= width) return truncLine(titleStyled, width);
|
|
124
|
+
const fillCount = width - tw;
|
|
125
|
+
return titleStyled + fillStyled.repeat(fillCount);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* SGR 序列 sticky regex(truncLine 专用)。
|
|
130
|
+
*
|
|
131
|
+
* lastIndex 置位后 exec/test 只在该位置匹配(等价 `flat.slice(pos).match(/^\x1b\[[0-9;]*m/)`,
|
|
132
|
+
* 但零子串分配)。同步代码内「设 lastIndex → 立即用」,无跨调用残留。
|
|
133
|
+
*/
|
|
134
|
+
const SGR_STICKY_RE = /\x1b\[[0-9;]*m/y;
|
|
135
|
+
|
|
136
|
+
/** 判断 s 在 pos 处是否开始一个 SGR 序列(非 SGR ESC——OSC/非 SGR CSI/裸 ESC——返回 false)。 */
|
|
137
|
+
function isSgrStart(s: string, pos: number): boolean {
|
|
138
|
+
SGR_STICKY_RE.lastIndex = pos;
|
|
139
|
+
return SGR_STICKY_RE.test(s);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 截断文本到 maxWidth 可见宽度(带省略号 `…`,ANSI 安全).
|
|
144
|
+
*
|
|
145
|
+
* 问题:pi-tui 的 truncateToWidth 在省略号前插 `\x1b[0m`(全局 reset),
|
|
146
|
+
* 导致 contentBox 施加的背景色在省略号处断裂.
|
|
147
|
+
*
|
|
148
|
+
* 解决:遍历追踪 active SGR styles,遇 `\x1b[0m` 清空、遇其他 `\x1b[..m` push,
|
|
149
|
+
* 截断时 `result + activeStyles.join("") + "…"`——重应用 active 样式,背景不断裂.
|
|
150
|
+
* 用 Intl.Segmenter grapheme 切分,正确处理 emoji/CJK/组合字符.
|
|
151
|
+
*
|
|
152
|
+
* 性能(IF12/#18):旧实现逐字符 `flat.slice(end).match(...)` 是 O(n²)(每字符
|
|
153
|
+
* 一次子串分配 + 正则扫描);现改为 `indexOf("\x1b", ...)` 跳到下一 ESC,仅在 ESC
|
|
154
|
+
* 位置做一次 sticky 判定——纯文本段零分配零正则。
|
|
155
|
+
*
|
|
156
|
+
* **非 SGR ESC 行为(等价关键,逐字节等价的定义点)**:OSC(\x1b]0;..\x07)、
|
|
157
|
+
* 非 SGR CSI(\x1b[K)、裸 \x1b 后跟非 [ 字符——均不构成文本段边界,作为纯文本
|
|
158
|
+
* 并入 textPortion 被 segmenter 消费(占 currentWidth、进 result),与旧实现
|
|
159
|
+
* 「while 停点集合 = SGR 序列起点」完全一致。输出由 __fixtures__/truncline.snapshot.json
|
|
160
|
+
* (改造前实现生成)逐字节锚定。
|
|
161
|
+
*
|
|
162
|
+
* 移植自 pi-subagents render.ts:44-89.
|
|
163
|
+
*/
|
|
164
|
+
export function truncLine(text: string, maxWidth: number): string {
|
|
165
|
+
if (maxWidth <= 0) return "";
|
|
166
|
+
// 剥离换行符(\n / \r):text 可能含多行 prompt / turn.text,\n 不占可见宽度
|
|
167
|
+
// 但在终端会换行,导致单行渲染意外变成多行,破坏行对齐(list overlay 左右列错位、
|
|
168
|
+
// tool block 行数跳变)。单行渲染入口必须保证无 \n。用空格替代(保留词边界可读性)。
|
|
169
|
+
const flat = text.replace(/[\r\n]+/g, " ");
|
|
170
|
+
if (visibleWidth(flat) <= maxWidth) return flat;
|
|
171
|
+
|
|
172
|
+
const targetWidth = Math.max(0, maxWidth - 1);
|
|
173
|
+
const state: TruncState = { result: "", currentWidth: 0, activeStyles: [] };
|
|
174
|
+
let i = 0;
|
|
175
|
+
|
|
176
|
+
while (i < flat.length) {
|
|
177
|
+
// 捕获 ANSI SGR 序列(sticky:只在 i 处匹配,等价旧 slice(i).match(/^.../m))
|
|
178
|
+
SGR_STICKY_RE.lastIndex = i;
|
|
179
|
+
const ansiMatch = SGR_STICKY_RE.exec(flat);
|
|
180
|
+
if (ansiMatch) {
|
|
181
|
+
applySgr(state, ansiMatch[0]);
|
|
182
|
+
i += ansiMatch[0].length;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const end = findNextSgrStart(flat, i);
|
|
187
|
+
if (consumeGraphemes(state, flat.slice(i, end), targetWidth)) {
|
|
188
|
+
return truncTail(state);
|
|
189
|
+
}
|
|
190
|
+
i = end;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 理论上 visibleWidth 检查已提前返回,此行兜底
|
|
194
|
+
return truncTail(state);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** truncLine 遍历累积态:已产出文本 / 已占可见宽度 / active SGR 栈。 */
|
|
198
|
+
interface TruncState {
|
|
199
|
+
result: string;
|
|
200
|
+
currentWidth: number;
|
|
201
|
+
activeStyles: string[];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 消费一个 SGR 序列:写入 result 并维护 activeStyles 栈(reset 清空 / 其他 push)。 */
|
|
205
|
+
function applySgr(state: TruncState, code: string): void {
|
|
206
|
+
state.result += code;
|
|
207
|
+
if (code === "\x1b[0m" || code === "\x1b[m") {
|
|
208
|
+
state.activeStyles = []; // reset → 清空栈
|
|
209
|
+
} else {
|
|
210
|
+
state.activeStyles.push(code);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 找 flat 自 from 起下一段纯文本的终点:下一处 SGR 序列起点(或串尾)。
|
|
216
|
+
*
|
|
217
|
+
* indexOf 跳到 ESC 候选位,仅对 ESC 位置做一次 sticky 判定;非 SGR ESC
|
|
218
|
+
* (OSC/\x1b[K/裸 \x1b)不构成边界、并入文本段(返回值推进到该 ESC 之后继续找),
|
|
219
|
+
* 与旧逐字符 while 的停点集合逐字节一致。
|
|
220
|
+
*/
|
|
221
|
+
function findNextSgrStart(s: string, from: number): number {
|
|
222
|
+
for (
|
|
223
|
+
let escPos = s.indexOf("\x1b", from);
|
|
224
|
+
escPos !== -1;
|
|
225
|
+
escPos = s.indexOf("\x1b", escPos + 1)
|
|
226
|
+
) {
|
|
227
|
+
if (isSgrStart(s, escPos)) {
|
|
228
|
+
return escPos;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return s.length;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 按 grapheme 迭代 textPortion 累加到 targetWidth;超宽时停笔并返回 true
|
|
236
|
+
* (截断点已到达,由调用方收尾)。
|
|
237
|
+
*/
|
|
238
|
+
function consumeGraphemes(state: TruncState, textPortion: string, targetWidth: number): boolean {
|
|
239
|
+
for (const seg of segmenter.segment(textPortion)) {
|
|
240
|
+
const grapheme = seg.segment;
|
|
241
|
+
const graphemeWidth = visibleWidth(grapheme);
|
|
242
|
+
if (state.currentWidth + graphemeWidth > targetWidth) {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
state.result += grapheme;
|
|
246
|
+
state.currentWidth += graphemeWidth;
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 截断尾部:重应用 active 样式 + 省略号 + reset。
|
|
253
|
+
*
|
|
254
|
+
* reset 不可省——否则行尾颜色渗透到 padToVisible 的填充空格、乃至下一帧行,
|
|
255
|
+
* 视觉上表现为颜色重影(被截断的着色延伸到行尾之外)。
|
|
256
|
+
* 但纯文本输入(activeStyles 为空)不发 reset——\x1b[0m 是全局重置,
|
|
257
|
+
* 会清除 theme.bg 施加的外层背景色(背景框内省略号后失去背景的根因)。
|
|
258
|
+
*/
|
|
259
|
+
function truncTail(state: TruncState): string {
|
|
260
|
+
return state.result + state.activeStyles.join("") + "…" + (state.activeStyles.length ? "\x1b[0m" : "");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 把长文本按指定可见宽度拆成多行(word-wrap),完整展示不截断。
|
|
265
|
+
*
|
|
266
|
+
* 用于 detail 模式完整展示长内容(task / output text)。detail 有翻屏能力,
|
|
267
|
+
* 不应像预览那样截断成一行省略号——信息完整性优先。
|
|
268
|
+
*
|
|
269
|
+
* 输入为纯文本(不含 ANSI 颜色)。调用方对返回的每行单独着色,
|
|
270
|
+
* 这样避免对 ANSI 文本做复杂 wrap(SGR 状态跨行续重应用)。
|
|
271
|
+
*
|
|
272
|
+
* grapheme 迭代(CJK/emoji 安全):逐字素累加可见宽度,超 maxWidth 即断行。
|
|
273
|
+
* CJK 可在任意字素间断(每个汉字占 2 列但可断),拉丁文不强制保留词边界
|
|
274
|
+
* (detail 场景优先完整性,width 足够宽时自然在空格附近断)。
|
|
275
|
+
*
|
|
276
|
+
* 原始 \n 保留为段落分隔(split 后每段独立 wrap,空行保留为 "")。
|
|
277
|
+
* 与 truncLine 的扁平化不同:wrapText 的产物是多行,\n 是有意的段落边界。
|
|
278
|
+
*/
|
|
279
|
+
export function wrapText(text: string, maxWidth: number): string[] {
|
|
280
|
+
if (maxWidth <= 0) return [text];
|
|
281
|
+
const result: string[] = [];
|
|
282
|
+
for (const para of text.split(/\r?\n/)) {
|
|
283
|
+
const trimmed = para.trim();
|
|
284
|
+
if (!trimmed) {
|
|
285
|
+
result.push("");
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
let line = "";
|
|
289
|
+
let lineWidth = 0;
|
|
290
|
+
for (const seg of segmenter.segment(trimmed)) {
|
|
291
|
+
const g = seg.segment;
|
|
292
|
+
const gw = visibleWidth(g);
|
|
293
|
+
if (lineWidth + gw > maxWidth && line.length > 0) {
|
|
294
|
+
result.push(line);
|
|
295
|
+
line = g;
|
|
296
|
+
lineWidth = gw;
|
|
297
|
+
} else {
|
|
298
|
+
line += g;
|
|
299
|
+
lineWidth += gw;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (line.length > 0) result.push(line);
|
|
303
|
+
}
|
|
304
|
+
return result;
|
|
305
|
+
}
|