zen-gitsync 2.15.34 → 2.16.2
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 +37 -3
- package/package.json +1 -1
- package/src/cli/ai/agent.js +493 -83
- package/src/cli/ai/agent.test.js +190 -0
- package/src/cli/ai/images.js +211 -0
- package/src/cli/ai/images.test.js +98 -0
- package/src/cli/ai/modelSetup.js +594 -0
- package/src/cli/ai/modelSetup.test.js +496 -0
- package/src/cli/ai/platformGuard.js +200 -0
- package/src/cli/ai/platformGuard.test.js +253 -0
- package/src/cli/ai/streamFilter.js +82 -0
- package/src/cli/ai/streamFilter.test.js +88 -0
- package/src/cli/ai/termui.js +453 -0
- package/src/cli/ai/termui.test.js +253 -0
- package/src/cli/ai/tools.js +54 -18
- package/src/cli/ai/tools.test.js +28 -0
- package/src/ui/public/assets/AppVersionBadge-BTznOVSJ.js +2 -0
- package/src/ui/public/assets/AppVersionBadge-CS7Trvp-.css +1 -0
- package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +1 -0
- package/src/ui/public/assets/BranchSelector-BISqgXoZ.js +1 -0
- package/src/ui/public/assets/CommitForm-C7FmI0Sl.js +6 -0
- package/src/ui/public/assets/CommitForm-DdK8dymc.css +1 -0
- package/src/ui/public/assets/CommonDialog-BA4xg5l3.js +1 -0
- package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +1 -0
- package/src/ui/public/assets/EditorView-BVod-rjn.css +1 -0
- package/src/ui/public/assets/EditorView-CSWy4q7G.js +0 -0
- package/src/ui/public/assets/FlowExecutionViewer-CZdF1f1e.js +1 -0
- package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-BECFOAcQ.js +21 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +1 -0
- package/src/ui/public/assets/LogList-CeFFytjB.css +1 -0
- package/src/ui/public/assets/LogList-DYoGtOou.js +15 -0
- package/src/ui/public/assets/MindmapView-3rMfOoNm.css +1 -0
- package/src/ui/public/assets/MindmapView-CU3tXgbR.js +1 -0
- package/src/ui/public/assets/MonitorView-CSAqU7oV.js +1 -0
- package/src/ui/public/assets/MonitorView-TaS4kL0Y.css +1 -0
- package/src/ui/public/assets/ProjectStartupButton-2By0-fuq.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +1 -0
- package/src/ui/public/assets/RemoteRepoCard-BRGNlbeO.css +1 -0
- package/src/ui/public/assets/RemoteRepoCard-DHsJwTEX.js +5 -0
- package/src/ui/public/assets/SourceMapView-BNRjvgzH.css +1 -0
- package/src/ui/public/assets/SourceMapView-DskfUia5.js +3 -0
- package/src/ui/public/assets/SvgIcon-BqWp2WS8.js +1 -0
- package/src/ui/public/assets/{SvgIcon-BQ6bR7l0.css → SvgIcon-CDNsk4-Q.css} +1 -1
- package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +1 -0
- package/src/ui/public/assets/UserInputNode-Ccnh0e-0.js +1 -0
- package/src/ui/public/assets/WorkbenchView-1q0xaqyV.js +28 -0
- package/src/ui/public/assets/{WorkbenchView-CQfjF0Gr.css → WorkbenchView-DbF4hH5s.css} +1 -1
- package/src/ui/public/assets/{_plugin-vue_export-helper-B7s-K_Aj.js → _plugin-vue_export-helper-BQRl7WAH.js} +1 -1
- package/src/ui/public/assets/{configStore-DPGao81A.js → configStore-BkxDLXGa.js} +1 -1
- package/src/ui/public/assets/element-plus-DNnePjco.js +32 -0
- package/src/ui/public/assets/{flow-mindmap-5SDymYbY.js → flow-mindmap-DRmzYE4F.js} +1 -1
- package/src/ui/public/assets/index-Br2VZQBT.js +68 -0
- package/src/ui/public/assets/{index-CWoujklW.css → index-CaIu73UM.css} +1 -1
- package/src/ui/public/assets/socket-io-pmu5tRPo.js +1 -0
- package/src/ui/public/assets/{vendor-BteTDIoK.js → vendor-CcK8n8T4.js} +44 -37
- package/src/ui/public/assets/vue-flow-pAz--378.js +39 -0
- package/src/ui/public/index.html +12 -12
- package/src/ui/public/assets/AppVersionBadge-DL0Y81fU.js +0 -2
- package/src/ui/public/assets/AppVersionBadge-Dj6H729N.css +0 -1
- package/src/ui/public/assets/BranchSelector-B_VMrSKv.js +0 -1
- package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +0 -1
- package/src/ui/public/assets/CommitForm-BNuwp3ha.js +0 -6
- package/src/ui/public/assets/CommitForm-CUvl0VcF.css +0 -1
- package/src/ui/public/assets/CommonDialog-C2dbNSrw.js +0 -1
- package/src/ui/public/assets/CommonDialog-Cyh3xu0p.css +0 -1
- package/src/ui/public/assets/EditorView-BdTQsQw7.js +0 -0
- package/src/ui/public/assets/EditorView-CRYGVw_r.css +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-D5DBcy8S.js +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-omHN8HaT.js +0 -21
- package/src/ui/public/assets/LogList-DJYpDUTZ.css +0 -1
- package/src/ui/public/assets/LogList-Dc1RMEFq.js +0 -7
- package/src/ui/public/assets/MindmapView-BPTh-XAf.js +0 -1
- package/src/ui/public/assets/MindmapView-CWoqfZRL.css +0 -1
- package/src/ui/public/assets/MonitorView-DxFogQIt.css +0 -1
- package/src/ui/public/assets/MonitorView-RTbnLZlO.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +0 -1
- package/src/ui/public/assets/ProjectStartupButton-CcQ8SaPb.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-Cr1yQ4pF.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-d0klonVb.css +0 -1
- package/src/ui/public/assets/SourceMapView-BbiYmCHB.js +0 -3
- package/src/ui/public/assets/SourceMapView-DG_a_076.css +0 -1
- package/src/ui/public/assets/SvgIcon-D3jL_b3c.js +0 -1
- package/src/ui/public/assets/UserInputNode-DCk_cvaT.js +0 -1
- package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +0 -1
- package/src/ui/public/assets/WorkbenchView-KX3nKGZc.js +0 -12
- package/src/ui/public/assets/element-plus-BAcS68uS.js +0 -24
- package/src/ui/public/assets/index-B8oXZO4w.js +0 -38
- package/src/ui/public/assets/socket-io-BC2cap_-.js +0 -1
- package/src/ui/public/assets/vue-flow-CtZ_ZWKZ.js +0 -39
|
@@ -0,0 +1,453 @@
|
|
|
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
|
+
// 斜杠命令即时提示(输入 / 时在提示符下方浮现,随输入过滤)
|
|
105
|
+
// ──────────────────────────────────────────────
|
|
106
|
+
//
|
|
107
|
+
// 命令元数据集中放这里,printSlashHelp(agent.js)与即时提示共用同一份来源,
|
|
108
|
+
// 新增命令时只改这一处。desc 分中英,由调用方按 locale 取用。
|
|
109
|
+
|
|
110
|
+
export const SLASH_COMMANDS = [
|
|
111
|
+
{ cmd: '/help', descZh: '显示帮助', descEn: 'Show help' },
|
|
112
|
+
{ cmd: '/model', descZh: '列出 / 切换模型', descEn: 'List / switch models' },
|
|
113
|
+
{ cmd: '/addmodel', descZh: '添加模型配置(向导)', descEn: 'Add a model (wizard)' },
|
|
114
|
+
{ cmd: '/cd', descZh: '切换工作目录', descEn: 'Change working directory' },
|
|
115
|
+
{ cmd: '/image', descZh: '附加 / 查看图片', descEn: 'Attach / list images' },
|
|
116
|
+
{ cmd: '/think', descZh: '开关思考过程显示', descEn: 'Toggle thinking display' },
|
|
117
|
+
{ cmd: '/clear', descZh: '清空对话历史', descEn: 'Clear conversation' },
|
|
118
|
+
{ cmd: '/exit', descZh: '退出', descEn: 'Quit' },
|
|
119
|
+
{ cmd: '/quit', descZh: '退出', descEn: 'Quit' },
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 按当前输入过滤斜杠命令(纯函数,便于单测)。
|
|
124
|
+
* - 仅当输入以 / 开头、且尚未输入空格(还在敲命令名)时才提示
|
|
125
|
+
* - 前缀匹配,大小写不敏感
|
|
126
|
+
* - 精确等于某命令且无后续参数时仍然展示该命令(便于确认拼写)
|
|
127
|
+
* @returns {{cmd:string, desc:string}[]}
|
|
128
|
+
*/
|
|
129
|
+
export function filterSlashCommands(input, locale) {
|
|
130
|
+
const zh = !String(locale || '').startsWith('en')
|
|
131
|
+
const line = String(input || '')
|
|
132
|
+
if (!line.startsWith('/') || /\s/.test(line)) return []
|
|
133
|
+
const q = line.toLowerCase()
|
|
134
|
+
return SLASH_COMMANDS
|
|
135
|
+
.filter((c) => c.cmd.startsWith(q))
|
|
136
|
+
.map((c) => ({ cmd: c.cmd, desc: zh ? c.descZh : c.descEn }))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 生成即时提示面板的 ANSI 字符串(不含定位,由调用方负责保存/恢复光标)。
|
|
141
|
+
* 每行:两空格缩进 + 青色命令名(左对齐补齐)+ 灰色说明。
|
|
142
|
+
* 空数组返回空串。
|
|
143
|
+
*/
|
|
144
|
+
export function renderSlashHintBody(matches) {
|
|
145
|
+
if (!Array.isArray(matches) || matches.length === 0) return ''
|
|
146
|
+
return matches
|
|
147
|
+
.map((m) => ' ' + chalk.cyan(String(m.cmd).padEnd(12)) + chalk.dim(m.desc))
|
|
148
|
+
.join('\n')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 盒式帮助面板 */
|
|
152
|
+
export function printHelpPanel(title, lines) {
|
|
153
|
+
const body = [chalk.bold(title), ...lines].join('\n')
|
|
154
|
+
process.stdout.write(
|
|
155
|
+
boxenAdaptive(body, {
|
|
156
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
157
|
+
margin: { top: 1, bottom: 0, left: 0, right: 0 },
|
|
158
|
+
borderColor: 'gray',
|
|
159
|
+
borderStyle: 'round',
|
|
160
|
+
}) + '\n'
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ──────────────────────────────────────────────
|
|
165
|
+
// 盒式输入框(Codex composer 风格)
|
|
166
|
+
// ──────────────────────────────────────────────
|
|
167
|
+
//
|
|
168
|
+
// ╭────────────────────────────────────────╮ ← drawInputTop()
|
|
169
|
+
// ❯ 用户输入… ← readline prompt(光标停在这行)
|
|
170
|
+
// ╰────────────────────────────────────────╯ ← 与上行同时画好,输入时就看到完整框
|
|
171
|
+
//
|
|
172
|
+
// readline 只能管一行提示符,边框由我们补画:
|
|
173
|
+
// 画完下边框后由调用方把光标移回输入行(readline 的刷新只动当前行,互不影响);
|
|
174
|
+
// 用户回车后光标正好落在下边框行,write('\n') 越过它即可,框完整留在回显里。
|
|
175
|
+
// 非 TTY 或终端过窄时静默降级为裸提示符。
|
|
176
|
+
|
|
177
|
+
function borderLine(left, right) {
|
|
178
|
+
const w = Math.max(MIN_BOX_WIDTH, termWidth() - 2)
|
|
179
|
+
return chalk.dim(left + '─'.repeat(w) + right)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function drawInputTop(write = (s) => process.stdout.write(s)) {
|
|
183
|
+
if (!process.stdout.isTTY) return
|
|
184
|
+
write(borderLine('╭', '╮') + '\n')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 下边框字符串(不带换行)—— 由调用方在 prompt 之后补画并自行恢复光标 */
|
|
188
|
+
export function inputBottomBorder() {
|
|
189
|
+
return borderLine('╰', '╯')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function drawInputBottom(write = (s) => process.stdout.write(s)) {
|
|
193
|
+
if (!process.stdout.isTTY) return
|
|
194
|
+
write(borderLine('╰', '╯') + '\n')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ──────────────────────────────────────────────
|
|
198
|
+
// 等待 spinner(ora 包装)
|
|
199
|
+
// ──────────────────────────────────────────────
|
|
200
|
+
//
|
|
201
|
+
// 首个 token 到达前转动;到达后 stop() 清行,接着流式输出。
|
|
202
|
+
// 非 TTY 时 ora 自动退化为只打印一次文本。
|
|
203
|
+
|
|
204
|
+
export function startSpinner(text) {
|
|
205
|
+
const spinner = ora({
|
|
206
|
+
text: chalk.dim(text),
|
|
207
|
+
spinner: 'dots',
|
|
208
|
+
color: 'cyan',
|
|
209
|
+
// ora 默认 discardStdin: true,内部用 stdin-discarder 在 spinner 运行时
|
|
210
|
+
// 丢弃 stdin 输入。但 stdin-discarder 的 stop() 在 Windows 上有 bug:
|
|
211
|
+
// start() 跳过 Windows,但 stop() 没有 Windows 检查,无条件执行
|
|
212
|
+
// process.stdin.pause() + setRawMode(false),导致 readline 的 stdin
|
|
213
|
+
// 停止 flowing → 事件循环失去引用 → 进程退出。
|
|
214
|
+
// 关闭这个选项即可避免问题。
|
|
215
|
+
discardStdin: false,
|
|
216
|
+
})
|
|
217
|
+
spinner.start()
|
|
218
|
+
return {
|
|
219
|
+
stop() {
|
|
220
|
+
if (spinner.isSpinning) spinner.stop()
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ──────────────────────────────────────────────
|
|
226
|
+
// 流式回复 writer:思考(灰斜体) + 正文(⏺ + 轻量 markdown)
|
|
227
|
+
// ──────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/** 行内 markdown:`code` 优先提取防干扰,再处理 **bold** */
|
|
230
|
+
function renderInline(line) {
|
|
231
|
+
const spans = []
|
|
232
|
+
// 先抠出 inline code,避免其中的 * 被 bold 规则误吃
|
|
233
|
+
line = line.replace(/`([^`\n]+)`/g, (m, c) => {
|
|
234
|
+
spans.push(c)
|
|
235
|
+
return `\u0000${spans.length - 1}\u0000`
|
|
236
|
+
})
|
|
237
|
+
line = line.replace(/\*\*([^*\n]+)\*\*/g, (m, c) => chalk.bold(c))
|
|
238
|
+
line = line.replace(/\u0000(\d+)\u0000/g, (m, i) => chalk.cyan(spans[Number(i)]))
|
|
239
|
+
return line
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 创建一个流式回复 writer。
|
|
244
|
+
*
|
|
245
|
+
* @param {object} [opts]
|
|
246
|
+
* @param {boolean} [opts.showThinking=true] - false 时丢弃思考段(不影响历史)
|
|
247
|
+
* @param {string} [opts.thinkingHeader] - 思考段头文案(如 "✻ 思考")
|
|
248
|
+
* @param {(s:string)=>void} [opts.write] - 输出函数,默认写 stdout(测试可注入)
|
|
249
|
+
*/
|
|
250
|
+
export function createAssistantWriter({
|
|
251
|
+
showThinking = true,
|
|
252
|
+
thinkingHeader = '✻ 思考',
|
|
253
|
+
write = (s) => process.stdout.write(s),
|
|
254
|
+
} = {}) {
|
|
255
|
+
let mode = null // null | 'thinking' | 'content'
|
|
256
|
+
let lineBuf = '' // 正文行缓冲(逐行渲染,保证 ** 等标记完整)
|
|
257
|
+
let inFence = false // ``` 代码块状态
|
|
258
|
+
let contentLines = 0 // 已输出正文行数(首行带 ⏺ 子弹头)
|
|
259
|
+
let lastBlank = false // 上一行是空白行(连续空行合并,避免模型输出头部/分隔空行刷屏)
|
|
260
|
+
let thinkAtLineStart = true // 思考流当前是否在行首(用于逐行缩进 + 计算与正文的分隔)
|
|
261
|
+
// ⏺ 后留 2 个空格,后续行 3 空格对齐 — 图标与文字之间别太挤
|
|
262
|
+
const BULLET_FIRST = chalk.green('⏺') + ' '
|
|
263
|
+
const BULLET_REST = ' '
|
|
264
|
+
// 思考内容整体右移,与正文文字左缘对齐,视觉上成为独立子块
|
|
265
|
+
const THINK_INDENT = ' '
|
|
266
|
+
// 思考用橙黄色斜体(gray/dim 太浅看不清;橙黄既醒目又与正文白、工具青区分开)
|
|
267
|
+
const thinkStyle = (s) => chalk.hex('#e8a33d').italic(s)
|
|
268
|
+
|
|
269
|
+
const emitContentLine = (raw, withNewline = true) => {
|
|
270
|
+
// 围栏标记行:切换状态,用一个淡淡的槽线代替裸 ```
|
|
271
|
+
if (/^\s*```/.test(raw)) {
|
|
272
|
+
inFence = !inFence
|
|
273
|
+
const bullet = contentLines === 0 ? BULLET_FIRST : BULLET_REST
|
|
274
|
+
contentLines++
|
|
275
|
+
lastBlank = false
|
|
276
|
+
write(bullet + chalk.dim(inFence ? '┄ code ' + '┄'.repeat(8) : '┄'.repeat(14)) + (withNewline ? '\n' : ''))
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
// 围栏内:代码行原样带槽线(空行也保留,代码格式不能动)
|
|
280
|
+
if (inFence) {
|
|
281
|
+
const bullet = contentLines === 0 ? BULLET_FIRST : BULLET_REST
|
|
282
|
+
contentLines++
|
|
283
|
+
lastBlank = false
|
|
284
|
+
write(bullet + chalk.dim('│ ') + raw + (withNewline ? '\n' : ''))
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
// 空白行:首个正文行之前不输出;连续空行合并为一行
|
|
288
|
+
if (raw.trim() === '') {
|
|
289
|
+
if (contentLines === 0 || lastBlank) return
|
|
290
|
+
lastBlank = true
|
|
291
|
+
write('\n')
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
lastBlank = false
|
|
295
|
+
const bullet = contentLines === 0 ? BULLET_FIRST : BULLET_REST
|
|
296
|
+
contentLines++
|
|
297
|
+
const h = raw.match(/^(#{1,6})\s+(.*)$/)
|
|
298
|
+
const body = h ? chalk.bold(renderInline(h[2])) : renderInline(raw)
|
|
299
|
+
write(bullet + body + (withNewline ? '\n' : ''))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const flushLineBuf = (withNewline = true) => {
|
|
303
|
+
if (!lineBuf) return
|
|
304
|
+
emitContentLine(lineBuf, withNewline)
|
|
305
|
+
lineBuf = ''
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
/** 思考段:橙黄斜体 + 整体右缩进(不做 markdown),段头只打印一次 */
|
|
310
|
+
writeThinking(text) {
|
|
311
|
+
if (!showThinking || !text) return
|
|
312
|
+
if (mode !== 'thinking') {
|
|
313
|
+
flushLineBuf()
|
|
314
|
+
mode = 'thinking'
|
|
315
|
+
// 段头前空一行与上文分隔;头部本身也缩进,和内容左缘对齐
|
|
316
|
+
write('\n' + THINK_INDENT + thinkStyle(thinkingHeader) + '\n')
|
|
317
|
+
thinkAtLineStart = true
|
|
318
|
+
}
|
|
319
|
+
// 按换行切段,整段着色(避免逐字符 escape 刷屏);每逢行首补一层缩进,
|
|
320
|
+
// 使多行思考整体右移成独立子块。流式 token 可能不以换行结尾,
|
|
321
|
+
// 故用 thinkAtLineStart 记住跨调用的行首状态。
|
|
322
|
+
const parts = text.split('\n')
|
|
323
|
+
for (let i = 0; i < parts.length; i++) {
|
|
324
|
+
const seg = parts[i]
|
|
325
|
+
if (seg) {
|
|
326
|
+
if (thinkAtLineStart) write(THINK_INDENT)
|
|
327
|
+
write(thinkStyle(seg))
|
|
328
|
+
thinkAtLineStart = false
|
|
329
|
+
}
|
|
330
|
+
if (i < parts.length - 1) { // 段间的换行(最后一段后不补)
|
|
331
|
+
write('\n')
|
|
332
|
+
thinkAtLineStart = true
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
/** 正文段:⏺ 子弹头 + 逐行 markdown 渲染 */
|
|
338
|
+
writeContent(text) {
|
|
339
|
+
if (!text) return
|
|
340
|
+
if (mode !== 'content') {
|
|
341
|
+
// 思考→正文:补足换行 + 空一行,让思考块与正文之间有呼吸间隔(不再紧挨)
|
|
342
|
+
if (mode === 'thinking') write((thinkAtLineStart ? '' : '\n') + '\n')
|
|
343
|
+
mode = 'content'
|
|
344
|
+
}
|
|
345
|
+
lineBuf += text
|
|
346
|
+
let idx
|
|
347
|
+
while ((idx = lineBuf.indexOf('\n')) >= 0) {
|
|
348
|
+
emitContentLine(lineBuf.slice(0, idx))
|
|
349
|
+
lineBuf = lineBuf.slice(idx + 1)
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
/** 流结束:冲刷行缓冲,补结尾换行(任何段都没输出过则不产生空行) */
|
|
354
|
+
finish() {
|
|
355
|
+
if (mode === null) return
|
|
356
|
+
flushLineBuf(false)
|
|
357
|
+
write('\n')
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ──────────────────────────────────────────────
|
|
363
|
+
// 工具调用块(Claude Code 风格)
|
|
364
|
+
// ──────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
/** 按工具类型生成一行参数摘要(纯函数,便于单测) */
|
|
367
|
+
export function summarizeToolArgs(name, args, { chars = '字符' } = {}) {
|
|
368
|
+
const clamp = (s, n = 120) => {
|
|
369
|
+
s = String(s ?? '').replace(/\s+/g, ' ').trim()
|
|
370
|
+
return s.length > n ? s.slice(0, n) + '…' : s
|
|
371
|
+
}
|
|
372
|
+
switch (name) {
|
|
373
|
+
case 'run_command':
|
|
374
|
+
return '$ ' + clamp(args?.command)
|
|
375
|
+
case 'read_file': {
|
|
376
|
+
const range = (args?.offset || args?.limit)
|
|
377
|
+
? ` L${args?.offset || 1}-${(args?.offset || 1) + (args?.limit || 2000) - 1}`
|
|
378
|
+
: ''
|
|
379
|
+
return clamp(`${args?.path || ''}${range}`)
|
|
380
|
+
}
|
|
381
|
+
case 'write_file':
|
|
382
|
+
return clamp(`${args?.path || ''} (${String(args?.content ?? '').length} ${chars})`)
|
|
383
|
+
case 'edit_file':
|
|
384
|
+
return clamp(args?.path || '')
|
|
385
|
+
case 'list_files':
|
|
386
|
+
return clamp(`${args?.path || '.'} depth=${args?.depth || 2}`)
|
|
387
|
+
case 'search_text':
|
|
388
|
+
return clamp(`/${args?.pattern || ''}/ ${args?.path || '.'}`)
|
|
389
|
+
default:
|
|
390
|
+
return clamp(JSON.stringify(args ?? {}))
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** 工具头:⏺ name 参数摘要(图标与文字之间 2 空格,别太挤;青色与正文绿色子弹头区分) */
|
|
395
|
+
export function printToolHeader(name, summary, write = (s) => process.stdout.write(s)) {
|
|
396
|
+
write('\n' + chalk.cyan('⏺') + ' ' + chalk.bold(name) + (summary ? ' ' + chalk.dim(summary) : '') + '\n')
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* 工具结果块:
|
|
401
|
+
* └─ 首行
|
|
402
|
+
* │ 后续行…
|
|
403
|
+
* 退出码非 0 → 黄色;"错误/已拒绝"开头 → 红色;其余用青色(cyan,dim/gray 太浅看不清)。
|
|
404
|
+
* (首行用 └─ 而不是 Claude 的 ⎿:box-drawing 字符在 Windows 终端字体里渲染更稳,
|
|
405
|
+
* 且与输入框 ╭╰ 边框同一字符族,视觉更统一)
|
|
406
|
+
*/
|
|
407
|
+
export function printToolResult(result, write = (s) => process.stdout.write(s)) {
|
|
408
|
+
const text = truncateDisplay(result)
|
|
409
|
+
const exitMatch = text.match(/^\$[^\n]*\n\(exit (\d+)\)/)
|
|
410
|
+
const exitCode = exitMatch ? Number(exitMatch[1]) : null
|
|
411
|
+
const isError = exitCode !== null && exitCode !== 0
|
|
412
|
+
|| /^(错误|已拒绝|Error)/.test(text.trim())
|
|
413
|
+
// 正文用 cyan(比 dim/gray 亮,能看清);错误保持黄色告警。槽线仍用 dim 当装饰导轨
|
|
414
|
+
const colorize = isError ? chalk.yellow : chalk.cyan
|
|
415
|
+
const lines = text.split('\n')
|
|
416
|
+
const rendered = lines.map((l, i) => {
|
|
417
|
+
const gutter = i === 0 ? chalk.dim(' └─ ') : chalk.dim(' │ ')
|
|
418
|
+
return gutter + colorize(l)
|
|
419
|
+
}).join('\n')
|
|
420
|
+
write(rendered + '\n')
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ──────────────────────────────────────────────
|
|
424
|
+
// 杂项状态行
|
|
425
|
+
// ──────────────────────────────────────────────
|
|
426
|
+
export const printOk = (s, write = (x) => process.stdout.write(x)) => write(chalk.green(s) + '\n')
|
|
427
|
+
export const printWarn = (s, write = (x) => process.stdout.write(x)) => write(chalk.yellow(s) + '\n')
|
|
428
|
+
export const printError = (s, write = (x) => process.stdout.write(x)) => write(chalk.red(s) + '\n')
|
|
429
|
+
export const printDim = (s, write = (x) => process.stdout.write(x)) => write(chalk.dim(s) + '\n')
|
|
430
|
+
|
|
431
|
+
export default {
|
|
432
|
+
DISPLAY_RESULT_LIMIT,
|
|
433
|
+
termWidth,
|
|
434
|
+
stripAnsi,
|
|
435
|
+
truncateDisplay,
|
|
436
|
+
printBanner,
|
|
437
|
+
printHelpPanel,
|
|
438
|
+
SLASH_COMMANDS,
|
|
439
|
+
filterSlashCommands,
|
|
440
|
+
renderSlashHintBody,
|
|
441
|
+
drawInputTop,
|
|
442
|
+
drawInputBottom,
|
|
443
|
+
inputBottomBorder,
|
|
444
|
+
startSpinner,
|
|
445
|
+
createAssistantWriter,
|
|
446
|
+
summarizeToolArgs,
|
|
447
|
+
printToolHeader,
|
|
448
|
+
printToolResult,
|
|
449
|
+
printOk,
|
|
450
|
+
printWarn,
|
|
451
|
+
printError,
|
|
452
|
+
printDim,
|
|
453
|
+
}
|