zen-gitsync 2.16.1 → 2.16.3

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 (85) hide show
  1. package/README.md +28 -3
  2. package/package.json +1 -1
  3. package/src/cli/ai/agent.js +181 -21
  4. package/src/cli/ai/agent.test.js +77 -1
  5. package/src/cli/ai/modelSetup.js +594 -0
  6. package/src/cli/ai/modelSetup.test.js +496 -0
  7. package/src/cli/ai/platformGuard.js +200 -0
  8. package/src/cli/ai/platformGuard.test.js +253 -0
  9. package/src/cli/ai/termui.js +135 -37
  10. package/src/cli/ai/termui.test.js +48 -8
  11. package/src/cli/ai/tools.js +8 -0
  12. package/src/gitCommit.js +484 -484
  13. package/src/ui/public/assets/AppVersionBadge-nwlsGMPO.js +2 -0
  14. package/src/ui/public/assets/BranchSelector-bUBqRWF2.js +1 -0
  15. package/src/ui/public/assets/CommitForm-DyHOStnk.js +6 -0
  16. package/src/ui/public/assets/CommonDialog-BA4xg5l3.js +1 -0
  17. package/src/ui/public/assets/EditorView-D939kLwW.js +0 -0
  18. package/src/ui/public/assets/{FlowExecutionViewer-CPblwVA1.js → FlowExecutionViewer-CZdF1f1e.js} +1 -1
  19. package/src/ui/public/assets/FlowOrchestrationWorkspace-BOn17akW.js +21 -0
  20. package/src/ui/public/assets/LogList-xk8W-v1F.js +15 -0
  21. package/src/ui/public/assets/{MindmapView-C_9P5ijq.js → MindmapView-CU3tXgbR.js} +1 -1
  22. package/src/ui/public/assets/MonitorView-CSAqU7oV.js +1 -0
  23. package/src/ui/public/assets/ProjectStartupButton-2By0-fuq.js +1 -0
  24. package/src/ui/public/assets/{RemoteRepoCard-C_cBtHcN.js → RemoteRepoCard-Cx5q9Yxb.js} +2 -2
  25. package/src/ui/public/assets/SourceMapView-uEieIRHF.js +3 -0
  26. package/src/ui/public/assets/{SvgIcon-C9YQSvGU.js → SvgIcon-BqWp2WS8.js} +1 -1
  27. package/src/ui/public/assets/UserInputNode-Ccnh0e-0.js +1 -0
  28. package/src/ui/public/assets/WorkbenchView-DBoFN37v.js +28 -0
  29. package/src/ui/public/assets/{_plugin-vue_export-helper-CHQqCylN.js → _plugin-vue_export-helper-BQRl7WAH.js} +1 -1
  30. package/src/ui/public/assets/configStore-BkxDLXGa.js +1 -0
  31. package/src/ui/public/assets/{css.worker-CvXBzhp8.js → css.worker-BKx7BC4h.js} +7 -7
  32. package/src/ui/public/assets/dagre-CTEVx1fc.js +1 -0
  33. package/src/ui/public/assets/editor.worker-DQJwLooG.js +26 -0
  34. package/src/ui/public/assets/element-plus-CQrjg9us.css +1 -0
  35. package/src/ui/public/assets/element-plus-DNnePjco.js +32 -0
  36. package/src/ui/public/assets/{flow-mindmap-CUvJaYn2.js → flow-mindmap-DRmzYE4F.js} +6 -6
  37. package/src/ui/public/assets/{html.worker-BO6WuOEO.js → html.worker-ChRLLxCa.js} +4 -4
  38. package/src/ui/public/assets/index-Dh9-lDzZ.js +68 -0
  39. package/src/ui/public/assets/{json.worker-BkJRGcCJ.js → json.worker-DR52qCex.js} +5 -5
  40. package/src/ui/public/assets/{monaco-BFLoT2VD.js → monaco-BvgtFLbw.js} +30 -30
  41. package/src/ui/public/assets/rolldown-runtime-DAXXjFlN.js +1 -0
  42. package/src/ui/public/assets/{socket-io-BSgn8Jx2.js → socket-io-pmu5tRPo.js} +1 -1
  43. package/src/ui/public/assets/{ts.worker-B0J26iPs.js → ts.worker-Cqvg7802.js} +18 -18
  44. package/src/ui/public/assets/{vendor-CU5NYUKJ.js → vendor-CcK8n8T4.js} +10 -10
  45. package/src/ui/public/assets/vue-flow-pAz--378.js +39 -0
  46. package/src/ui/public/favicon.svg +75 -75
  47. package/src/ui/public/index.html +30 -30
  48. package/src/ui/public/logo.svg +74 -74
  49. package/src/ui/server/.claude/codediff.txt +8 -0
  50. package/src/ui/server/middleware/requestLogger.js +53 -53
  51. package/src/ui/server/routes/branchStatus.js +105 -105
  52. package/src/ui/server/routes/fs.js +791 -791
  53. package/src/ui/server/routes/git/diff.js +426 -426
  54. package/src/ui/server/routes/git/diffUtils.js +149 -149
  55. package/src/ui/server/routes/git/stash.js +550 -550
  56. package/src/ui/server/routes/git/tags.js +176 -176
  57. package/src/ui/server/routes/git.js +181 -181
  58. package/src/ui/server/routes/gitOps.js +1255 -1255
  59. package/src/ui/server/routes/process.js +86 -86
  60. package/src/ui/server/routes/status.js +67 -67
  61. package/src/ui/server/routes/workbench/pdfText.js +145 -145
  62. package/src/ui/server/utils/createSavePortToFile.js +48 -48
  63. package/src/ui/server/utils/instanceRegistry.js +282 -282
  64. package/src/ui/server/utils/startServerOnAvailablePort.js +116 -116
  65. package/src/utils/index.js +1173 -1173
  66. package/src/ui/public/assets/AppVersionBadge-DJyfXR_Q.js +0 -2
  67. package/src/ui/public/assets/BranchSelector-ClnVPC0Q.js +0 -1
  68. package/src/ui/public/assets/CommitForm-BihM26_4.js +0 -6
  69. package/src/ui/public/assets/CommonDialog-CYoIF2fg.js +0 -1
  70. package/src/ui/public/assets/EditorView-DYJQT-Xj.js +0 -0
  71. package/src/ui/public/assets/FlowOrchestrationWorkspace-Cc-Tnhng.js +0 -21
  72. package/src/ui/public/assets/LogList-va6ANtbL.js +0 -15
  73. package/src/ui/public/assets/MonitorView-DRPECOVW.js +0 -1
  74. package/src/ui/public/assets/ProjectStartupButton-DSovbKPE.js +0 -1
  75. package/src/ui/public/assets/SourceMapView-ZiCL8mS8.js +0 -3
  76. package/src/ui/public/assets/UserInputNode-CR6o8zJG.js +0 -1
  77. package/src/ui/public/assets/WorkbenchView-CJJrBqBH.js +0 -28
  78. package/src/ui/public/assets/configStore-Bw9U4AHx.js +0 -1
  79. package/src/ui/public/assets/dagre-CWIGcX9d.js +0 -1
  80. package/src/ui/public/assets/editor.worker-Cn2oRESe.js +0 -26
  81. package/src/ui/public/assets/element-plus-CyGhXd7X.js +0 -25
  82. package/src/ui/public/assets/element-plus-D3Dn7nX0.css +0 -1
  83. package/src/ui/public/assets/index-BzNpkvm5.js +0 -68
  84. package/src/ui/public/assets/rolldown-runtime-CMxvf4Kt.js +0 -1
  85. package/src/ui/public/assets/vue-flow-CWmfpfPN.js +0 -39
@@ -0,0 +1,253 @@
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/platformGuard.js 单元测试
16
+ // 覆盖纯函数(splitCommandSegments / extractCommandName / detectUnixOnlyCommands)
17
+ // 和异步主接口(guardCommand,注入 mock exec 避免 PATH 依赖)。
18
+ import { test, beforeEach } from 'node:test'
19
+ import assert from 'node:assert/strict'
20
+ import {
21
+ splitCommandSegments,
22
+ extractCommandName,
23
+ detectUnixOnlyCommands,
24
+ guardCommand,
25
+ checkCommandAvailability,
26
+ _clearAvailabilityCache,
27
+ UNIX_ONLY_COMMANDS,
28
+ } from './platformGuard.js'
29
+
30
+ // ── splitCommandSegments ──────────────────────
31
+
32
+ test('splitCommandSegments: 按管道切分', () => {
33
+ assert.deepEqual(
34
+ splitCommandSegments('npm test | tail -60'),
35
+ ['npm test', 'tail -60'],
36
+ )
37
+ })
38
+
39
+ test('splitCommandSegments: 按 && 切分', () => {
40
+ assert.deepEqual(
41
+ splitCommandSegments('echo a && echo b'),
42
+ ['echo a', 'echo b'],
43
+ )
44
+ })
45
+
46
+ test('splitCommandSegments: 按 || 和 ; 切分', () => {
47
+ assert.deepEqual(
48
+ splitCommandSegments('echo a || echo b ; echo c'),
49
+ ['echo a', 'echo b', 'echo c'],
50
+ )
51
+ })
52
+
53
+ test('splitCommandSegments: 空串/纯空白返回空数组', () => {
54
+ assert.deepEqual(splitCommandSegments(''), [])
55
+ assert.deepEqual(splitCommandSegments(' '), [])
56
+ })
57
+
58
+ test('splitCommandSegments: 无操作符的命令返回单段', () => {
59
+ assert.deepEqual(splitCommandSegments('echo hello'), ['echo hello'])
60
+ })
61
+
62
+ // ── extractCommandName ────────────────────────
63
+
64
+ test('extractCommandName: 提取第一个 token 并小写', () => {
65
+ assert.equal(extractCommandName('TAIL -60'), 'tail')
66
+ assert.equal(extractCommandName('node -e "code"'), 'node')
67
+ })
68
+
69
+ test('extractCommandName: 跳过 sudo/doas 前缀', () => {
70
+ assert.equal(extractCommandName('sudo tail -f log'), 'tail')
71
+ assert.equal(extractCommandName('doas grep pattern'), 'grep')
72
+ })
73
+
74
+ test('extractCommandName: 空串返回空', () => {
75
+ assert.equal(extractCommandName(''), '')
76
+ })
77
+
78
+ // ── detectUnixOnlyCommands ────────────────────
79
+
80
+ test('detectUnixOnlyCommands: 检测管道中的 Unix 命令', () => {
81
+ // 复现用户踩坑场景:npm test | tail -60
82
+ assert.deepEqual(detectUnixOnlyCommands('npm test | tail -60'), ['tail'])
83
+ })
84
+
85
+ test('detectUnixOnlyCommands: 检测多个不同的 Unix 命令', () => {
86
+ assert.deepEqual(
87
+ detectUnixOnlyCommands('cat file.txt | grep foo | wc -l'),
88
+ ['cat', 'grep', 'wc'],
89
+ )
90
+ })
91
+
92
+ test('detectUnixOnlyCommands: 同一命令去重', () => {
93
+ assert.deepEqual(
94
+ detectUnixOnlyCommands('grep a && grep b'),
95
+ ['grep'],
96
+ )
97
+ })
98
+
99
+ test('detectUnixOnlyCommands: 没有 Unix 命令时返回空数组', () => {
100
+ assert.deepEqual(detectUnixOnlyCommands('npm test'), [])
101
+ assert.deepEqual(detectUnixOnlyCommands('node -e "console.log(1)"'), [])
102
+ assert.deepEqual(detectUnixOnlyCommands('git status'), [])
103
+ })
104
+
105
+ test('detectUnixOnlyCommands: 带路径前缀的命令也能检测', () => {
106
+ // /usr/bin/tail → tail
107
+ assert.deepEqual(detectUnixOnlyCommands('/usr/bin/tail -f log'), ['tail'])
108
+ })
109
+
110
+ test('detectUnixOnlyCommands: 空输入返回空数组', () => {
111
+ assert.deepEqual(detectUnixOnlyCommands(''), [])
112
+ assert.deepEqual(detectUnixOnlyCommands(null), [])
113
+ assert.deepEqual(detectUnixOnlyCommands(undefined), [])
114
+ })
115
+
116
+ test('detectUnixOnlyCommands: 不误判 Windows 自带命令', () => {
117
+ // sort/find/where 在 Windows 上存在(行为不同但不是 Unix-only)
118
+ assert.deepEqual(detectUnixOnlyCommands('sort file.txt'), [])
119
+ assert.deepEqual(detectUnixOnlyCommands('where node'), [])
120
+ })
121
+
122
+ // ── guardCommand:非 Windows 平台 ─────────────
123
+
124
+ test('guardCommand: 非 Windows 平台直接放行', async () => {
125
+ const r = await guardCommand('tail -f log', { platform: 'linux' })
126
+ assert.equal(r.blocked, false)
127
+ assert.equal(r.reason, null)
128
+ })
129
+
130
+ test('guardCommand: macOS 也放行', async () => {
131
+ const r = await guardCommand('cat file | grep foo', { platform: 'darwin' })
132
+ assert.equal(r.blocked, false)
133
+ })
134
+
135
+ test('guardCommand: 空/非字符串命令放行', async () => {
136
+ assert.equal((await guardCommand('', { platform: 'win32' })).blocked, false)
137
+ assert.equal((await guardCommand(null, { platform: 'win32' })).blocked, false)
138
+ })
139
+
140
+ // ── guardCommand:Windows + mock exec ──────────
141
+ //
142
+ // 注入 mock execFn 模拟 `where` 的成功/失败,避免依赖真实 PATH。
143
+ // 每个测试前清空缓存,确保 mock 不互相污染。
144
+
145
+ // mock exec:commands 里列出的命令名返回"找到"(无 err),其余返回"找不到"(err)
146
+ function createMockExec(notFoundCommands = []) {
147
+ const notFound = new Set(notFoundCommands)
148
+ return (cmd, _opts, cb) => {
149
+ // 提取 `where <cmd>` 中的 cmd 名
150
+ const m = cmd.match(/^where\s+(\S+)/)
151
+ const name = m?.[1] || ''
152
+ if (notFound.has(name)) {
153
+ cb(new Error('not found'), '')
154
+ } else {
155
+ cb(null, `C:\\fake\\${name}.exe`)
156
+ }
157
+ }
158
+ }
159
+
160
+ beforeEach(() => {
161
+ _clearAvailabilityCache()
162
+ })
163
+
164
+ test('guardCommand: Windows 上 tail 不存在 → 拦截并给出替代方案', async () => {
165
+ const r = await guardCommand('npm test | tail -60', {
166
+ platform: 'win32',
167
+ execFn: createMockExec(['tail']),
168
+ })
169
+ assert.equal(r.blocked, true)
170
+ assert.match(r.reason, /tail/)
171
+ assert.match(r.reason, /Select-Object/)
172
+ assert.match(r.reason, /npm test \| tail -60/)
173
+ })
174
+
175
+ test('guardCommand: Windows 上 tail 存在(如 Git Bash) → 放行', async () => {
176
+ const r = await guardCommand('npm test | tail -60', {
177
+ platform: 'win32',
178
+ execFn: createMockExec([]), // 所有命令都"找到"
179
+ })
180
+ assert.equal(r.blocked, false)
181
+ })
182
+
183
+ test('guardCommand: Windows 上无 Unix 命令 → 放行', async () => {
184
+ const r = await guardCommand('npm test', {
185
+ platform: 'win32',
186
+ execFn: createMockExec(),
187
+ })
188
+ assert.equal(r.blocked, false)
189
+ })
190
+
191
+ test('guardCommand: 多个 Unix 命令都不存在 → 全部列出', async () => {
192
+ const r = await guardCommand('cat file | grep foo | wc -l', {
193
+ platform: 'win32',
194
+ execFn: createMockExec(['cat', 'grep', 'wc']),
195
+ })
196
+ assert.equal(r.blocked, true)
197
+ assert.match(r.reason, /cat/)
198
+ assert.match(r.reason, /grep/)
199
+ assert.match(r.reason, /wc/)
200
+ })
201
+
202
+ test('guardCommand: 多个 Unix 命令,部分存在部分不存在 → 只拦截不存在的', async () => {
203
+ // grep 存在(Git Bash),cat/wc 不存在
204
+ const r = await guardCommand('cat file | grep foo | wc -l', {
205
+ platform: 'win32',
206
+ execFn: createMockExec(['cat', 'wc']),
207
+ })
208
+ assert.equal(r.blocked, true)
209
+ assert.match(r.reason, /cat/)
210
+ assert.doesNotMatch(r.reason, /• grep/)
211
+ assert.match(r.reason, /wc/)
212
+ })
213
+
214
+ test('guardCommand: 拦截原因包含原始命令', async () => {
215
+ const cmd = 'echo hello | tail -5'
216
+ const r = await guardCommand(cmd, {
217
+ platform: 'win32',
218
+ execFn: createMockExec(['tail']),
219
+ })
220
+ assert.match(r.reason, new RegExp(cmd.replace(/\|/g, '\\|')))
221
+ })
222
+
223
+ // ── checkCommandAvailability:缓存行为 ────────
224
+
225
+ test('checkCommandAvailability: 缓存结果,第二次不调 exec', async () => {
226
+ let callCount = 0
227
+ const execFn = (cmd, _opts, cb) => {
228
+ callCount++
229
+ cb(new Error('not found'), '')
230
+ }
231
+ _clearAvailabilityCache()
232
+ const a1 = await checkCommandAvailability('tail', { execFn })
233
+ const a2 = await checkCommandAvailability('tail', { execFn })
234
+ assert.equal(a1, false)
235
+ assert.equal(a2, false)
236
+ assert.equal(callCount, 1, '第二次应命中缓存,不调 exec')
237
+ })
238
+
239
+ // ── 数据完整性 ────────────────────────────────
240
+
241
+ test('UNIX_ONLY_COMMANDS: 每个条目都有非空的替代方案说明', () => {
242
+ for (const [name, hint] of Object.entries(UNIX_ONLY_COMMANDS)) {
243
+ assert.ok(typeof name === 'string' && name.length > 0)
244
+ assert.ok(typeof hint === 'string' && hint.length > 10, `${name} 的替代方案说明过短`)
245
+ }
246
+ })
247
+
248
+ test('UNIX_ONLY_COMMANDS: 不包含 Windows 自带命令(防误杀)', () => {
249
+ const windowsBuiltins = ['sort', 'find', 'where', 'dir', 'type', 'copy', 'move', 'del']
250
+ for (const w of windowsBuiltins) {
251
+ assert.ok(!UNIX_ONLY_COMMANDS[w], `${w} 是 Windows 自带命令,不应在 Unix-only 清单中`)
252
+ }
253
+ })
@@ -16,10 +16,10 @@
16
16
  //
17
17
  // - 盒式输入框(Codex composer 风格):╭───╮ + ❯ 提示符 + ╰───╯
18
18
  // - 等待 spinner(ora):首个 token 到达前持续转动
19
- // - 思考过程:✻ 思考 头 + 灰色斜体流式输出
20
- // - 工具调用块(Claude Code 风格):⏺ 工具头 + 智能参数摘要,
21
- // 结果用 ⎿/│ 缩进槽,按退出码/错误前缀着色
22
- // - 正文:⏺ 项目符号 + 逐行缓冲的轻量 markdown 渲染
19
+ // - 思考过程:✻ 思考 头 + 橙黄斜体流式输出
20
+ // - 工具调用块(Claude Code 风格):⚙ 工具头 + 智能参数摘要,
21
+ // 结果用 缩进槽,按退出码/错误前缀着色
22
+ // - 正文:➤ 子弹头 + 逐行缓冲的轻量 markdown 渲染
23
23
  // (**bold**、`code`、# 标题、``` 代码块、- 列表)
24
24
  //
25
25
  // 设计约束:
@@ -85,21 +85,71 @@ export function truncateDisplay(text, limit = DISPLAY_RESULT_LIMIT) {
85
85
  /** 启动横幅:模型 + 目录 + 快捷键提示,盒式自适应宽度 */
86
86
  export function printBanner({ title, modelLabel, baseURL, cwd, modelText, cwdText, tip }) {
87
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),
88
+ // greenBright 在黑底上比 green 更鲜亮,与 🤖 回答图标同色系
89
+ chalk.greenBright.bold(title),
90
+ `${modelText}: ${chalk.cyanBright(modelLabel)}${baseURL ? ' ' + chalk.hex('#a0aec0')(baseURL) : ''}`,
91
+ `${cwdText}: ${chalk.cyanBright(cwd)}`,
92
+ // dim 在黑底太暗,改用浅灰可读
93
+ chalk.hex('#a0aec0')(tip),
92
94
  ].join('\n')
93
95
  process.stdout.write(
94
96
  boxenAdaptive(lines, {
95
97
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
96
98
  margin: { top: 1, bottom: 0, left: 0, right: 0 },
97
- borderColor: 'cyan',
99
+ borderColor: 'greenBright',
98
100
  borderStyle: 'round',
99
101
  }) + '\n'
100
102
  )
101
103
  }
102
104
 
105
+ // ──────────────────────────────────────────────
106
+ // 斜杠命令即时提示(输入 / 时在提示符下方浮现,随输入过滤)
107
+ // ──────────────────────────────────────────────
108
+ //
109
+ // 命令元数据集中放这里,printSlashHelp(agent.js)与即时提示共用同一份来源,
110
+ // 新增命令时只改这一处。desc 分中英,由调用方按 locale 取用。
111
+
112
+ export const SLASH_COMMANDS = [
113
+ { cmd: '/help', descZh: '显示帮助', descEn: 'Show help' },
114
+ { cmd: '/model', descZh: '列出 / 切换模型', descEn: 'List / switch models' },
115
+ { cmd: '/addmodel', descZh: '添加模型配置(向导)', descEn: 'Add a model (wizard)' },
116
+ { cmd: '/cd', descZh: '切换工作目录', descEn: 'Change working directory' },
117
+ { cmd: '/image', descZh: '附加 / 查看图片', descEn: 'Attach / list images' },
118
+ { cmd: '/think', descZh: '开关思考过程显示', descEn: 'Toggle thinking display' },
119
+ { cmd: '/clear', descZh: '清空对话历史', descEn: 'Clear conversation' },
120
+ { cmd: '/exit', descZh: '退出', descEn: 'Quit' },
121
+ { cmd: '/quit', descZh: '退出', descEn: 'Quit' },
122
+ ]
123
+
124
+ /**
125
+ * 按当前输入过滤斜杠命令(纯函数,便于单测)。
126
+ * - 仅当输入以 / 开头、且尚未输入空格(还在敲命令名)时才提示
127
+ * - 前缀匹配,大小写不敏感
128
+ * - 精确等于某命令且无后续参数时仍然展示该命令(便于确认拼写)
129
+ * @returns {{cmd:string, desc:string}[]}
130
+ */
131
+ export function filterSlashCommands(input, locale) {
132
+ const zh = !String(locale || '').startsWith('en')
133
+ const line = String(input || '')
134
+ if (!line.startsWith('/') || /\s/.test(line)) return []
135
+ const q = line.toLowerCase()
136
+ return SLASH_COMMANDS
137
+ .filter((c) => c.cmd.startsWith(q))
138
+ .map((c) => ({ cmd: c.cmd, desc: zh ? c.descZh : c.descEn }))
139
+ }
140
+
141
+ /**
142
+ * 生成即时提示面板的 ANSI 字符串(不含定位,由调用方负责保存/恢复光标)。
143
+ * 每行:两空格缩进 + 青色命令名(左对齐补齐)+ 灰色说明。
144
+ * 空数组返回空串。
145
+ */
146
+ export function renderSlashHintBody(matches) {
147
+ if (!Array.isArray(matches) || matches.length === 0) return ''
148
+ return matches
149
+ .map((m) => ' ' + chalk.cyan(String(m.cmd).padEnd(12)) + chalk.dim(m.desc))
150
+ .join('\n')
151
+ }
152
+
103
153
  /** 盒式帮助面板 */
104
154
  export function printHelpPanel(title, lines) {
105
155
  const body = [chalk.bold(title), ...lines].join('\n')
@@ -155,9 +205,10 @@ export function drawInputBottom(write = (s) => process.stdout.write(s)) {
155
205
 
156
206
  export function startSpinner(text) {
157
207
  const spinner = ora({
158
- text: chalk.dim(text),
208
+ // 琥珀色加粗,与思考内容同色系,dim 太浅看不清
209
+ text: chalk.hex('#e8a33d').bold(text),
159
210
  spinner: 'dots',
160
- color: 'cyan',
211
+ color: 'yellow',
161
212
  // ora 默认 discardStdin: true,内部用 stdin-discarder 在 spinner 运行时
162
213
  // 丢弃 stdin 输入。但 stdin-discarder 的 stop() 在 Windows 上有 bug:
163
214
  // start() 跳过 Windows,但 stop() 没有 Windows 检查,无条件执行
@@ -175,11 +226,14 @@ export function startSpinner(text) {
175
226
  }
176
227
 
177
228
  // ──────────────────────────────────────────────
178
- // 流式回复 writer:思考(灰斜体) + 正文( + 轻量 markdown)
229
+ // 流式回复 writer:思考(橙黄斜体) + 正文( 子弹头 + 轻量 markdown)
179
230
  // ──────────────────────────────────────────────
180
231
 
181
- /** 行内 markdown:`code` 优先提取防干扰,再处理 **bold** */
182
- function renderInline(line) {
232
+ /** 行内 markdown:`code` 优先提取防干扰,再处理 **bold**
233
+ * @param {string} resetFg - 行内 code 后需重置的前景色 ANSI 码(如 whiteBright 用 '\x1b[97m'),
234
+ * 避免 chalk.cyan 的 \x1b[39m 把外层 base 色清掉导致后续文字掉色
235
+ */
236
+ function renderInline(line, resetFg = '') {
183
237
  const spans = []
184
238
  // 先抠出 inline code,避免其中的 * 被 bold 规则误吃
185
239
  line = line.replace(/`([^`\n]+)`/g, (m, c) => {
@@ -187,7 +241,7 @@ function renderInline(line) {
187
241
  return `\u0000${spans.length - 1}\u0000`
188
242
  })
189
243
  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)]))
244
+ line = line.replace(/\u0000(\d+)\u0000/g, (m, i) => chalk.cyan(spans[Number(i)]) + resetFg)
191
245
  return line
192
246
  }
193
247
 
@@ -207,11 +261,21 @@ export function createAssistantWriter({
207
261
  let mode = null // null | 'thinking' | 'content'
208
262
  let lineBuf = '' // 正文行缓冲(逐行渲染,保证 ** 等标记完整)
209
263
  let inFence = false // ``` 代码块状态
210
- let contentLines = 0 // 已输出正文行数(首行带 子弹头)
264
+ let contentLines = 0 // 已输出正文行数(首行带 子弹头)
211
265
  let lastBlank = false // 上一行是空白行(连续空行合并,避免模型输出头部/分隔空行刷屏)
212
- // 后留 2 个空格,后续行 3 空格对齐 — 图标与文字之间别太挤
213
- const BULLET_FIRST = chalk.green('⏺') + ' '
266
+ let thinkAtLineStart = true // 思考流当前是否在行首(用于逐行缩进 + 计算与正文的分隔)
267
+ // 后留 2 个空格,后续行 3 空格对齐 — 图标与文字之间别太挤
268
+ // 正文 🤖(亮绿):模型最终回复的视觉锚点;用亮绿 + 加粗图标,普通绿在深背景上偏暗
269
+ const BULLET_FIRST = chalk.bold(chalk.hex('#5eff8b')('🤖')) + ' '
214
270
  const BULLET_REST = ' '
271
+ // whiteBright ANSI 码:行内 code 的 \x1b[39m 会清掉外层色,用此恢复正文亮白色
272
+ const WB = '\x1b[97m'
273
+ // 思考内容整体右移,与正文文字左缘对齐,视觉上成为独立子块
274
+ const THINK_INDENT = ' '
275
+ // 思考用橙黄色斜体(gray/dim 太浅看不清;橙黄既醒目又与正文白、工具青区分开)
276
+ const thinkStyle = (s) => chalk.hex('#e8a33d').italic(s)
277
+ // 思考图标:🧠 大脑 — 紧贴最左(0 缩进),与工具头、正文对齐到同一左缘
278
+ const THINK_ICON = '🧠'
215
279
 
216
280
  const emitContentLine = (raw, withNewline = true) => {
217
281
  // 围栏标记行:切换状态,用一个淡淡的槽线代替裸 ```
@@ -242,7 +306,10 @@ export function createAssistantWriter({
242
306
  const bullet = contentLines === 0 ? BULLET_FIRST : BULLET_REST
243
307
  contentLines++
244
308
  const h = raw.match(/^(#{1,6})\s+(.*)$/)
245
- const body = h ? chalk.bold(renderInline(h[2])) : renderInline(raw)
309
+ // 正文用 whiteBright(亮白)比默认白更醒目;resetFg=WB 让行内 code 后恢复亮白
310
+ const body = h
311
+ ? chalk.bold.whiteBright(renderInline(h[2], WB))
312
+ : chalk.whiteBright(renderInline(raw, WB))
246
313
  write(bullet + body + (withNewline ? '\n' : ''))
247
314
  }
248
315
 
@@ -253,22 +320,43 @@ export function createAssistantWriter({
253
320
  }
254
321
 
255
322
  return {
256
- /** 思考段:灰斜体直写(不做 markdown),段头只打印一次 */
323
+ /** 思考段:橙黄斜体 + 整体右缩进(不做 markdown),段头只打印一次 */
257
324
  writeThinking(text) {
258
325
  if (!showThinking || !text) return
259
326
  if (mode !== 'thinking') {
260
327
  flushLineBuf()
261
328
  mode = 'thinking'
262
- write('\n' + chalk.dim.italic(thinkingHeader) + '\n')
329
+ // 图标 🧠 靠最左(0 缩进),与 工具头、➤ 正文对齐同一左缘
330
+ // 段头前空一行与上文(spinner / 上一轮输出)分隔,视觉更清晰
331
+ write('\n' + THINK_ICON + ' ' + thinkStyle(chalk.bold(thinkingHeader)) + '\n')
332
+ thinkAtLineStart = true
333
+ }
334
+ // 按换行切段,整段着色(避免逐字符 escape 刷屏);每逢行首补一层缩进,
335
+ // 使多行思考整体右移成独立子块。流式 token 可能不以换行结尾,
336
+ // 故用 thinkAtLineStart 记住跨调用的行首状态。
337
+ const parts = text.split('\n')
338
+ for (let i = 0; i < parts.length; i++) {
339
+ const seg = parts[i]
340
+ if (seg) {
341
+ if (thinkAtLineStart) write(THINK_INDENT)
342
+ write(thinkStyle(seg))
343
+ thinkAtLineStart = false
344
+ }
345
+ if (i < parts.length - 1) { // 段间的换行(最后一段后不补)
346
+ write('\n')
347
+ thinkAtLineStart = true
348
+ }
263
349
  }
264
- write(chalk.dim.italic(text))
265
350
  },
266
351
 
267
- /** 正文段:⏺ 子弹头 + 逐行 markdown 渲染 */
352
+ /** 正文段:➤ 子弹头 + 逐行 markdown 渲染 */
268
353
  writeContent(text) {
269
354
  if (!text) return
270
355
  if (mode !== 'content') {
271
- if (mode === 'thinking') write('\n')
356
+ // 思考→正文:补足换行 + 空一行,让思考块与正文之间有呼吸间隔(不再紧挨)
357
+ if (mode === 'thinking') write((thinkAtLineStart ? '' : '\n') + '\n')
358
+ // 无思考段直接出正文:null→content 时在 🤖 上方加一空行,与 spinner 分隔
359
+ else if (mode === null) write('\n')
272
360
  mode = 'content'
273
361
  }
274
362
  lineBuf += text
@@ -320,31 +408,38 @@ export function summarizeToolArgs(name, args, { chars = '字符' } = {}) {
320
408
  }
321
409
  }
322
410
 
323
- /** 工具头:⏺ name 参数摘要(图标与文字之间 2 空格,别太挤;青色与正文绿色子弹头区分) */
411
+ /** 工具头:▶ name 参数摘要(青色三角表示"工具执行";name 加粗白色,摘要灰色,避免整块青色) */
324
412
  export function printToolHeader(name, summary, write = (s) => process.stdout.write(s)) {
325
- write('\n' + chalk.cyan('⏺') + ' ' + chalk.bold(name) + (summary ? ' ' + chalk.dim(summary) : '') + '\n')
413
+ // 保留青色作为工具执行的标识色;name 用白色加粗与下方结果区分;
414
+ // summary 用浅灰(#a0aec0)— dim 在黑底下太暗看不清,浅灰可读且仍比 name 低调
415
+ write('\n' + chalk.cyan('▶') + ' '
416
+ + chalk.bold.white(name)
417
+ + (summary ? ' ' + chalk.hex('#a0aec0')(summary) : '')
418
+ + '\n')
326
419
  }
327
420
 
328
421
  /**
329
422
  * 工具结果块:
330
- * └─ 首行
331
- * │ 后续行…
332
- * 退出码非 0 → 黄色;"错误/已拒绝"开头 → 红色;其余暗色。
333
- * (首行用 └─ 而不是 Claude 的 ⎿:box-drawing 字符在 Windows 终端字体里渲染更稳,
334
- * 且与输入框 ╭╰ 边框同一字符族,视觉更统一)
423
+ * │ 每行统一用 │ 槽线对齐(不做首行 └─ 拐角,看着更干净)
424
+ * 退出码非 0 → 琥珀色;"错误/已拒绝"开头 → 琥珀色;其余用柔和灰蓝(避免与工具头同色)。
425
+ *
426
+ * run_command 的结果里首行是 `$ <command>` 回显——这条信息已经出现在上方的
427
+ * `▶ run_command $ <summary>` 工具头里,这里再印一次就是重复。所以这里把首
428
+ * 行 `$ ...` 剥掉,同时复用它来识别退出码(退出码仍驱动错误着色)。
335
429
  */
336
430
  export function printToolResult(result, write = (s) => process.stdout.write(s)) {
337
431
  const text = truncateDisplay(result)
432
+ // 先把 "$ <command>\n" 这一行回显从展示里剥掉(退出码仍在第二行里识别)
433
+ const visible = text.replace(/^\$[^\n]*\n/, '')
338
434
  const exitMatch = text.match(/^\$[^\n]*\n\(exit (\d+)\)/)
339
435
  const exitCode = exitMatch ? Number(exitMatch[1]) : null
340
436
  const isError = exitCode !== null && exitCode !== 0
341
437
  || /^(错误|已拒绝|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')
438
+ // 正文用柔和灰蓝(#94a3b8)— 不再用青色,避免与工具头同色连成一片;
439
+ // 错误用琥珀色(#f0a020)比黄色更醒目
440
+ const colorize = isError ? chalk.hex('#f0a020') : chalk.hex('#94a3b8')
441
+ const lines = visible.split('\n')
442
+ const rendered = lines.map((l) => chalk.dim(' │ ') + colorize(l)).join('\n')
348
443
  write(rendered + '\n')
349
444
  }
350
445
 
@@ -363,6 +458,9 @@ export default {
363
458
  truncateDisplay,
364
459
  printBanner,
365
460
  printHelpPanel,
461
+ SLASH_COMMANDS,
462
+ filterSlashCommands,
463
+ renderSlashHintBody,
366
464
  drawInputTop,
367
465
  drawInputBottom,
368
466
  inputBottomBorder,
@@ -20,6 +20,7 @@ import assert from 'node:assert/strict'
20
20
  import {
21
21
  stripAnsi, truncateDisplay, summarizeToolArgs,
22
22
  createAssistantWriter, printToolHeader, printToolResult,
23
+ filterSlashCommands, renderSlashHintBody, SLASH_COMMANDS,
23
24
  } from './termui.js'
24
25
 
25
26
  // 收集 write 输出的辅助:返回 {text(), lines()}
@@ -101,13 +102,13 @@ test('writer: showThinking=false 时丢弃思考段', () => {
101
102
  assert.ok(text.includes('正文'))
102
103
  })
103
104
 
104
- test('writer: 正文首行 子弹头,后续行缩进对齐', () => {
105
+ test('writer: 正文首行 🤖 子弹头,后续行缩进对齐', () => {
105
106
  const c = collect()
106
107
  const w = createAssistantWriter({ write: c.write })
107
108
  w.writeContent('第一行\n第二行\n第三行')
108
109
  w.finish()
109
110
  const lines = c.lines()
110
- assert.ok(lines.some(l => l.startsWith(' 第一行')))
111
+ assert.ok(lines.some(l => l.startsWith('🤖 第一行')))
111
112
  assert.ok(lines.some(l => l.startsWith(' 第二行')))
112
113
  assert.ok(lines.some(l => l.startsWith(' 第三行')))
113
114
  })
@@ -183,7 +184,9 @@ test('writer: 头部空白行被吞掉,连续空白行合并(真实 MiniMax 输
183
184
  w.writeContent('答案\n\n\n\n下一段\n')
184
185
  w.finish()
185
186
  const lines = c.lines()
186
- assert.equal(lines[0], '⏺ 答案', `首行应为 ⏺ 答案,实际: ${lines[0]}`)
187
+ // null→content 自动在 🤖 上方加一空行,跳过空行找正文首行
188
+ const firstContent = lines.find(l => l !== '')
189
+ assert.equal(firstContent, '🤖 答案', `首行应为 🤖 答案,实际: ${JSON.stringify(lines)}`)
187
190
  // "答案"与"下一段"之间最多一个空行,且空行不带缩进
188
191
  const midBlank = lines.slice(1, lines.indexOf(' 下一段')).filter(l => l === '').length
189
192
  assert.ok(midBlank <= 1, `空行应被合并,实际行: ${JSON.stringify(lines)}`)
@@ -191,20 +194,20 @@ test('writer: 头部空白行被吞掉,连续空白行合并(真实 MiniMax 输
191
194
  })
192
195
 
193
196
  // ── 工具块 ──
194
- test('printToolHeader: + 名称 + 摘要', () => {
197
+ test('printToolHeader: + 名称(粗)+ 摘要(粗)', () => {
195
198
  const c = collect()
196
199
  printToolHeader('run_command', '$ npm test', c.write)
197
200
  const text = c.text()
198
- assert.ok(text.includes(' run_command'))
201
+ assert.ok(text.includes(' run_command'))
199
202
  assert.ok(text.includes('$ npm test'))
200
203
  })
201
204
 
202
- test('printToolResult: 首行 └─,后续行 │ 槽线', () => {
205
+ test('printToolResult: 全部行统一 │ 槽线(无 └─ 拐角)', () => {
203
206
  const c = collect()
204
207
  printToolResult('$ ls\n(exit 0)\nfile1\nfile2', c.write)
205
208
  const lines = c.lines()
206
- assert.ok(lines[0].startsWith(' └─ '), `首行应为 └─ 槽线,实际: ${lines[0]}`)
207
- assert.ok(lines.slice(1).every(l => l.startsWith('') || l === ''), '后续行应为 槽线')
209
+ assert.ok(lines.every(l => l.startsWith(' │ ') || l === ''), `每行都应为 槽线,实际: ${JSON.stringify(lines)}`)
210
+ assert.ok(!lines.some(l => l.includes('└─')), '不应出现 └─ 拐角字符')
208
211
  })
209
212
 
210
213
  test('printToolResult: 超长结果被截断并含省略标记', () => {
@@ -213,3 +216,40 @@ test('printToolResult: 超长结果被截断并含省略标记', () => {
213
216
  printToolResult(long, c.write)
214
217
  assert.match(c.text(), /回显省略/)
215
218
  })
219
+
220
+ // ── 斜杠命令即时提示 ──
221
+
222
+ test('filterSlashCommands: 单个 / 返回全部命令', () => {
223
+ const got = filterSlashCommands('/', 'zh').map(m => m.cmd)
224
+ assert.deepEqual(got, SLASH_COMMANDS.map(c => c.cmd))
225
+ })
226
+
227
+ test('filterSlashCommands: 前缀过滤,大小写不敏感', () => {
228
+ assert.deepEqual(filterSlashCommands('/m', 'zh').map(m => m.cmd), ['/model'])
229
+ assert.deepEqual(filterSlashCommands('/AD', 'zh').map(m => m.cmd), ['/addmodel'])
230
+ assert.deepEqual(filterSlashCommands('/exit', 'zh').map(m => m.cmd), ['/exit'])
231
+ })
232
+
233
+ test('filterSlashCommands: 已输入空格(进入参数)时不再提示', () => {
234
+ assert.deepEqual(filterSlashCommands('/model 2', 'zh'), [])
235
+ assert.deepEqual(filterSlashCommands('/cd ..', 'zh'), [])
236
+ })
237
+
238
+ test('filterSlashCommands: 非 slash 输入 / 无匹配返回空', () => {
239
+ assert.deepEqual(filterSlashCommands('hello', 'zh'), [])
240
+ assert.deepEqual(filterSlashCommands('', 'zh'), [])
241
+ assert.deepEqual(filterSlashCommands('/xyz', 'zh'), [])
242
+ })
243
+
244
+ test('filterSlashCommands: locale 决定说明语言', () => {
245
+ const zh = filterSlashCommands('/help', 'zh')[0]
246
+ const en = filterSlashCommands('/help', 'en')[0]
247
+ assert.equal(zh.desc, '显示帮助')
248
+ assert.equal(en.desc, 'Show help')
249
+ })
250
+
251
+ test('renderSlashHintBody: 每行含命令名与说明,空输入返回空串', () => {
252
+ assert.equal(renderSlashHintBody([]), '')
253
+ const body = stripAnsi(renderSlashHintBody(filterSlashCommands('/m', 'en')))
254
+ assert.match(body, /\/model\s+List \/ switch models/)
255
+ })
@@ -30,6 +30,7 @@ import path from 'node:path'
30
30
  import iconv from 'iconv-lite'
31
31
  import { trackChild } from '../cleanup.js'
32
32
  import { checkDangerousCommand } from './safety.js'
33
+ import { guardCommand } from './platformGuard.js'
33
34
 
34
35
  // ──────────────────────────────────────────────
35
36
  // 常量
@@ -226,6 +227,13 @@ async function toolRunCommand(args, ctx) {
226
227
  return `已拒绝执行(安全守卫): ${danger.reason}\n命令: ${command}\n如果你确认需要类似效果,请换一种不破坏系统的方式,或明确告知用户需要他手动执行。`
227
228
  }
228
229
 
230
+ // 平台兼容性守卫 — 拦截"在当前 shell 里注定失败"的命令
231
+ // (如 Windows cmd 上跑 tail/grep/ls 等 Unix-only 命令)
232
+ const platform = await guardCommand(command)
233
+ if (platform.blocked) {
234
+ return `已拒绝执行(平台守卫): ${platform.reason}`
235
+ }
236
+
229
237
  const cwd = resolvePath(ctx, args.cwd)
230
238
  let timeoutSec = Number(args.timeout_seconds) || CMD_TIMEOUT_DEFAULT
231
239
  timeoutSec = Math.max(1, Math.min(CMD_TIMEOUT_MAX, timeoutSec))