zen-gitsync 2.15.33 → 2.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +25 -0
  2. package/package.json +2 -1
  3. package/scripts/run-tests.cjs +1 -0
  4. package/src/cli/ai/agent.js +845 -0
  5. package/src/cli/ai/agent.test.js +114 -0
  6. package/src/cli/ai/images.js +211 -0
  7. package/src/cli/ai/images.test.js +98 -0
  8. package/src/cli/ai/safety.js +215 -0
  9. package/src/cli/ai/safety.test.js +158 -0
  10. package/src/cli/ai/streamFilter.js +82 -0
  11. package/src/cli/ai/streamFilter.test.js +88 -0
  12. package/src/cli/ai/termui.js +378 -0
  13. package/src/cli/ai/termui.test.js +215 -0
  14. package/src/cli/ai/tools.js +482 -0
  15. package/src/cli/ai/tools.test.js +190 -0
  16. package/src/gitCommit.js +9 -0
  17. package/src/ui/public/assets/AppVersionBadge-CS7Trvp-.css +1 -0
  18. package/src/ui/public/assets/AppVersionBadge-DJyfXR_Q.js +2 -0
  19. package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +1 -0
  20. package/src/ui/public/assets/BranchSelector-ClnVPC0Q.js +1 -0
  21. package/src/ui/public/assets/CommitForm-BihM26_4.js +6 -0
  22. package/src/ui/public/assets/CommitForm-DdK8dymc.css +1 -0
  23. package/src/ui/public/assets/CommonDialog-CYoIF2fg.js +1 -0
  24. package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +1 -0
  25. package/src/ui/public/assets/EditorView-BVod-rjn.css +1 -0
  26. package/src/ui/public/assets/EditorView-DYJQT-Xj.js +0 -0
  27. package/src/ui/public/assets/FlowExecutionViewer-CPblwVA1.js +1 -0
  28. package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +1 -0
  29. package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +1 -0
  30. package/src/ui/public/assets/FlowOrchestrationWorkspace-Cc-Tnhng.js +21 -0
  31. package/src/ui/public/assets/LogList-CeFFytjB.css +1 -0
  32. package/src/ui/public/assets/LogList-va6ANtbL.js +15 -0
  33. package/src/ui/public/assets/MindmapView-3rMfOoNm.css +1 -0
  34. package/src/ui/public/assets/MindmapView-C_9P5ijq.js +1 -0
  35. package/src/ui/public/assets/MonitorView-DRPECOVW.js +1 -0
  36. package/src/ui/public/assets/MonitorView-TaS4kL0Y.css +1 -0
  37. package/src/ui/public/assets/ProjectStartupButton-DSovbKPE.js +1 -0
  38. package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +1 -0
  39. package/src/ui/public/assets/RemoteRepoCard-BRGNlbeO.css +1 -0
  40. package/src/ui/public/assets/RemoteRepoCard-C_cBtHcN.js +5 -0
  41. package/src/ui/public/assets/SourceMapView-BNRjvgzH.css +1 -0
  42. package/src/ui/public/assets/SourceMapView-ZiCL8mS8.js +3 -0
  43. package/src/ui/public/assets/SvgIcon-C9YQSvGU.js +1 -0
  44. package/src/ui/public/assets/{SvgIcon-BQ6bR7l0.css → SvgIcon-CDNsk4-Q.css} +1 -1
  45. package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +1 -0
  46. package/src/ui/public/assets/UserInputNode-CR6o8zJG.js +1 -0
  47. package/src/ui/public/assets/WorkbenchView-CJJrBqBH.js +28 -0
  48. package/src/ui/public/assets/{WorkbenchView-CQfjF0Gr.css → WorkbenchView-DbF4hH5s.css} +1 -1
  49. package/src/ui/public/assets/{_plugin-vue_export-helper-CzpRIaQR.js → _plugin-vue_export-helper-CHQqCylN.js} +1 -1
  50. package/src/ui/public/assets/{configStore-DaWjJLTW.js → configStore-Bw9U4AHx.js} +1 -1
  51. package/src/ui/public/assets/element-plus-CyGhXd7X.js +25 -0
  52. package/src/ui/public/assets/{flow-mindmap-2CcykUk_.js → flow-mindmap-CUvJaYn2.js} +1 -1
  53. package/src/ui/public/assets/index-BzNpkvm5.js +68 -0
  54. package/src/ui/public/assets/{index-CWoujklW.css → index-CaIu73UM.css} +1 -1
  55. package/src/ui/public/assets/socket-io-BSgn8Jx2.js +1 -0
  56. package/src/ui/public/assets/{vendor-CG4K5XlB.js → vendor-CU5NYUKJ.js} +44 -37
  57. package/src/ui/public/assets/vue-flow-CWmfpfPN.js +39 -0
  58. package/src/ui/public/index.html +12 -12
  59. package/src/utils/index.js +5 -0
  60. package/src/ui/public/assets/AppVersionBadge-Ckwa7eM9.js +0 -2
  61. package/src/ui/public/assets/AppVersionBadge-Dj6H729N.css +0 -1
  62. package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +0 -1
  63. package/src/ui/public/assets/BranchSelector-e0465kxE.js +0 -1
  64. package/src/ui/public/assets/CommitForm-862NAe0q.js +0 -6
  65. package/src/ui/public/assets/CommitForm-CUvl0VcF.css +0 -1
  66. package/src/ui/public/assets/CommonDialog-BVPvUoQ0.js +0 -1
  67. package/src/ui/public/assets/CommonDialog-Cyh3xu0p.css +0 -1
  68. package/src/ui/public/assets/EditorView-CRYGVw_r.css +0 -1
  69. package/src/ui/public/assets/EditorView-sZoyx-nK.js +0 -0
  70. package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +0 -1
  71. package/src/ui/public/assets/FlowExecutionViewer-CowPPIpm.js +0 -1
  72. package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +0 -1
  73. package/src/ui/public/assets/FlowOrchestrationWorkspace-Dno2fn7O.js +0 -21
  74. package/src/ui/public/assets/LogList-DJYpDUTZ.css +0 -1
  75. package/src/ui/public/assets/LogList-Dy42jDbI.js +0 -7
  76. package/src/ui/public/assets/MindmapView-CWoqfZRL.css +0 -1
  77. package/src/ui/public/assets/MindmapView-Ds2XDHgS.js +0 -1
  78. package/src/ui/public/assets/MonitorView-DxFogQIt.css +0 -1
  79. package/src/ui/public/assets/MonitorView-Dy158z7E.js +0 -1
  80. package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +0 -1
  81. package/src/ui/public/assets/ProjectStartupButton-eozWTyGl.js +0 -1
  82. package/src/ui/public/assets/RemoteRepoCard-CTfNrrx4.js +0 -1
  83. package/src/ui/public/assets/RemoteRepoCard-d0klonVb.css +0 -1
  84. package/src/ui/public/assets/SourceMapView-Bl5jEKf6.js +0 -3
  85. package/src/ui/public/assets/SourceMapView-DG_a_076.css +0 -1
  86. package/src/ui/public/assets/SvgIcon-D7FdXvWv.js +0 -1
  87. package/src/ui/public/assets/UserInputNode-Bcxe_5-C.js +0 -1
  88. package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +0 -1
  89. package/src/ui/public/assets/WorkbenchView-CZSPrO3Q.js +0 -12
  90. package/src/ui/public/assets/element-plus-d0668myq.js +0 -17
  91. package/src/ui/public/assets/index-CG24iOR5.js +0 -38
  92. package/src/ui/public/assets/socket-io-B1foIbJS.js +0 -1
  93. package/src/ui/public/assets/vue-flow-CVRzDji7.js +0 -39
@@ -0,0 +1,378 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // g ai 终端渲染层 — 对标 Codex CLI / Claude Code / OpenCode 的视觉效果:
16
+ //
17
+ // - 盒式输入框(Codex composer 风格):╭───╮ + ❯ 提示符 + ╰───╯
18
+ // - 等待 spinner(ora):首个 token 到达前持续转动
19
+ // - 思考过程:✻ 思考 头 + 灰色斜体流式输出
20
+ // - 工具调用块(Claude Code 风格):⏺ 工具头 + 智能参数摘要,
21
+ // 结果用 ⎿/│ 缩进槽,按退出码/错误前缀着色
22
+ // - 正文:⏺ 项目符号 + 逐行缓冲的轻量 markdown 渲染
23
+ // (**bold**、`code`、# 标题、``` 代码块、- 列表)
24
+ //
25
+ // 设计约束:
26
+ // - 纯 readline CLI(非全屏 TUI),所有渲染都是"追加式"的,不做光标重排
27
+ // - 非 TTY(管道/CI)下自动降级:不画边框、spinner 退化为静态文本
28
+ // - 所有需要单测的函数都支持注入 write 函数,不直接绑死 process.stdout
29
+
30
+ import chalk from 'chalk'
31
+ import ora from 'ora'
32
+ import { boxenAdaptive } from '../ui.js'
33
+
34
+ // ──────────────────────────────────────────────
35
+ // 常量
36
+ // ──────────────────────────────────────────────
37
+ export const DISPLAY_RESULT_LIMIT = 600 // 工具结果回显截断长度(完整结果仍进上下文)
38
+
39
+ // 回显截断的"最小省略量":只超出一两个字符时截断反而碍事(实测:git status
40
+ // 输出 614 字符被截,省略 14 字符还把路径从中间切断),不值得就不截
41
+ const TRUNCATE_MIN_OMITTED = 200
42
+
43
+ const MIN_BOX_WIDTH = 24 // 终端窄于这个宽度就不画输入框边框(画了也难看)
44
+
45
+ // ──────────────────────────────────────────────
46
+ // 基础工具
47
+ // ──────────────────────────────────────────────
48
+
49
+ /** 终端宽度;非 TTY / 获取不到时给 fallback */
50
+ export function termWidth(fallback = 100) {
51
+ const cols = process.stdout?.columns
52
+ return (typeof cols === 'number' && cols > 0 && Number.isFinite(cols)) ? cols : fallback
53
+ }
54
+
55
+ /** 去掉 ANSI 转义(测试断言用) */
56
+ export function stripAnsi(s) {
57
+ return String(s).replace(/\x1b\[[0-9;]*m/g, '')
58
+ }
59
+
60
+ /**
61
+ * 回显截断(纯函数,便于单测):
62
+ * - 只超出一点点(< TRUNCATE_MIN_OMITTED)时原样返回
63
+ * - 截断保留 头+尾,且切口对齐到整行边界 —— 避免把路径/单词从中间切断
64
+ * - 命令输出最关键的信息(报错、最终结果)通常在末尾,所以头尾都要
65
+ */
66
+ export function truncateDisplay(text, limit = DISPLAY_RESULT_LIMIT) {
67
+ text = String(text || '')
68
+ if (text.length <= limit + TRUNCATE_MIN_OMITTED) return text
69
+ const half = Math.floor(limit / 2)
70
+ const headRaw = text.slice(0, half)
71
+ const tailRaw = text.slice(text.length - half)
72
+ // 头部对齐到最后一个完整行;尾部对齐到第一个完整行
73
+ const lastNl = headRaw.lastIndexOf('\n')
74
+ const head = lastNl > 0 ? headRaw.slice(0, lastNl) : headRaw
75
+ const firstNl = tailRaw.indexOf('\n')
76
+ const tail = firstNl >= 0 ? tailRaw.slice(firstNl + 1) : tailRaw
77
+ const omitted = text.length - head.length - tail.length
78
+ return `${head}\n ⋮ [回显省略 ${omitted} 字符,完整结果已提供给模型]\n${tail}`
79
+ }
80
+
81
+ // ──────────────────────────────────────────────
82
+ // 横幅 / 帮助
83
+ // ──────────────────────────────────────────────
84
+
85
+ /** 启动横幅:模型 + 目录 + 快捷键提示,盒式自适应宽度 */
86
+ export function printBanner({ title, modelLabel, baseURL, cwd, modelText, cwdText, tip }) {
87
+ const lines = [
88
+ chalk.green.bold(title),
89
+ `${modelText}: ${chalk.cyan(modelLabel)}${baseURL ? ' ' + chalk.dim(baseURL) : ''}`,
90
+ `${cwdText}: ${chalk.cyan(cwd)}`,
91
+ chalk.dim(tip),
92
+ ].join('\n')
93
+ process.stdout.write(
94
+ boxenAdaptive(lines, {
95
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
96
+ margin: { top: 1, bottom: 0, left: 0, right: 0 },
97
+ borderColor: 'cyan',
98
+ borderStyle: 'round',
99
+ }) + '\n'
100
+ )
101
+ }
102
+
103
+ /** 盒式帮助面板 */
104
+ export function printHelpPanel(title, lines) {
105
+ const body = [chalk.bold(title), ...lines].join('\n')
106
+ process.stdout.write(
107
+ boxenAdaptive(body, {
108
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
109
+ margin: { top: 1, bottom: 0, left: 0, right: 0 },
110
+ borderColor: 'gray',
111
+ borderStyle: 'round',
112
+ }) + '\n'
113
+ )
114
+ }
115
+
116
+ // ──────────────────────────────────────────────
117
+ // 盒式输入框(Codex composer 风格)
118
+ // ──────────────────────────────────────────────
119
+ //
120
+ // ╭────────────────────────────────────────╮ ← drawInputTop()
121
+ // ❯ 用户输入… ← readline prompt(光标停在这行)
122
+ // ╰────────────────────────────────────────╯ ← 与上行同时画好,输入时就看到完整框
123
+ //
124
+ // readline 只能管一行提示符,边框由我们补画:
125
+ // 画完下边框后由调用方把光标移回输入行(readline 的刷新只动当前行,互不影响);
126
+ // 用户回车后光标正好落在下边框行,write('\n') 越过它即可,框完整留在回显里。
127
+ // 非 TTY 或终端过窄时静默降级为裸提示符。
128
+
129
+ function borderLine(left, right) {
130
+ const w = Math.max(MIN_BOX_WIDTH, termWidth() - 2)
131
+ return chalk.dim(left + '─'.repeat(w) + right)
132
+ }
133
+
134
+ export function drawInputTop(write = (s) => process.stdout.write(s)) {
135
+ if (!process.stdout.isTTY) return
136
+ write(borderLine('╭', '╮') + '\n')
137
+ }
138
+
139
+ /** 下边框字符串(不带换行)—— 由调用方在 prompt 之后补画并自行恢复光标 */
140
+ export function inputBottomBorder() {
141
+ return borderLine('╰', '╯')
142
+ }
143
+
144
+ export function drawInputBottom(write = (s) => process.stdout.write(s)) {
145
+ if (!process.stdout.isTTY) return
146
+ write(borderLine('╰', '╯') + '\n')
147
+ }
148
+
149
+ // ──────────────────────────────────────────────
150
+ // 等待 spinner(ora 包装)
151
+ // ──────────────────────────────────────────────
152
+ //
153
+ // 首个 token 到达前转动;到达后 stop() 清行,接着流式输出。
154
+ // 非 TTY 时 ora 自动退化为只打印一次文本。
155
+
156
+ export function startSpinner(text) {
157
+ const spinner = ora({
158
+ text: chalk.dim(text),
159
+ spinner: 'dots',
160
+ color: 'cyan',
161
+ // ora 默认 discardStdin: true,内部用 stdin-discarder 在 spinner 运行时
162
+ // 丢弃 stdin 输入。但 stdin-discarder 的 stop() 在 Windows 上有 bug:
163
+ // start() 跳过 Windows,但 stop() 没有 Windows 检查,无条件执行
164
+ // process.stdin.pause() + setRawMode(false),导致 readline 的 stdin
165
+ // 停止 flowing → 事件循环失去引用 → 进程退出。
166
+ // 关闭这个选项即可避免问题。
167
+ discardStdin: false,
168
+ })
169
+ spinner.start()
170
+ return {
171
+ stop() {
172
+ if (spinner.isSpinning) spinner.stop()
173
+ },
174
+ }
175
+ }
176
+
177
+ // ──────────────────────────────────────────────
178
+ // 流式回复 writer:思考(灰斜体) + 正文(⏺ + 轻量 markdown)
179
+ // ──────────────────────────────────────────────
180
+
181
+ /** 行内 markdown:`code` 优先提取防干扰,再处理 **bold** */
182
+ function renderInline(line) {
183
+ const spans = []
184
+ // 先抠出 inline code,避免其中的 * 被 bold 规则误吃
185
+ line = line.replace(/`([^`\n]+)`/g, (m, c) => {
186
+ spans.push(c)
187
+ return `\u0000${spans.length - 1}\u0000`
188
+ })
189
+ line = line.replace(/\*\*([^*\n]+)\*\*/g, (m, c) => chalk.bold(c))
190
+ line = line.replace(/\u0000(\d+)\u0000/g, (m, i) => chalk.cyan(spans[Number(i)]))
191
+ return line
192
+ }
193
+
194
+ /**
195
+ * 创建一个流式回复 writer。
196
+ *
197
+ * @param {object} [opts]
198
+ * @param {boolean} [opts.showThinking=true] - false 时丢弃思考段(不影响历史)
199
+ * @param {string} [opts.thinkingHeader] - 思考段头文案(如 "✻ 思考")
200
+ * @param {(s:string)=>void} [opts.write] - 输出函数,默认写 stdout(测试可注入)
201
+ */
202
+ export function createAssistantWriter({
203
+ showThinking = true,
204
+ thinkingHeader = '✻ 思考',
205
+ write = (s) => process.stdout.write(s),
206
+ } = {}) {
207
+ let mode = null // null | 'thinking' | 'content'
208
+ let lineBuf = '' // 正文行缓冲(逐行渲染,保证 ** 等标记完整)
209
+ let inFence = false // ``` 代码块状态
210
+ let contentLines = 0 // 已输出正文行数(首行带 ⏺ 子弹头)
211
+ let lastBlank = false // 上一行是空白行(连续空行合并,避免模型输出头部/分隔空行刷屏)
212
+ // ⏺ 后留 2 个空格,后续行 3 空格对齐 — 图标与文字之间别太挤
213
+ const BULLET_FIRST = chalk.green('⏺') + ' '
214
+ const BULLET_REST = ' '
215
+
216
+ const emitContentLine = (raw, withNewline = true) => {
217
+ // 围栏标记行:切换状态,用一个淡淡的槽线代替裸 ```
218
+ if (/^\s*```/.test(raw)) {
219
+ inFence = !inFence
220
+ const bullet = contentLines === 0 ? BULLET_FIRST : BULLET_REST
221
+ contentLines++
222
+ lastBlank = false
223
+ write(bullet + chalk.dim(inFence ? '┄ code ' + '┄'.repeat(8) : '┄'.repeat(14)) + (withNewline ? '\n' : ''))
224
+ return
225
+ }
226
+ // 围栏内:代码行原样带槽线(空行也保留,代码格式不能动)
227
+ if (inFence) {
228
+ const bullet = contentLines === 0 ? BULLET_FIRST : BULLET_REST
229
+ contentLines++
230
+ lastBlank = false
231
+ write(bullet + chalk.dim('│ ') + raw + (withNewline ? '\n' : ''))
232
+ return
233
+ }
234
+ // 空白行:首个正文行之前不输出;连续空行合并为一行
235
+ if (raw.trim() === '') {
236
+ if (contentLines === 0 || lastBlank) return
237
+ lastBlank = true
238
+ write('\n')
239
+ return
240
+ }
241
+ lastBlank = false
242
+ const bullet = contentLines === 0 ? BULLET_FIRST : BULLET_REST
243
+ contentLines++
244
+ const h = raw.match(/^(#{1,6})\s+(.*)$/)
245
+ const body = h ? chalk.bold(renderInline(h[2])) : renderInline(raw)
246
+ write(bullet + body + (withNewline ? '\n' : ''))
247
+ }
248
+
249
+ const flushLineBuf = (withNewline = true) => {
250
+ if (!lineBuf) return
251
+ emitContentLine(lineBuf, withNewline)
252
+ lineBuf = ''
253
+ }
254
+
255
+ return {
256
+ /** 思考段:灰斜体直写(不做 markdown),段头只打印一次 */
257
+ writeThinking(text) {
258
+ if (!showThinking || !text) return
259
+ if (mode !== 'thinking') {
260
+ flushLineBuf()
261
+ mode = 'thinking'
262
+ write('\n' + chalk.dim.italic(thinkingHeader) + '\n')
263
+ }
264
+ write(chalk.dim.italic(text))
265
+ },
266
+
267
+ /** 正文段:⏺ 子弹头 + 逐行 markdown 渲染 */
268
+ writeContent(text) {
269
+ if (!text) return
270
+ if (mode !== 'content') {
271
+ if (mode === 'thinking') write('\n')
272
+ mode = 'content'
273
+ }
274
+ lineBuf += text
275
+ let idx
276
+ while ((idx = lineBuf.indexOf('\n')) >= 0) {
277
+ emitContentLine(lineBuf.slice(0, idx))
278
+ lineBuf = lineBuf.slice(idx + 1)
279
+ }
280
+ },
281
+
282
+ /** 流结束:冲刷行缓冲,补结尾换行(任何段都没输出过则不产生空行) */
283
+ finish() {
284
+ if (mode === null) return
285
+ flushLineBuf(false)
286
+ write('\n')
287
+ },
288
+ }
289
+ }
290
+
291
+ // ──────────────────────────────────────────────
292
+ // 工具调用块(Claude Code 风格)
293
+ // ──────────────────────────────────────────────
294
+
295
+ /** 按工具类型生成一行参数摘要(纯函数,便于单测) */
296
+ export function summarizeToolArgs(name, args, { chars = '字符' } = {}) {
297
+ const clamp = (s, n = 120) => {
298
+ s = String(s ?? '').replace(/\s+/g, ' ').trim()
299
+ return s.length > n ? s.slice(0, n) + '…' : s
300
+ }
301
+ switch (name) {
302
+ case 'run_command':
303
+ return '$ ' + clamp(args?.command)
304
+ case 'read_file': {
305
+ const range = (args?.offset || args?.limit)
306
+ ? ` L${args?.offset || 1}-${(args?.offset || 1) + (args?.limit || 2000) - 1}`
307
+ : ''
308
+ return clamp(`${args?.path || ''}${range}`)
309
+ }
310
+ case 'write_file':
311
+ return clamp(`${args?.path || ''} (${String(args?.content ?? '').length} ${chars})`)
312
+ case 'edit_file':
313
+ return clamp(args?.path || '')
314
+ case 'list_files':
315
+ return clamp(`${args?.path || '.'} depth=${args?.depth || 2}`)
316
+ case 'search_text':
317
+ return clamp(`/${args?.pattern || ''}/ ${args?.path || '.'}`)
318
+ default:
319
+ return clamp(JSON.stringify(args ?? {}))
320
+ }
321
+ }
322
+
323
+ /** 工具头:⏺ name 参数摘要(图标与文字之间 2 空格,别太挤;青色与正文绿色子弹头区分) */
324
+ export function printToolHeader(name, summary, write = (s) => process.stdout.write(s)) {
325
+ write('\n' + chalk.cyan('⏺') + ' ' + chalk.bold(name) + (summary ? ' ' + chalk.dim(summary) : '') + '\n')
326
+ }
327
+
328
+ /**
329
+ * 工具结果块:
330
+ * └─ 首行
331
+ * │ 后续行…
332
+ * 退出码非 0 → 黄色;"错误/已拒绝"开头 → 红色;其余暗色。
333
+ * (首行用 └─ 而不是 Claude 的 ⎿:box-drawing 字符在 Windows 终端字体里渲染更稳,
334
+ * 且与输入框 ╭╰ 边框同一字符族,视觉更统一)
335
+ */
336
+ export function printToolResult(result, write = (s) => process.stdout.write(s)) {
337
+ const text = truncateDisplay(result)
338
+ const exitMatch = text.match(/^\$[^\n]*\n\(exit (\d+)\)/)
339
+ const exitCode = exitMatch ? Number(exitMatch[1]) : null
340
+ const isError = exitCode !== null && exitCode !== 0
341
+ || /^(错误|已拒绝|Error)/.test(text.trim())
342
+ const colorize = isError ? chalk.yellow : chalk.dim
343
+ const lines = text.split('\n')
344
+ const rendered = lines.map((l, i) => {
345
+ const gutter = i === 0 ? chalk.dim(' └─ ') : chalk.dim(' │ ')
346
+ return gutter + colorize(l)
347
+ }).join('\n')
348
+ write(rendered + '\n')
349
+ }
350
+
351
+ // ──────────────────────────────────────────────
352
+ // 杂项状态行
353
+ // ──────────────────────────────────────────────
354
+ export const printOk = (s, write = (x) => process.stdout.write(x)) => write(chalk.green(s) + '\n')
355
+ export const printWarn = (s, write = (x) => process.stdout.write(x)) => write(chalk.yellow(s) + '\n')
356
+ export const printError = (s, write = (x) => process.stdout.write(x)) => write(chalk.red(s) + '\n')
357
+ export const printDim = (s, write = (x) => process.stdout.write(x)) => write(chalk.dim(s) + '\n')
358
+
359
+ export default {
360
+ DISPLAY_RESULT_LIMIT,
361
+ termWidth,
362
+ stripAnsi,
363
+ truncateDisplay,
364
+ printBanner,
365
+ printHelpPanel,
366
+ drawInputTop,
367
+ drawInputBottom,
368
+ inputBottomBorder,
369
+ startSpinner,
370
+ createAssistantWriter,
371
+ summarizeToolArgs,
372
+ printToolHeader,
373
+ printToolResult,
374
+ printOk,
375
+ printWarn,
376
+ printError,
377
+ printDim,
378
+ }
@@ -0,0 +1,215 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // src/cli/ai/termui.js 单元测试:
16
+ // - 渲染结构用注入 write 的方式收集输出,stripAnsi 后断言纯文本结构
17
+ // - ANSI 着色本身不在断言范围(非 TTY 下 chalk 自动降级,测了也没意义)
18
+ import { test } from 'node:test'
19
+ import assert from 'node:assert/strict'
20
+ import {
21
+ stripAnsi, truncateDisplay, summarizeToolArgs,
22
+ createAssistantWriter, printToolHeader, printToolResult,
23
+ } from './termui.js'
24
+
25
+ // 收集 write 输出的辅助:返回 {text(), lines()}
26
+ function collect() {
27
+ let buf = ''
28
+ return {
29
+ write: (s) => { buf += s },
30
+ text: () => stripAnsi(buf),
31
+ lines: () => stripAnsi(buf).split('\n'),
32
+ }
33
+ }
34
+
35
+ // ── truncateDisplay(回归基础,主测试在 agent.test.js)──
36
+ test('truncateDisplay: 短文本原样,长文本头尾保留', () => {
37
+ assert.equal(truncateDisplay('abc'), 'abc')
38
+ const long = Array.from({ length: 40 }, (_, i) => `row${i}-` + 'x'.repeat(40)).join('\n')
39
+ const r = truncateDisplay(long, 600)
40
+ assert.match(r, /回显省略/)
41
+ assert.ok(r.startsWith('row0'))
42
+ assert.ok(r.trimEnd().endsWith('x'.repeat(40)))
43
+ })
44
+
45
+ // ── summarizeToolArgs ──
46
+ test('summarizeToolArgs: run_command 带 $ 前缀', () => {
47
+ assert.equal(summarizeToolArgs('run_command', { command: 'npm test' }), '$ npm test')
48
+ })
49
+
50
+ test('summarizeToolArgs: read_file 行范围与非范围', () => {
51
+ assert.equal(summarizeToolArgs('read_file', { path: 'a.js' }), 'a.js')
52
+ assert.equal(
53
+ summarizeToolArgs('read_file', { path: 'a.js', offset: 10, limit: 50 }),
54
+ 'a.js L10-59',
55
+ )
56
+ })
57
+
58
+ test('summarizeToolArgs: write_file 显示字符数,单位可本地化', () => {
59
+ assert.equal(summarizeToolArgs('write_file', { path: 'a.js', content: 'abcd' }), 'a.js (4 字符)')
60
+ assert.equal(summarizeToolArgs('write_file', { path: 'a.js', content: 'abcd' }, { chars: 'chars' }), 'a.js (4 chars)')
61
+ })
62
+
63
+ test('summarizeToolArgs: 其余工具的摘要格式', () => {
64
+ assert.equal(summarizeToolArgs('edit_file', { path: 'a.js' }), 'a.js')
65
+ assert.equal(summarizeToolArgs('list_files', {}), '. depth=2')
66
+ assert.equal(summarizeToolArgs('search_text', { pattern: 'foo', path: 'src' }), '/foo/ src')
67
+ })
68
+
69
+ test('summarizeToolArgs: 超长命令截断到 120 字符', () => {
70
+ const s = summarizeToolArgs('run_command', { command: 'x'.repeat(200) })
71
+ assert.ok(s.length <= 123) // '$ ' 前缀 + 120 字符 + '…'
72
+ assert.ok(s.endsWith('…'))
73
+ })
74
+
75
+ test('summarizeToolArgs: 未知工具回退 JSON,缺参数不炸', () => {
76
+ assert.equal(summarizeToolArgs('whatever', { a: 1 }), '{"a":1}')
77
+ assert.equal(summarizeToolArgs('run_command', undefined), '$ ')
78
+ })
79
+
80
+ // ── createAssistantWriter ──
81
+ test('writer: 思考段头部只打印一次,内容直写', () => {
82
+ const c = collect()
83
+ const w = createAssistantWriter({ thinkingHeader: '✻ 思考', write: c.write })
84
+ w.writeThinking('第一段')
85
+ w.writeThinking('第二段')
86
+ w.finish()
87
+ const text = c.text()
88
+ assert.equal(text.match(/✻ 思考/g).length, 1)
89
+ assert.ok(text.includes('第一段第二段'))
90
+ })
91
+
92
+ test('writer: showThinking=false 时丢弃思考段', () => {
93
+ const c = collect()
94
+ const w = createAssistantWriter({ showThinking: false, thinkingHeader: '✻ 思考', write: c.write })
95
+ w.writeThinking('看不见')
96
+ w.writeContent('正文\n')
97
+ w.finish()
98
+ const text = c.text()
99
+ assert.ok(!text.includes('看不见'))
100
+ assert.ok(!text.includes('✻ 思考'))
101
+ assert.ok(text.includes('正文'))
102
+ })
103
+
104
+ test('writer: 正文首行 ⏺ 子弹头,后续行缩进对齐', () => {
105
+ const c = collect()
106
+ const w = createAssistantWriter({ write: c.write })
107
+ w.writeContent('第一行\n第二行\n第三行')
108
+ w.finish()
109
+ const lines = c.lines()
110
+ assert.ok(lines.some(l => l.startsWith('⏺ 第一行')))
111
+ assert.ok(lines.some(l => l.startsWith(' 第二行')))
112
+ assert.ok(lines.some(l => l.startsWith(' 第三行')))
113
+ })
114
+
115
+ test('writer: 行内 markdown — bold 标记被消费,code 反引号被消费', () => {
116
+ const c = collect()
117
+ const w = createAssistantWriter({ write: c.write })
118
+ w.writeContent('这是 **加粗** 和 `代码` 混排\n')
119
+ w.finish()
120
+ const text = c.text()
121
+ assert.ok(text.includes('加粗'))
122
+ assert.ok(text.includes('代码'))
123
+ assert.ok(!text.includes('**'), '不应残留 ** 标记')
124
+ assert.ok(!text.includes('`'), '不应残留反引号')
125
+ })
126
+
127
+ test('writer: 标题与列表渲染', () => {
128
+ const c = collect()
129
+ const w = createAssistantWriter({ write: c.write })
130
+ w.writeContent('## 标题文字\n- 列表项\n')
131
+ w.finish()
132
+ const text = c.text()
133
+ assert.ok(text.includes('标题文字'))
134
+ assert.ok(!text.includes('##'), '标题井号应被消费')
135
+ assert.ok(text.includes('- 列表项'))
136
+ })
137
+
138
+ test('writer: 代码围栏内容带槽线,围栏标记不原样显示', () => {
139
+ const c = collect()
140
+ const w = createAssistantWriter({ write: c.write })
141
+ w.writeContent('```js\nconst x = 1\n```\n围栏外\n')
142
+ w.finish()
143
+ const lines = c.lines()
144
+ assert.ok(lines.some(l => l.includes('│ const x = 1')), '代码行应有 │ 槽线')
145
+ assert.ok(!lines.some(l => l.trim() === '```js' || l.trim() === '```'), '裸围栏标记不应出现')
146
+ assert.ok(lines.some(l => l.includes('围栏外')))
147
+ })
148
+
149
+ test('writer: ** 标记跨 chunk 也能正确渲染(行缓冲)', () => {
150
+ const c = collect()
151
+ const w = createAssistantWriter({ write: c.write })
152
+ // ** 被流式分片切开:buf 里暂不渲染,等行尾到了一次性渲染
153
+ w.writeContent('这是 *')
154
+ w.writeContent('*加粗** 文本\n')
155
+ w.finish()
156
+ const text = c.text()
157
+ assert.ok(text.includes('加粗'))
158
+ assert.ok(!text.includes('**'))
159
+ })
160
+
161
+ test('writer: 思考→正文切换插入换行分隔', () => {
162
+ const c = collect()
163
+ const w = createAssistantWriter({ thinkingHeader: '✻ 思考', write: c.write })
164
+ w.writeThinking('想了一下')
165
+ w.writeContent('正文\n')
166
+ w.finish()
167
+ const text = c.text()
168
+ assert.ok(/想了一下\n/.test(text), '思考与正文之间应有换行')
169
+ })
170
+
171
+ test('writer: 完全无输出时 finish 不产生空行', () => {
172
+ const c = collect()
173
+ const w = createAssistantWriter({ write: c.write })
174
+ w.finish()
175
+ assert.equal(c.text(), '')
176
+ })
177
+
178
+ test('writer: 头部空白行被吞掉,连续空白行合并(真实 MiniMax 输出形态)', () => {
179
+ const c = collect()
180
+ const w = createAssistantWriter({ write: c.write })
181
+ // 模拟模型先吐几个空行再出正文的场景(实测 minimax-m2.7 的流式输出)
182
+ w.writeContent('\n\n\n')
183
+ w.writeContent('答案\n\n\n\n下一段\n')
184
+ w.finish()
185
+ const lines = c.lines()
186
+ assert.equal(lines[0], '⏺ 答案', `首行应为 ⏺ 答案,实际: ${lines[0]}`)
187
+ // "答案"与"下一段"之间最多一个空行,且空行不带缩进
188
+ const midBlank = lines.slice(1, lines.indexOf(' 下一段')).filter(l => l === '').length
189
+ assert.ok(midBlank <= 1, `空行应被合并,实际行: ${JSON.stringify(lines)}`)
190
+ assert.ok(lines.includes(' 下一段'))
191
+ })
192
+
193
+ // ── 工具块 ──
194
+ test('printToolHeader: ⏺ + 名称 + 摘要', () => {
195
+ const c = collect()
196
+ printToolHeader('run_command', '$ npm test', c.write)
197
+ const text = c.text()
198
+ assert.ok(text.includes('⏺ run_command'))
199
+ assert.ok(text.includes('$ npm test'))
200
+ })
201
+
202
+ test('printToolResult: 首行 └─,后续行 │ 槽线', () => {
203
+ const c = collect()
204
+ printToolResult('$ ls\n(exit 0)\nfile1\nfile2', c.write)
205
+ const lines = c.lines()
206
+ assert.ok(lines[0].startsWith(' └─ '), `首行应为 └─ 槽线,实际: ${lines[0]}`)
207
+ assert.ok(lines.slice(1).every(l => l.startsWith(' │ ') || l === ''), '后续行应为 │ 槽线')
208
+ })
209
+
210
+ test('printToolResult: 超长结果被截断并含省略标记', () => {
211
+ const c = collect()
212
+ const long = Array.from({ length: 60 }, (_, i) => `line${i} ` + 'y'.repeat(40)).join('\n')
213
+ printToolResult(long, c.write)
214
+ assert.match(c.text(), /回显省略/)
215
+ })