zen-gitsync 2.17.5 → 2.17.7
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 +35 -2
- package/package.json +1 -1
- package/scripts/release.js +139 -48
- package/src/aiCommit.js +6 -3
- package/src/cli/ai/agent.js +106 -311
- package/src/cli/ai/cli.integration.test.js +99 -0
- package/src/cli/ai/context.js +90 -0
- package/src/cli/ai/modelSetup.js +44 -8
- package/src/cli/ai/runtime.test.js +152 -0
- package/src/cli/ai/safety.js +27 -4
- package/src/cli/ai/safety.test.js +13 -0
- package/src/cli/ai/telemetry.js +43 -0
- package/src/cli/ai/termui.js +154 -41
- package/src/cli/ai/termui.test.js +63 -14
- package/src/cli/ai/tools.js +27 -5
- package/src/cli/ai/tools.test.js +23 -0
- package/src/cli/ai/transport.js +89 -0
- package/src/cli/ai/turn.js +126 -0
- package/src/config.js +88 -5
- package/src/ui/public/assets/{AgentView-DJOdvJEV.js → AgentView-CZc191Xm.js} +3 -3
- package/src/ui/public/assets/AgentView-CzUeyQfR.css +1 -0
- package/src/ui/public/assets/{AppVersionBadge-D2Li5_R-.js → AppVersionBadge-Gs5lUl7P.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-CSd5uaVm.js → BranchSelector-IjaFk0S-.js} +1 -1
- package/src/ui/public/assets/{CommitForm-Ckg9-zay.js → CommitForm-C-sowOXH.js} +3 -3
- package/src/ui/public/assets/{CommonDialog-B8XOXtz_.js → CommonDialog-QyptFD1R.js} +1 -1
- package/src/ui/public/assets/{EditorView-Crj8S2TI.js → EditorView-CvvluFCY.js} +1 -1
- package/src/ui/public/assets/{FileDiffViewer-DL07IMz5.js → FileDiffViewer-BXVsdCxE.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-DvVT19Qv.js → FlowExecutionViewer-CRxipXru.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-kRhK9IyL.js → FlowOrchestrationWorkspace-AE1ZHTKF.js} +1 -1
- package/src/ui/public/assets/{IconButton-pqSYKRT8.js → IconButton-BmSueF_H.js} +1 -1
- package/src/ui/public/assets/{LogList-BrN6qtFb.js → LogList-iaVA-_aC.js} +1 -1
- package/src/ui/public/assets/{MindmapView-CSsT0NDZ.js → MindmapView-DcG4jpCh.js} +1 -1
- package/src/ui/public/assets/{MonitorView-edRyBybQ.js → MonitorView-DO59syvl.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-DZ8VgU79.js → ProjectStartupButton-DYppceyw.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-pu9szuYB.js → RemoteManagerDialog-CDbxG6pZ.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-BSFehgY5.js → RemoteRepoCard-Cko2KB8w.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-DbEydLSU.js → SourceMapView-CwWPVdz-.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-BVCbrPws.js → SvgIcon-Cf7nOKc8.js} +1 -1
- package/src/ui/public/assets/{TemplateManager-DPyYaGCE.js → TemplateManager-DWPDjWxM.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-DXHeCjzG.js → UserInputNode-YOziN-5A.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-DNnEpIYy.js +13 -0
- package/src/ui/public/assets/WorkbenchView-v3QFn6Ln.css +1 -0
- package/src/ui/public/assets/_plugin-vue_export-helper-CQjg8MXm.js +19 -0
- package/src/ui/public/assets/configStore-C7HOx5S1.js +1 -0
- package/src/ui/public/assets/{gitStore-BJbNtjxA.js → gitStore-DIsBd6n4.js} +1 -1
- package/src/ui/public/assets/index-FOe1cHkh.css +1 -0
- package/src/ui/public/assets/index-MmcnT2Dn.js +24 -0
- package/src/ui/public/assets/{previewSandbox-BZV6Eqhv.js → previewSandbox-TKucqLxt.js} +0 -0
- package/src/ui/public/assets/{toolsStore-Bu8Ujw_A.js → toolsStore-BiC2mmC3.js} +1 -1
- package/src/ui/public/assets/{vendor-CwZs6S_i.js → vendor-92fKgtnz.js} +7 -7
- package/src/ui/public/assets/{vendor-_6pgW9Z2.css → vendor-B1UrVTTI.css} +1 -1
- package/src/ui/public/index.html +14 -14
- package/src/ui/server/routes/codeAnalysis.js +6 -3
- package/src/ui/server/routes/config.js +40 -9
- package/src/ui/server/routes/fileOpen.js +48 -2
- package/src/ui/server/routes/gAiLauncher.test.js +70 -0
- package/src/ui/server/routes/workbench/agentChat.js +34 -8
- package/src/ui/server/routes/workbench/index.js +37 -6
- package/src/ui/server/routes/workbench/jobStore.js +4 -0
- package/src/ui/server/routes/workbench/llmClient.js +15 -8
- package/src/ui/server/routes/workbench/taskRunner.js +276 -96
- package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +126 -0
- package/src/utils/aiEndpoint.js +231 -0
- package/src/utils/aiEndpoint.test.js +175 -0
- package/src/ui/public/assets/AgentView-BsN9y4Kg.css +0 -1
- package/src/ui/public/assets/WorkbenchView-CKXtZAJn.css +0 -1
- package/src/ui/public/assets/WorkbenchView-DGcirla3.js +0 -13
- package/src/ui/public/assets/_plugin-vue_export-helper-C-YBnnP5.js +0 -19
- package/src/ui/public/assets/configStore-DoobmT7N.js +0 -1
- package/src/ui/public/assets/index-ClqvC4kJ.css +0 -1
- package/src/ui/public/assets/index-ndDaGoRO.js +0 -24
package/src/cli/ai/termui.js
CHANGED
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
//
|
|
17
17
|
// - 盒式输入框(Codex composer 风格):╭───╮ + ❯ 提示符 + ╰───╯
|
|
18
18
|
// - 等待 spinner(ora):首个 token 到达前持续转动
|
|
19
|
-
// -
|
|
19
|
+
// - 思考过程:琥珀色标题 + 灰色流式预览,可切换完整显示
|
|
20
20
|
// - 工具调用块(Claude Code 风格):⚙ 工具头 + 智能参数摘要,
|
|
21
21
|
// 结果用 │ 缩进槽,按退出码/错误前缀着色
|
|
22
|
-
// -
|
|
22
|
+
// - 正文:独立回答标题 + 自适应行宽的轻量 markdown 渲染
|
|
23
23
|
// (**bold**、`code`、# 标题、``` 代码块、- 列表)
|
|
24
24
|
//
|
|
25
25
|
// 设计约束:
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
|
|
30
30
|
import chalk from 'chalk'
|
|
31
31
|
import ora from 'ora'
|
|
32
|
+
import stringWidth from 'string-width'
|
|
33
|
+
import { stripVTControlCharacters } from 'node:util'
|
|
32
34
|
import { boxenAdaptive } from '../ui.js'
|
|
33
35
|
|
|
34
36
|
// ──────────────────────────────────────────────
|
|
@@ -54,7 +56,32 @@ export function termWidth(fallback = 100) {
|
|
|
54
56
|
|
|
55
57
|
/** 去掉 ANSI 转义(测试断言用) */
|
|
56
58
|
export function stripAnsi(s) {
|
|
57
|
-
return String(s)
|
|
59
|
+
return stripVTControlCharacters(String(s))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Wrap terminal cells, preserving colour and CJK/emoji widths. */
|
|
63
|
+
export function wrapTerminalText(text, width = 100) {
|
|
64
|
+
const rows = []
|
|
65
|
+
let row = '', cells = 0, lastBreak = -1, breakCells = 0
|
|
66
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
67
|
+
for (const part of String(text).split(/(\x1b\[[0-9;]*m)/g)) {
|
|
68
|
+
if (/^\x1b\[/.test(part)) { row += part; continue }
|
|
69
|
+
for (const { segment } of segmenter.segment(part.replace(/\t/g, ' '))) {
|
|
70
|
+
if (segment === '\n') { rows.push(row); row = ''; cells = 0; lastBreak = -1; continue }
|
|
71
|
+
const size = stringWidth(segment)
|
|
72
|
+
if (cells && cells + size > Math.max(1, width)) {
|
|
73
|
+
if (lastBreak > 0 && breakCells >= width / 3) {
|
|
74
|
+
rows.push(row.slice(0, lastBreak)); row = row.slice(lastBreak); cells -= breakCells
|
|
75
|
+
} else { rows.push(row); row = ''; cells = 0 }
|
|
76
|
+
lastBreak = -1
|
|
77
|
+
}
|
|
78
|
+
row += segment
|
|
79
|
+
cells += size
|
|
80
|
+
if (/^[ \t]$/.test(segment)) { lastBreak = row.length; breakCells = cells }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
rows.push(row)
|
|
84
|
+
return rows
|
|
58
85
|
}
|
|
59
86
|
|
|
60
87
|
/**
|
|
@@ -117,6 +144,8 @@ export const SLASH_COMMANDS = [
|
|
|
117
144
|
{ cmd: '/cd', descZh: '切换工作目录', descEn: 'Change working directory' },
|
|
118
145
|
{ cmd: '/image', descZh: '附加 / 查看图片', descEn: 'Attach / list images' },
|
|
119
146
|
{ cmd: '/think', descZh: '开关思考过程显示', descEn: 'Toggle thinking display' },
|
|
147
|
+
{ cmd: '/tools', descZh: '切换工具结果:精简 / 完整', descEn: 'Toggle compact / full tool output' },
|
|
148
|
+
{ cmd: '/stats', descZh: '查看耗时与 Token 用量', descEn: 'Show timing and token usage' },
|
|
120
149
|
{ cmd: '/new', descZh: '开启新对话', descEn: 'Start a new chat' },
|
|
121
150
|
{ cmd: '/resume', descZh: '恢复之前的对话', descEn: 'Resume a previous chat' },
|
|
122
151
|
{ cmd: '/clear', descZh: '清空对话历史', descEn: 'Clear conversation' },
|
|
@@ -341,6 +370,7 @@ export function drawInputBottom(write = (s) => process.stdout.write(s)) {
|
|
|
341
370
|
// 非 TTY 时 ora 自动退化为只打印一次文本。
|
|
342
371
|
|
|
343
372
|
export function startSpinner(text) {
|
|
373
|
+
const started = performance.now()
|
|
344
374
|
const spinner = ora({
|
|
345
375
|
// 琥珀色加粗,与思考内容同色系,dim 太浅看不清
|
|
346
376
|
text: chalk.hex('#e8a33d').bold(text),
|
|
@@ -355,9 +385,14 @@ export function startSpinner(text) {
|
|
|
355
385
|
discardStdin: false,
|
|
356
386
|
})
|
|
357
387
|
spinner.start()
|
|
388
|
+
const timer = spinner.isSpinning ? setInterval(() => {
|
|
389
|
+
spinner.text = chalk.hex('#e8a33d')(text) + chalk.gray(` · ${formatDuration(performance.now() - started)}`)
|
|
390
|
+
}, 100) : null
|
|
391
|
+
timer?.unref()
|
|
358
392
|
return {
|
|
359
393
|
stop() {
|
|
360
|
-
|
|
394
|
+
clearInterval(timer)
|
|
395
|
+
spinner.stop()
|
|
361
396
|
},
|
|
362
397
|
}
|
|
363
398
|
}
|
|
@@ -392,7 +427,11 @@ function renderInline(line, resetFg = '') {
|
|
|
392
427
|
*/
|
|
393
428
|
export function createAssistantWriter({
|
|
394
429
|
showThinking = true,
|
|
395
|
-
thinkingHeader = '
|
|
430
|
+
thinkingHeader = '思考',
|
|
431
|
+
answerHeader = '回答',
|
|
432
|
+
thinkingLimit = Infinity,
|
|
433
|
+
thinkingHint = '… 思考预览已收起,/think full 显示完整过程',
|
|
434
|
+
width = Math.min(100, termWidth() - 4),
|
|
396
435
|
write = (s) => process.stdout.write(s),
|
|
397
436
|
} = {}) {
|
|
398
437
|
let mode = null // null | 'thinking' | 'content'
|
|
@@ -401,18 +440,36 @@ export function createAssistantWriter({
|
|
|
401
440
|
let contentLines = 0 // 已输出正文行数(首行带 ➤ 子弹头)
|
|
402
441
|
let lastBlank = false // 上一行是空白行(连续空行合并,避免模型输出头部/分隔空行刷屏)
|
|
403
442
|
let thinkAtLineStart = true // 思考流当前是否在行首(用于逐行缩进 + 计算与正文的分隔)
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const
|
|
443
|
+
let thinkingCells = 0, thinkingRows = 0, thinkingClipped = false
|
|
444
|
+
let answerShown = false
|
|
445
|
+
// 回答正文统一缩进;标题作为独立的视觉锚点。
|
|
446
|
+
const BULLET_FIRST = ' '
|
|
447
|
+
const BULLET_REST = ' '
|
|
408
448
|
// whiteBright ANSI 码:行内 code 的 \x1b[39m 会清掉外层色,用此恢复正文亮白色
|
|
409
|
-
const WB = '\x1b[97m'
|
|
449
|
+
const WB = chalk.level ? '\x1b[97m' : ''
|
|
410
450
|
// 思考内容整体右移,与正文文字左缘对齐,视觉上成为独立子块
|
|
411
|
-
const THINK_INDENT = '
|
|
412
|
-
//
|
|
413
|
-
const thinkStyle = (s) => chalk.hex('#
|
|
414
|
-
//
|
|
415
|
-
const THINK_ICON = '
|
|
451
|
+
const THINK_INDENT = chalk.gray(' │ ')
|
|
452
|
+
// 思考正文降低视觉权重,仅标题用琥珀色。
|
|
453
|
+
const thinkStyle = (s) => chalk.hex('#aab2c0')(s)
|
|
454
|
+
// 使用单色符号,避免 emoji 在不同终端中的宽度差异。
|
|
455
|
+
const THINK_ICON = chalk.hex('#e8a33d')('◇')
|
|
456
|
+
const clipThinking = () => {
|
|
457
|
+
if (!thinkAtLineStart) write('\n')
|
|
458
|
+
write(wrapTerminalText(thinkingHint, Math.max(8, width - 2)).map(row => THINK_INDENT + chalk.gray(row)).join('\n') + '\n')
|
|
459
|
+
thinkingClipped = true
|
|
460
|
+
thinkAtLineStart = true
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const showAnswerHeader = () => {
|
|
464
|
+
if (answerShown) return
|
|
465
|
+
answerShown = true
|
|
466
|
+
write(chalk.greenBright.bold(`◆ ${answerHeader}`) + '\n\n')
|
|
467
|
+
}
|
|
468
|
+
const writeContentRows = (prefix, body, withNewline) => {
|
|
469
|
+
showAnswerHeader()
|
|
470
|
+
const rows = wrapTerminalText(body, Math.max(8, width))
|
|
471
|
+
write(rows.map(row => prefix + row).join('\n') + (withNewline ? '\n' : ''))
|
|
472
|
+
}
|
|
416
473
|
|
|
417
474
|
const emitContentLine = (raw, withNewline = true) => {
|
|
418
475
|
// 围栏标记行:切换状态,用一个淡淡的槽线代替裸 ```
|
|
@@ -421,7 +478,8 @@ export function createAssistantWriter({
|
|
|
421
478
|
const bullet = contentLines === 0 ? BULLET_FIRST : BULLET_REST
|
|
422
479
|
contentLines++
|
|
423
480
|
lastBlank = false
|
|
424
|
-
|
|
481
|
+
const language = raw.trim().slice(3).trim() || 'code'
|
|
482
|
+
writeContentRows(bullet, chalk.gray(inFence ? '┌─ ' + language : '└' + '─'.repeat(12)), withNewline)
|
|
425
483
|
return
|
|
426
484
|
}
|
|
427
485
|
// 围栏内:代码行原样带槽线(空行也保留,代码格式不能动)
|
|
@@ -429,7 +487,8 @@ export function createAssistantWriter({
|
|
|
429
487
|
const bullet = contentLines === 0 ? BULLET_FIRST : BULLET_REST
|
|
430
488
|
contentLines++
|
|
431
489
|
lastBlank = false
|
|
432
|
-
|
|
490
|
+
showAnswerHeader()
|
|
491
|
+
write(wrapTerminalText(raw, Math.max(8, width - 2)).map(row => bullet + chalk.gray('│ ') + row).join('\n') + (withNewline ? '\n' : ''))
|
|
433
492
|
return
|
|
434
493
|
}
|
|
435
494
|
// 空白行:首个正文行之前不输出;连续空行合并为一行
|
|
@@ -447,7 +506,7 @@ export function createAssistantWriter({
|
|
|
447
506
|
const body = h
|
|
448
507
|
? chalk.bold.whiteBright(renderInline(h[2], WB))
|
|
449
508
|
: chalk.whiteBright(renderInline(raw, WB))
|
|
450
|
-
|
|
509
|
+
writeContentRows(bullet, body, withNewline)
|
|
451
510
|
}
|
|
452
511
|
|
|
453
512
|
const flushLineBuf = (withNewline = true) => {
|
|
@@ -459,30 +518,34 @@ export function createAssistantWriter({
|
|
|
459
518
|
return {
|
|
460
519
|
/** 思考段:橙黄斜体 + 整体右缩进(不做 markdown),段头只打印一次 */
|
|
461
520
|
writeThinking(text) {
|
|
462
|
-
if (!showThinking || !text) return
|
|
521
|
+
if (!showThinking || !text || thinkingClipped) return
|
|
463
522
|
if (mode !== 'thinking') {
|
|
464
523
|
flushLineBuf()
|
|
465
524
|
mode = 'thinking'
|
|
466
525
|
// 图标 🧠 靠最左(0 缩进),与 ⚙ 工具头、➤ 正文对齐同一左缘
|
|
467
526
|
// 段头前空一行与上文(spinner / 上一轮输出)分隔,视觉更清晰
|
|
468
|
-
write('\n' + THINK_ICON + '
|
|
527
|
+
write('\n' + THINK_ICON + ' ' + chalk.hex('#e8a33d').bold(thinkingHeader) + '\n')
|
|
469
528
|
thinkAtLineStart = true
|
|
470
529
|
}
|
|
471
530
|
// 按换行切段,整段着色(避免逐字符 escape 刷屏);每逢行首补一层缩进,
|
|
472
531
|
// 使多行思考整体右移成独立子块。流式 token 可能不以换行结尾,
|
|
473
532
|
// 故用 thinkAtLineStart 记住跨调用的行首状态。
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
if (thinkAtLineStart) write(THINK_INDENT)
|
|
479
|
-
write(thinkStyle(seg))
|
|
480
|
-
thinkAtLineStart = false
|
|
533
|
+
for (const char of text) {
|
|
534
|
+
if (thinkingRows >= thinkingLimit) {
|
|
535
|
+
clipThinking()
|
|
536
|
+
break
|
|
481
537
|
}
|
|
482
|
-
if (
|
|
483
|
-
write('\n')
|
|
484
|
-
|
|
538
|
+
if (char === '\n' || thinkingCells + stringWidth(char) > Math.max(8, width - 2)) {
|
|
539
|
+
write('\n'); thinkingRows++; thinkingCells = 0; thinkAtLineStart = true
|
|
540
|
+
if (char === '\n') continue
|
|
541
|
+
if (thinkingRows >= thinkingLimit) {
|
|
542
|
+
clipThinking(); break
|
|
543
|
+
}
|
|
485
544
|
}
|
|
545
|
+
if (thinkAtLineStart) write(THINK_INDENT)
|
|
546
|
+
write(thinkStyle(char))
|
|
547
|
+
thinkingCells += stringWidth(char)
|
|
548
|
+
thinkAtLineStart = false
|
|
486
549
|
}
|
|
487
550
|
},
|
|
488
551
|
|
|
@@ -535,6 +598,43 @@ export function formatDuration(ms) {
|
|
|
535
598
|
return `${m}m${rem}s`
|
|
536
599
|
}
|
|
537
600
|
|
|
601
|
+
export function renderTurnSummary(stats, { locale = 'zh-CN', session, width = Math.min(100, termWidth() - 4) } = {}) {
|
|
602
|
+
const zh = !String(locale).startsWith('en')
|
|
603
|
+
const number = n => n == null ? '—' : Math.round(n).toLocaleString('en-US')
|
|
604
|
+
const usageLine = (usage, reported, requests) => {
|
|
605
|
+
if (!usage) return zh ? 'Token:服务端未返回用量' : 'Tokens: not reported by provider'
|
|
606
|
+
const parts = [
|
|
607
|
+
`Token ${number(usage.totalTokens)}`,
|
|
608
|
+
`${zh ? '输入' : 'in'} ${number(usage.inputTokens)}`,
|
|
609
|
+
`${zh ? '输出' : 'out'} ${number(usage.outputTokens)}`,
|
|
610
|
+
]
|
|
611
|
+
if (usage.cachedTokens != null) parts.push(`${zh ? '输入含缓存' : 'cached input'} ${number(usage.cachedTokens)}`)
|
|
612
|
+
if (usage.reasoningTokens != null) parts.push(`${zh ? '输出含推理' : 'reasoning output'} ${number(usage.reasoningTokens)}`)
|
|
613
|
+
if (reported < requests) parts.push(zh ? `部分用量(${reported}/${requests} 次请求)` : `partial usage (${reported}/${requests} requests)`)
|
|
614
|
+
return parts.join(' · ')
|
|
615
|
+
}
|
|
616
|
+
const status = zh
|
|
617
|
+
? ({ completed: '完成', cancelled: '已停止', failed: '失败', limit: '达到调用上限' }[stats.status] || '进行中')
|
|
618
|
+
: ({ completed: 'Done', cancelled: 'Stopped', failed: 'Failed', limit: 'Tool limit reached' }[stats.status] || 'Running')
|
|
619
|
+
const time = stats.completedAt ? new Date(stats.completedAt).toLocaleTimeString(zh ? 'zh-CN' : 'en-GB', { hour12: false }) : ''
|
|
620
|
+
const timing = [status + (time ? ` ${time}` : ''), `${zh ? '总耗时' : 'total'} ${formatDuration(stats.totalMs)}`]
|
|
621
|
+
if (stats.firstTokenMs != null) timing.push(`${zh ? '首响应' : 'first token'} ${formatDuration(stats.firstTokenMs)}`)
|
|
622
|
+
if (stats.firstAnswerMs != null) timing.push(`${zh ? '正文等待' : 'first answer'} ${formatDuration(stats.firstAnswerMs)}`)
|
|
623
|
+
const lines = [
|
|
624
|
+
timing.join(' · '),
|
|
625
|
+
`${zh ? '模型' : 'model'} ${formatDuration(stats.llmMs)} · ${zh ? '工具' : 'tools'} ${formatDuration(stats.toolsMs)} (${stats.toolCalls}) · ${zh ? '请求' : 'requests'} ${stats.requests}`,
|
|
626
|
+
usageLine(stats.usage, stats.usageRequests, stats.requests),
|
|
627
|
+
]
|
|
628
|
+
if (session) lines.push(`${zh ? '会话累计' : 'Session'} (${session.turns}) · ${usageLine(session.usage, session.usageRequests, session.requests)}`)
|
|
629
|
+
const rows = lines.flatMap(line => wrapTerminalText(line, Math.max(12, width)))
|
|
630
|
+
return '\n' + chalk.gray(' ' + '─'.repeat(Math.max(12, Math.min(56, width)))) + '\n'
|
|
631
|
+
+ rows.map(row => ' ' + chalk.hex('#aab2c0')(row)).join('\n') + '\n'
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export function printTurnSummary(stats, options, write = s => process.stdout.write(s)) {
|
|
635
|
+
write(renderTurnSummary(stats, options))
|
|
636
|
+
}
|
|
637
|
+
|
|
538
638
|
// ──────────────────────────────────────────────
|
|
539
639
|
// 工具调用块(Claude Code 风格)
|
|
540
640
|
// ──────────────────────────────────────────────
|
|
@@ -568,13 +668,14 @@ export function summarizeToolArgs(name, args, { chars = '字符' } = {}) {
|
|
|
568
668
|
}
|
|
569
669
|
|
|
570
670
|
/** 工具头:▶ name 参数摘要(青色三角表示"工具执行";name 加粗白色,摘要灰色,避免整块青色) */
|
|
571
|
-
export function printToolHeader(name, summary, write = (s) => process.stdout.write(s)) {
|
|
671
|
+
export function printToolHeader(name, summary, write = (s) => process.stdout.write(s), { locale = 'zh-CN' } = {}) {
|
|
572
672
|
// ▶ 保留青色作为工具执行的标识色;name 用白色加粗与下方结果区分;
|
|
573
673
|
// summary 用浅灰(#a0aec0)— dim 在黑底下太暗看不清,浅灰可读且仍比 name 低调
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
674
|
+
const labels = { run_command: '执行命令', read_file: '读取文件', write_file: '写入文件', edit_file: '修改文件', list_files: '浏览目录', search_text: '搜索内容' }
|
|
675
|
+
const rows = wrapTerminalText(summary || '', Math.max(16, Math.min(100, termWidth() - 6)))
|
|
676
|
+
const label = String(locale).startsWith('en') ? name : labels[name] || name
|
|
677
|
+
write('\n' + chalk.cyan('▸ ') + chalk.bold.white(label) + (label !== name ? chalk.gray(` ${name}`) : '') + '\n')
|
|
678
|
+
if (summary) write(rows.map(row => ' ' + chalk.hex('#aab2c0')(row)).join('\n') + '\n')
|
|
578
679
|
}
|
|
579
680
|
|
|
580
681
|
/**
|
|
@@ -590,22 +691,31 @@ export function printToolHeader(name, summary, write = (s) => process.stdout.wri
|
|
|
590
691
|
* @param {(s: string) => void} [write] - 输出函数,默认写 stdout
|
|
591
692
|
* @param {number} [durationMs] - 执行耗时(毫秒),有值时在结果末尾追加 ⏱ 计时行
|
|
592
693
|
*/
|
|
593
|
-
export function printToolResult(result, write = (s) => process.stdout.write(s), durationMs) {
|
|
594
|
-
const text =
|
|
694
|
+
export function printToolResult(result, write = (s) => process.stdout.write(s), durationMs, { full = false, locale = 'zh-CN', width = Math.min(100, termWidth() - 6) } = {}) {
|
|
695
|
+
const text = stripAnsi(result)
|
|
595
696
|
// 先把 "$ <command>\n" 这一行回显从展示里剥掉(退出码仍在第二行里识别)
|
|
596
|
-
const visible = text.replace(/^\$[^\n]*\n/, '')
|
|
697
|
+
const visible = text.replace(/^\$[^\n]*\n/, '').replace(/^\(exit \d+\)\n?/, '')
|
|
597
698
|
const exitMatch = text.match(/^\$[^\n]*\n\(exit (\d+)\)/)
|
|
598
699
|
const exitCode = exitMatch ? Number(exitMatch[1]) : null
|
|
700
|
+
const isCancelled = /命令已由用户停止|Cancelled by user/.test(text)
|
|
599
701
|
const isError = exitCode !== null && exitCode !== 0
|
|
600
702
|
|| /^(错误|已拒绝|Error)/.test(text.trim())
|
|
601
703
|
// 正文用柔和灰蓝(#94a3b8)— 不再用青色,避免与工具头同色连成一片;
|
|
602
704
|
// 错误用琥珀色(#f0a020)比黄色更醒目
|
|
603
705
|
const colorize = isError ? chalk.hex('#f0a020') : chalk.hex('#94a3b8')
|
|
604
|
-
const
|
|
605
|
-
|
|
706
|
+
const zh = !String(locale).startsWith('en')
|
|
707
|
+
let lines = wrapTerminalText(visible.trimEnd(), Math.max(8, width))
|
|
708
|
+
const maxRows = isError ? 10 : 6
|
|
709
|
+
if (!full && lines.length > maxRows) {
|
|
710
|
+
const omitted = lines.length - maxRows + 1
|
|
711
|
+
const hint = width < 35 ? `… +${omitted}` : zh ? `… 回显省略 ${omitted} 行 · /tools full` : `… +${omitted} lines · /tools full`
|
|
712
|
+
lines = [...lines.slice(0, 3), hint, ...lines.slice(-(maxRows - 4))]
|
|
713
|
+
}
|
|
714
|
+
const rendered = lines.map((l) => chalk.gray(' │ ') + colorize(l)).join('\n')
|
|
606
715
|
// 有耗时时在结果末尾追加 ⏱ 计时行(与结果块同缩进)
|
|
607
716
|
const timingLine = (durationMs != null && Number.isFinite(durationMs))
|
|
608
|
-
? '\n' + chalk.
|
|
717
|
+
? '\n' + chalk.gray(' └ ') + colorize(isCancelled ? (zh ? '已停止' : 'Stopped') : isError ? (zh ? '失败' : 'Failed') : (zh ? '完成' : 'Done'))
|
|
718
|
+
+ chalk.gray(` · ${formatDuration(durationMs)}${exitCode !== null ? ` · exit ${exitCode}` : ''}`)
|
|
609
719
|
: ''
|
|
610
720
|
write(rendered + timingLine + '\n')
|
|
611
721
|
}
|
|
@@ -640,6 +750,9 @@ export default {
|
|
|
640
750
|
summarizeToolArgs,
|
|
641
751
|
printToolHeader,
|
|
642
752
|
printToolResult,
|
|
753
|
+
printTurnSummary,
|
|
754
|
+
renderTurnSummary,
|
|
755
|
+
wrapTerminalText,
|
|
643
756
|
printOk,
|
|
644
757
|
printWarn,
|
|
645
758
|
printError,
|
|
@@ -21,7 +21,7 @@ import chalk from 'chalk'
|
|
|
21
21
|
import {
|
|
22
22
|
stripAnsi, truncateDisplay, summarizeToolArgs,
|
|
23
23
|
createAssistantWriter, printToolHeader, printToolResult,
|
|
24
|
-
formatDuration,
|
|
24
|
+
formatDuration, wrapTerminalText, renderTurnSummary,
|
|
25
25
|
filterSlashCommands, renderSlashHintBody, parseKeyForSlashHint, SLASH_COMMANDS,
|
|
26
26
|
renderSelectableListBody, parseKeyForSelectableList,
|
|
27
27
|
} from './termui.js'
|
|
@@ -110,15 +110,16 @@ test('writer: showThinking=false 时丢弃思考段', () => {
|
|
|
110
110
|
assert.ok(text.includes('正文'))
|
|
111
111
|
})
|
|
112
112
|
|
|
113
|
-
test('writer:
|
|
113
|
+
test('writer: 回答标题独立显示,正文各行缩进对齐', () => {
|
|
114
114
|
const c = collect()
|
|
115
115
|
const w = createAssistantWriter({ write: c.write })
|
|
116
116
|
w.writeContent('第一行\n第二行\n第三行')
|
|
117
117
|
w.finish()
|
|
118
118
|
const lines = c.lines()
|
|
119
|
-
assert.
|
|
120
|
-
assert.ok(lines.
|
|
121
|
-
assert.ok(lines.
|
|
119
|
+
assert.equal(lines.filter(l => l === '◆ 回答').length, 1)
|
|
120
|
+
assert.ok(lines.includes(' 第一行'))
|
|
121
|
+
assert.ok(lines.includes(' 第二行'))
|
|
122
|
+
assert.ok(lines.includes(' 第三行'))
|
|
122
123
|
})
|
|
123
124
|
|
|
124
125
|
test('writer: 行内 markdown — bold 标记被消费,code 反引号被消费', () => {
|
|
@@ -194,19 +195,19 @@ test('writer: 头部空白行被吞掉,连续空白行合并(真实 MiniMax 输
|
|
|
194
195
|
const lines = c.lines()
|
|
195
196
|
// null→content 自动在 🤖 上方加一空行,跳过空行找正文首行
|
|
196
197
|
const firstContent = lines.find(l => l !== '')
|
|
197
|
-
assert.equal(firstContent, '
|
|
198
|
+
assert.equal(firstContent, '◆ 回答')
|
|
198
199
|
// "答案"与"下一段"之间最多一个空行,且空行不带缩进
|
|
199
|
-
const midBlank = lines.slice(1, lines.indexOf('
|
|
200
|
+
const midBlank = lines.slice(lines.indexOf(' 答案') + 1, lines.indexOf(' 下一段')).filter(l => l === '').length
|
|
200
201
|
assert.ok(midBlank <= 1, `空行应被合并,实际行: ${JSON.stringify(lines)}`)
|
|
201
|
-
assert.ok(lines.includes('
|
|
202
|
+
assert.ok(lines.includes(' 下一段'))
|
|
202
203
|
})
|
|
203
204
|
|
|
204
205
|
// ── 工具块 ──
|
|
205
|
-
test('printToolHeader:
|
|
206
|
+
test('printToolHeader: 工具标签与参数分行显示', () => {
|
|
206
207
|
const c = collect()
|
|
207
208
|
printToolHeader('run_command', '$ npm test', c.write)
|
|
208
209
|
const text = c.text()
|
|
209
|
-
assert.ok(text.includes('
|
|
210
|
+
assert.ok(text.includes('▸ 执行命令 run_command'))
|
|
210
211
|
assert.ok(text.includes('$ npm test'))
|
|
211
212
|
})
|
|
212
213
|
|
|
@@ -214,7 +215,7 @@ test('printToolResult: 全部行统一 │ 槽线(无 └─ 拐角)', () => {
|
|
|
214
215
|
const c = collect()
|
|
215
216
|
printToolResult('$ ls\n(exit 0)\nfile1\nfile2', c.write)
|
|
216
217
|
const lines = c.lines()
|
|
217
|
-
assert.ok(lines.every(l => l.startsWith(' │
|
|
218
|
+
assert.ok(lines.every(l => l.startsWith(' │ ') || l === ''), `每行都应为 │ 槽线,实际: ${JSON.stringify(lines)}`)
|
|
218
219
|
assert.ok(!lines.some(l => l.includes('└─')), '不应出现 └─ 拐角字符')
|
|
219
220
|
})
|
|
220
221
|
|
|
@@ -252,15 +253,15 @@ test('formatDuration: 无效输入返回空串', () => {
|
|
|
252
253
|
})
|
|
253
254
|
|
|
254
255
|
// ── printToolResult 带耗时 ──
|
|
255
|
-
test('printToolResult:
|
|
256
|
+
test('printToolResult: 结果末尾显示状态与耗时', () => {
|
|
256
257
|
const c = collect()
|
|
257
258
|
printToolResult('hello world', c.write, 1500)
|
|
258
259
|
// 去掉末尾空行(split('\n') 在末尾 \n 后产生空串)
|
|
259
260
|
const lines = c.lines().filter(l => l !== '')
|
|
260
261
|
const lastLine = lines[lines.length - 1]
|
|
261
|
-
assert.ok(lastLine.includes('
|
|
262
|
+
assert.ok(lastLine.includes('完成'), `应包含状态,实际: ${lastLine}`)
|
|
262
263
|
assert.ok(lastLine.includes('1.5s'), `应包含 1.5s,实际: ${lastLine}`)
|
|
263
|
-
assert.ok(lastLine.startsWith('
|
|
264
|
+
assert.ok(lastLine.startsWith(' └ '), `计时行应与结果块同缩进,实际: ${lastLine}`)
|
|
264
265
|
})
|
|
265
266
|
|
|
266
267
|
test('printToolResult: 不传 durationMs 时无计时行(向后兼容)', () => {
|
|
@@ -272,6 +273,54 @@ test('printToolResult: 不传 durationMs 时无计时行(向后兼容)', () => {
|
|
|
272
273
|
|
|
273
274
|
// ── 斜杠命令即时提示 ──
|
|
274
275
|
|
|
276
|
+
test('compact tools bound physical rows while full output preserves every line', () => {
|
|
277
|
+
const output = Array.from({ length: 80 }, (_, i) => `row ${i}`).join('\n')
|
|
278
|
+
const compact = collect(), full = collect()
|
|
279
|
+
printToolResult(output, compact.write, 10)
|
|
280
|
+
printToolResult(output, full.write, 10, { full: true })
|
|
281
|
+
assert.ok(compact.lines().filter(Boolean).length <= 7)
|
|
282
|
+
assert.ok(compact.text().includes('row 0'))
|
|
283
|
+
assert.ok(compact.text().includes('row 79'))
|
|
284
|
+
assert.ok(full.text().includes('row 40'))
|
|
285
|
+
assert.doesNotMatch(full.text(), /省略/)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('thinking previews stop after their row budget and still render the answer', () => {
|
|
289
|
+
const c = collect()
|
|
290
|
+
const w = createAssistantWriter({ write: c.write, thinkingLimit: 2, width: 30 })
|
|
291
|
+
w.writeThinking('first\nsecond\nthird\nfourth')
|
|
292
|
+
w.writeThinking('hidden more')
|
|
293
|
+
w.writeContent('answer')
|
|
294
|
+
w.finish()
|
|
295
|
+
assert.match(c.text(), /first\n/)
|
|
296
|
+
assert.doesNotMatch(c.text(), /third|fourth|hidden more/)
|
|
297
|
+
assert.match(c.text(), /answer/)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('terminal wrapping respects wide glyphs and preserves text', () => {
|
|
301
|
+
const text = '中文🙂中文abcdef'
|
|
302
|
+
const rows = wrapTerminalText(chalk.cyan(text), 6)
|
|
303
|
+
assert.equal(stripAnsi(rows.join('')), text)
|
|
304
|
+
assert.ok(rows.length >= 3)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('turn summaries distinguish missing and partial usage, and show cancellation', () => {
|
|
308
|
+
const stats = { status: 'cancelled', totalMs: 2000, llmMs: 1800, toolsMs: 100,
|
|
309
|
+
firstTokenMs: 200, firstAnswerMs: 500, requests: 2, toolCalls: 1, usageRequests: 0, usage: null }
|
|
310
|
+
let text = stripAnsi(renderTurnSummary(stats, { width: 200 }))
|
|
311
|
+
assert.match(text, /已停止/)
|
|
312
|
+
assert.match(text, /首响应 200ms/)
|
|
313
|
+
assert.match(text, /正文等待 500ms/)
|
|
314
|
+
assert.match(text, /服务端未返回/)
|
|
315
|
+
assert.doesNotMatch(text, /Token 0/)
|
|
316
|
+
stats.usage = { totalTokens: 120, inputTokens: 100, outputTokens: 20, cachedTokens: 80, reasoningTokens: 5 }
|
|
317
|
+
stats.usageRequests = 1
|
|
318
|
+
text = stripAnsi(renderTurnSummary(stats, { width: 200 }))
|
|
319
|
+
assert.match(text, /Token 120/)
|
|
320
|
+
assert.match(text, /部分用量(1\/2/)
|
|
321
|
+
assert.match(text, /输入含缓存 80/)
|
|
322
|
+
})
|
|
323
|
+
|
|
275
324
|
test('filterSlashCommands: 单个 / 返回全部命令', () => {
|
|
276
325
|
const got = filterSlashCommands('/', 'zh').map(m => m.cmd)
|
|
277
326
|
assert.deepEqual(got, SLASH_COMMANDS.map(c => c.cmd))
|
package/src/cli/ai/tools.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// - 工具内部异常一律 catch 成字符串返回,不抛给 agent 循环 ——
|
|
25
25
|
// 让模型看到错误信息自己修正,而不是中断整轮对话
|
|
26
26
|
|
|
27
|
-
import { exec } from 'node:child_process'
|
|
27
|
+
import { exec, execFile } from 'node:child_process'
|
|
28
28
|
import { promises as fs } from 'node:fs'
|
|
29
29
|
import path from 'node:path'
|
|
30
30
|
import iconv from 'iconv-lite'
|
|
@@ -196,20 +196,39 @@ function decodeOutput(buf) {
|
|
|
196
196
|
|
|
197
197
|
// 包装 exec 为 Promise,stdout/stderr 合并返回;出错(非零退出)也正常返回输出
|
|
198
198
|
// encoding:'buffer' 拿原始字节,由 decodeOutput 决定真实编码
|
|
199
|
+
export function terminateCommand(child) {
|
|
200
|
+
if (!child?.pid || child.exitCode !== null) return Promise.resolve()
|
|
201
|
+
if (process.platform === 'win32') {
|
|
202
|
+
return new Promise(resolve => execFile('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }, () => resolve()))
|
|
203
|
+
}
|
|
204
|
+
try { process.kill(-child.pid, 'SIGTERM') } catch { try { child.kill('SIGTERM') } catch {} }
|
|
205
|
+
return Promise.resolve()
|
|
206
|
+
}
|
|
207
|
+
|
|
199
208
|
function execCommand(command, options) {
|
|
200
209
|
return new Promise((resolve) => {
|
|
201
|
-
const
|
|
210
|
+
const { signal, timeout, onChild, ...execOptions } = options
|
|
211
|
+
let timedOut = false
|
|
212
|
+
const abort = () => { void terminateCommand(child) }
|
|
213
|
+
const child = exec(command, { ...execOptions, detached: process.platform !== 'win32', encoding: 'buffer' }, (err, stdout, stderr) => {
|
|
214
|
+
clearTimeout(timer)
|
|
215
|
+
signal?.removeEventListener('abort', abort)
|
|
202
216
|
resolve({
|
|
203
217
|
code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
|
|
204
|
-
killed:
|
|
218
|
+
killed: timedOut,
|
|
219
|
+
cancelled: !!signal?.aborted,
|
|
205
220
|
stdout: decodeOutput(stdout),
|
|
206
221
|
stderr: decodeOutput(stderr),
|
|
207
222
|
errorMessage: err && !('code' in err) ? err.message : null,
|
|
208
223
|
})
|
|
209
224
|
})
|
|
225
|
+
const timer = setTimeout(() => { timedOut = true; void terminateCommand(child) }, timeout)
|
|
226
|
+
timer.unref()
|
|
227
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
228
|
+
if (signal?.aborted) abort()
|
|
210
229
|
trackChild(child)
|
|
211
230
|
// 把 child 暴露给 agent 循环,支持用户 Ctrl+C 时立即中止当前命令
|
|
212
|
-
if (typeof
|
|
231
|
+
if (typeof onChild === 'function') onChild(child)
|
|
213
232
|
})
|
|
214
233
|
}
|
|
215
234
|
|
|
@@ -253,9 +272,11 @@ async function toolRunCommand(args, ctx) {
|
|
|
253
272
|
windowsHide: true,
|
|
254
273
|
env: { ...process.env, FORCE_COLOR: '0' },
|
|
255
274
|
onChild: ctx.onChild,
|
|
275
|
+
signal: ctx.signal,
|
|
256
276
|
})
|
|
257
277
|
|
|
258
278
|
const parts = []
|
|
279
|
+
if (result.cancelled) parts.push('[命令已由用户停止]')
|
|
259
280
|
if (result.killed) parts.push(`[命令超时被终止(>${timeoutSec}s)]`)
|
|
260
281
|
if (result.stdout) parts.push(result.stdout.trimEnd())
|
|
261
282
|
if (result.stderr) parts.push(`[stderr]\n${result.stderr.trimEnd()}`)
|
|
@@ -333,7 +354,7 @@ async function toolEditFile(args, ctx) {
|
|
|
333
354
|
return `错误: old_string 在 ${filePath} 中出现 ${count} 次,不唯一。请提供更多上下文让它唯一,或显式传 replace_all=true 全部替换。`
|
|
334
355
|
}
|
|
335
356
|
|
|
336
|
-
const updated = args.replace_all ? raw.split(oldStr).join(newStr) : raw.replace(oldStr, newStr)
|
|
357
|
+
const updated = args.replace_all ? raw.split(oldStr).join(newStr) : raw.replace(oldStr, () => newStr)
|
|
337
358
|
try {
|
|
338
359
|
await fs.writeFile(filePath, updated, 'utf-8')
|
|
339
360
|
return `已修改 ${filePath} (替换 ${args.replace_all ? count : 1} 处)`
|
|
@@ -478,6 +499,7 @@ const TOOL_HANDLERS = {
|
|
|
478
499
|
* @param {{ cwd: string, onChild?: (child: object) => void }} ctx
|
|
479
500
|
*/
|
|
480
501
|
export async function executeTool(name, args, ctx) {
|
|
502
|
+
if (ctx.signal?.aborted) return 'Cancelled by user; this tool was not executed.'
|
|
481
503
|
const handler = TOOL_HANDLERS[name]
|
|
482
504
|
if (!handler) return `错误: 未知工具 "${name}",可用工具: ${Object.keys(TOOL_HANDLERS).join(', ')}`
|
|
483
505
|
try {
|
package/src/cli/ai/tools.test.js
CHANGED
|
@@ -35,6 +35,29 @@ after(async () => {
|
|
|
35
35
|
|
|
36
36
|
// ========== schema 基本形态 ==========
|
|
37
37
|
|
|
38
|
+
test('edit_file preserves literal replacement tokens', async () => {
|
|
39
|
+
await executeTool('write_file', { path: 'literal.txt', content: 'before' }, ctx)
|
|
40
|
+
const replacement = '$& $$ $` $\x27'
|
|
41
|
+
await executeTool('edit_file', { path: 'literal.txt', old_string: 'before', new_string: replacement }, ctx)
|
|
42
|
+
assert.equal(await fs.readFile(path.join(tmpDir, 'literal.txt'), 'utf8'), replacement)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('run_command cancellation terminates a running command promptly', async () => {
|
|
46
|
+
const controller = new AbortController()
|
|
47
|
+
const started = performance.now()
|
|
48
|
+
const result = await executeTool('run_command', { command: 'node -e "setInterval(()=>{},1000)"' }, {
|
|
49
|
+
...ctx, signal: controller.signal, onChild: () => setTimeout(() => controller.abort(), 80),
|
|
50
|
+
})
|
|
51
|
+
assert.match(result, /用户停止/)
|
|
52
|
+
assert.ok(performance.now() - started < 5000)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('an already cancelled tool does not write files', async () => {
|
|
56
|
+
const result = await executeTool('write_file', { path: 'cancelled.txt', content: 'no' }, { ...ctx, signal: AbortSignal.abort() })
|
|
57
|
+
assert.match(result, /not executed/)
|
|
58
|
+
await assert.rejects(fs.stat(path.join(tmpDir, 'cancelled.txt')), { code: 'ENOENT' })
|
|
59
|
+
})
|
|
60
|
+
|
|
38
61
|
test('TOOL_DEFINITIONS 符合 OpenAI function calling 格式', () => {
|
|
39
62
|
assert.ok(Array.isArray(TOOL_DEFINITIONS))
|
|
40
63
|
assert.ok(TOOL_DEFINITIONS.length >= 5)
|