zen-gitsync 2.16.1 → 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 +28 -3
- package/package.json +1 -1
- package/src/cli/ai/agent.js +173 -15
- package/src/cli/ai/agent.test.js +77 -1
- 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/termui.js +81 -6
- package/src/cli/ai/termui.test.js +38 -0
- package/src/cli/ai/tools.js +8 -0
- package/src/gitCommit.js +484 -484
- package/src/ui/public/assets/AppVersionBadge-BTznOVSJ.js +2 -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/CommonDialog-BA4xg5l3.js +1 -0
- package/src/ui/public/assets/EditorView-CSWy4q7G.js +0 -0
- package/src/ui/public/assets/{FlowExecutionViewer-CPblwVA1.js → FlowExecutionViewer-CZdF1f1e.js} +1 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-BECFOAcQ.js +21 -0
- package/src/ui/public/assets/LogList-DYoGtOou.js +15 -0
- package/src/ui/public/assets/{MindmapView-C_9P5ijq.js → MindmapView-CU3tXgbR.js} +1 -1
- package/src/ui/public/assets/MonitorView-CSAqU7oV.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-2By0-fuq.js +1 -0
- package/src/ui/public/assets/{RemoteRepoCard-C_cBtHcN.js → RemoteRepoCard-DHsJwTEX.js} +2 -2
- package/src/ui/public/assets/SourceMapView-DskfUia5.js +3 -0
- package/src/ui/public/assets/{SvgIcon-C9YQSvGU.js → SvgIcon-BqWp2WS8.js} +1 -1
- 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/{_plugin-vue_export-helper-CHQqCylN.js → _plugin-vue_export-helper-BQRl7WAH.js} +1 -1
- package/src/ui/public/assets/configStore-BkxDLXGa.js +1 -0
- package/src/ui/public/assets/{css.worker-CvXBzhp8.js → css.worker-BKx7BC4h.js} +7 -7
- package/src/ui/public/assets/dagre-CTEVx1fc.js +1 -0
- package/src/ui/public/assets/editor.worker-DQJwLooG.js +26 -0
- package/src/ui/public/assets/element-plus-CQrjg9us.css +1 -0
- package/src/ui/public/assets/element-plus-DNnePjco.js +32 -0
- package/src/ui/public/assets/{flow-mindmap-CUvJaYn2.js → flow-mindmap-DRmzYE4F.js} +6 -6
- package/src/ui/public/assets/{html.worker-BO6WuOEO.js → html.worker-ChRLLxCa.js} +4 -4
- package/src/ui/public/assets/index-Br2VZQBT.js +68 -0
- package/src/ui/public/assets/{json.worker-BkJRGcCJ.js → json.worker-DR52qCex.js} +5 -5
- package/src/ui/public/assets/{monaco-BFLoT2VD.js → monaco-BvgtFLbw.js} +30 -30
- package/src/ui/public/assets/rolldown-runtime-DAXXjFlN.js +1 -0
- package/src/ui/public/assets/{socket-io-BSgn8Jx2.js → socket-io-pmu5tRPo.js} +1 -1
- package/src/ui/public/assets/{ts.worker-B0J26iPs.js → ts.worker-Cqvg7802.js} +18 -18
- package/src/ui/public/assets/{vendor-CU5NYUKJ.js → vendor-CcK8n8T4.js} +10 -10
- package/src/ui/public/assets/vue-flow-pAz--378.js +39 -0
- package/src/ui/public/favicon.svg +75 -75
- package/src/ui/public/index.html +30 -30
- package/src/ui/public/logo.svg +74 -74
- package/src/ui/server/.claude/codediff.txt +8 -0
- package/src/ui/server/middleware/requestLogger.js +53 -53
- package/src/ui/server/routes/branchStatus.js +105 -105
- package/src/ui/server/routes/fs.js +791 -791
- package/src/ui/server/routes/git/diff.js +426 -426
- package/src/ui/server/routes/git/diffUtils.js +149 -149
- package/src/ui/server/routes/git/stash.js +550 -550
- package/src/ui/server/routes/git/tags.js +176 -176
- package/src/ui/server/routes/git.js +181 -181
- package/src/ui/server/routes/gitOps.js +1255 -1255
- package/src/ui/server/routes/process.js +86 -86
- package/src/ui/server/routes/status.js +67 -67
- package/src/ui/server/routes/workbench/pdfText.js +145 -145
- package/src/ui/server/utils/createSavePortToFile.js +48 -48
- package/src/ui/server/utils/instanceRegistry.js +282 -282
- package/src/ui/server/utils/startServerOnAvailablePort.js +116 -116
- package/src/utils/index.js +1173 -1173
- package/src/ui/public/assets/AppVersionBadge-DJyfXR_Q.js +0 -2
- package/src/ui/public/assets/BranchSelector-ClnVPC0Q.js +0 -1
- package/src/ui/public/assets/CommitForm-BihM26_4.js +0 -6
- package/src/ui/public/assets/CommonDialog-CYoIF2fg.js +0 -1
- package/src/ui/public/assets/EditorView-DYJQT-Xj.js +0 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-Cc-Tnhng.js +0 -21
- package/src/ui/public/assets/LogList-va6ANtbL.js +0 -15
- package/src/ui/public/assets/MonitorView-DRPECOVW.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-DSovbKPE.js +0 -1
- package/src/ui/public/assets/SourceMapView-ZiCL8mS8.js +0 -3
- package/src/ui/public/assets/UserInputNode-CR6o8zJG.js +0 -1
- package/src/ui/public/assets/WorkbenchView-CJJrBqBH.js +0 -28
- package/src/ui/public/assets/configStore-Bw9U4AHx.js +0 -1
- package/src/ui/public/assets/dagre-CWIGcX9d.js +0 -1
- package/src/ui/public/assets/editor.worker-Cn2oRESe.js +0 -26
- package/src/ui/public/assets/element-plus-CyGhXd7X.js +0 -25
- package/src/ui/public/assets/element-plus-D3Dn7nX0.css +0 -1
- package/src/ui/public/assets/index-BzNpkvm5.js +0 -68
- package/src/ui/public/assets/rolldown-runtime-CMxvf4Kt.js +0 -1
- 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
|
+
})
|
package/src/cli/ai/termui.js
CHANGED
|
@@ -100,6 +100,54 @@ export function printBanner({ title, modelLabel, baseURL, cwd, modelText, cwdTex
|
|
|
100
100
|
)
|
|
101
101
|
}
|
|
102
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
|
+
|
|
103
151
|
/** 盒式帮助面板 */
|
|
104
152
|
export function printHelpPanel(title, lines) {
|
|
105
153
|
const body = [chalk.bold(title), ...lines].join('\n')
|
|
@@ -209,9 +257,14 @@ export function createAssistantWriter({
|
|
|
209
257
|
let inFence = false // ``` 代码块状态
|
|
210
258
|
let contentLines = 0 // 已输出正文行数(首行带 ⏺ 子弹头)
|
|
211
259
|
let lastBlank = false // 上一行是空白行(连续空行合并,避免模型输出头部/分隔空行刷屏)
|
|
260
|
+
let thinkAtLineStart = true // 思考流当前是否在行首(用于逐行缩进 + 计算与正文的分隔)
|
|
212
261
|
// ⏺ 后留 2 个空格,后续行 3 空格对齐 — 图标与文字之间别太挤
|
|
213
262
|
const BULLET_FIRST = chalk.green('⏺') + ' '
|
|
214
263
|
const BULLET_REST = ' '
|
|
264
|
+
// 思考内容整体右移,与正文文字左缘对齐,视觉上成为独立子块
|
|
265
|
+
const THINK_INDENT = ' '
|
|
266
|
+
// 思考用橙黄色斜体(gray/dim 太浅看不清;橙黄既醒目又与正文白、工具青区分开)
|
|
267
|
+
const thinkStyle = (s) => chalk.hex('#e8a33d').italic(s)
|
|
215
268
|
|
|
216
269
|
const emitContentLine = (raw, withNewline = true) => {
|
|
217
270
|
// 围栏标记行:切换状态,用一个淡淡的槽线代替裸 ```
|
|
@@ -253,22 +306,40 @@ export function createAssistantWriter({
|
|
|
253
306
|
}
|
|
254
307
|
|
|
255
308
|
return {
|
|
256
|
-
/**
|
|
309
|
+
/** 思考段:橙黄斜体 + 整体右缩进(不做 markdown),段头只打印一次 */
|
|
257
310
|
writeThinking(text) {
|
|
258
311
|
if (!showThinking || !text) return
|
|
259
312
|
if (mode !== 'thinking') {
|
|
260
313
|
flushLineBuf()
|
|
261
314
|
mode = 'thinking'
|
|
262
|
-
|
|
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
|
+
}
|
|
263
334
|
}
|
|
264
|
-
write(chalk.dim.italic(text))
|
|
265
335
|
},
|
|
266
336
|
|
|
267
337
|
/** 正文段:⏺ 子弹头 + 逐行 markdown 渲染 */
|
|
268
338
|
writeContent(text) {
|
|
269
339
|
if (!text) return
|
|
270
340
|
if (mode !== 'content') {
|
|
271
|
-
|
|
341
|
+
// 思考→正文:补足换行 + 空一行,让思考块与正文之间有呼吸间隔(不再紧挨)
|
|
342
|
+
if (mode === 'thinking') write((thinkAtLineStart ? '' : '\n') + '\n')
|
|
272
343
|
mode = 'content'
|
|
273
344
|
}
|
|
274
345
|
lineBuf += text
|
|
@@ -329,7 +400,7 @@ export function printToolHeader(name, summary, write = (s) => process.stdout.wri
|
|
|
329
400
|
* 工具结果块:
|
|
330
401
|
* └─ 首行
|
|
331
402
|
* │ 后续行…
|
|
332
|
-
* 退出码非 0 → 黄色;"错误/已拒绝"开头 →
|
|
403
|
+
* 退出码非 0 → 黄色;"错误/已拒绝"开头 → 红色;其余用青色(cyan,dim/gray 太浅看不清)。
|
|
333
404
|
* (首行用 └─ 而不是 Claude 的 ⎿:box-drawing 字符在 Windows 终端字体里渲染更稳,
|
|
334
405
|
* 且与输入框 ╭╰ 边框同一字符族,视觉更统一)
|
|
335
406
|
*/
|
|
@@ -339,7 +410,8 @@ export function printToolResult(result, write = (s) => process.stdout.write(s))
|
|
|
339
410
|
const exitCode = exitMatch ? Number(exitMatch[1]) : null
|
|
340
411
|
const isError = exitCode !== null && exitCode !== 0
|
|
341
412
|
|| /^(错误|已拒绝|Error)/.test(text.trim())
|
|
342
|
-
|
|
413
|
+
// 正文用 cyan(比 dim/gray 亮,能看清);错误保持黄色告警。槽线仍用 dim 当装饰导轨
|
|
414
|
+
const colorize = isError ? chalk.yellow : chalk.cyan
|
|
343
415
|
const lines = text.split('\n')
|
|
344
416
|
const rendered = lines.map((l, i) => {
|
|
345
417
|
const gutter = i === 0 ? chalk.dim(' └─ ') : chalk.dim(' │ ')
|
|
@@ -363,6 +435,9 @@ export default {
|
|
|
363
435
|
truncateDisplay,
|
|
364
436
|
printBanner,
|
|
365
437
|
printHelpPanel,
|
|
438
|
+
SLASH_COMMANDS,
|
|
439
|
+
filterSlashCommands,
|
|
440
|
+
renderSlashHintBody,
|
|
366
441
|
drawInputTop,
|
|
367
442
|
drawInputBottom,
|
|
368
443
|
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()}
|
|
@@ -213,3 +214,40 @@ test('printToolResult: 超长结果被截断并含省略标记', () => {
|
|
|
213
214
|
printToolResult(long, c.write)
|
|
214
215
|
assert.match(c.text(), /回显省略/)
|
|
215
216
|
})
|
|
217
|
+
|
|
218
|
+
// ── 斜杠命令即时提示 ──
|
|
219
|
+
|
|
220
|
+
test('filterSlashCommands: 单个 / 返回全部命令', () => {
|
|
221
|
+
const got = filterSlashCommands('/', 'zh').map(m => m.cmd)
|
|
222
|
+
assert.deepEqual(got, SLASH_COMMANDS.map(c => c.cmd))
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('filterSlashCommands: 前缀过滤,大小写不敏感', () => {
|
|
226
|
+
assert.deepEqual(filterSlashCommands('/m', 'zh').map(m => m.cmd), ['/model'])
|
|
227
|
+
assert.deepEqual(filterSlashCommands('/AD', 'zh').map(m => m.cmd), ['/addmodel'])
|
|
228
|
+
assert.deepEqual(filterSlashCommands('/exit', 'zh').map(m => m.cmd), ['/exit'])
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('filterSlashCommands: 已输入空格(进入参数)时不再提示', () => {
|
|
232
|
+
assert.deepEqual(filterSlashCommands('/model 2', 'zh'), [])
|
|
233
|
+
assert.deepEqual(filterSlashCommands('/cd ..', 'zh'), [])
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test('filterSlashCommands: 非 slash 输入 / 无匹配返回空', () => {
|
|
237
|
+
assert.deepEqual(filterSlashCommands('hello', 'zh'), [])
|
|
238
|
+
assert.deepEqual(filterSlashCommands('', 'zh'), [])
|
|
239
|
+
assert.deepEqual(filterSlashCommands('/xyz', 'zh'), [])
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('filterSlashCommands: locale 决定说明语言', () => {
|
|
243
|
+
const zh = filterSlashCommands('/help', 'zh')[0]
|
|
244
|
+
const en = filterSlashCommands('/help', 'en')[0]
|
|
245
|
+
assert.equal(zh.desc, '显示帮助')
|
|
246
|
+
assert.equal(en.desc, 'Show help')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('renderSlashHintBody: 每行含命令名与说明,空输入返回空串', () => {
|
|
250
|
+
assert.equal(renderSlashHintBody([]), '')
|
|
251
|
+
const body = stripAnsi(renderSlashHintBody(filterSlashCommands('/m', 'en')))
|
|
252
|
+
assert.match(body, /\/model\s+List \/ switch models/)
|
|
253
|
+
})
|
package/src/cli/ai/tools.js
CHANGED
|
@@ -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))
|