zen-gitsync 2.17.6 → 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.
Files changed (60) hide show
  1. package/README.md +22 -2
  2. package/package.json +1 -1
  3. package/scripts/release.js +139 -48
  4. package/src/cli/ai/agent.js +104 -324
  5. package/src/cli/ai/cli.integration.test.js +99 -0
  6. package/src/cli/ai/context.js +90 -0
  7. package/src/cli/ai/modelSetup.js +38 -0
  8. package/src/cli/ai/runtime.test.js +152 -0
  9. package/src/cli/ai/safety.js +27 -4
  10. package/src/cli/ai/safety.test.js +13 -0
  11. package/src/cli/ai/telemetry.js +43 -0
  12. package/src/cli/ai/termui.js +154 -41
  13. package/src/cli/ai/termui.test.js +63 -14
  14. package/src/cli/ai/tools.js +27 -5
  15. package/src/cli/ai/tools.test.js +23 -0
  16. package/src/cli/ai/transport.js +89 -0
  17. package/src/cli/ai/turn.js +126 -0
  18. package/src/config.js +34 -4
  19. package/src/ui/public/assets/{AgentView-BC3F0F6m.js → AgentView-CZc191Xm.js} +1 -1
  20. package/src/ui/public/assets/{AppVersionBadge-DV6KuLqb.js → AppVersionBadge-Gs5lUl7P.js} +2 -2
  21. package/src/ui/public/assets/{BranchSelector-DGXDAOkR.js → BranchSelector-IjaFk0S-.js} +1 -1
  22. package/src/ui/public/assets/{CommitForm-BYBj7UUa.js → CommitForm-C-sowOXH.js} +3 -3
  23. package/src/ui/public/assets/{CommonDialog-DWMCeujE.js → CommonDialog-QyptFD1R.js} +1 -1
  24. package/src/ui/public/assets/{EditorView-D-TEQZso.js → EditorView-CvvluFCY.js} +1 -1
  25. package/src/ui/public/assets/{FileDiffViewer-C4ZANajI.js → FileDiffViewer-BXVsdCxE.js} +1 -1
  26. package/src/ui/public/assets/{FlowExecutionViewer-DUv7mBuG.js → FlowExecutionViewer-CRxipXru.js} +1 -1
  27. package/src/ui/public/assets/{FlowOrchestrationWorkspace-BiSuvzL7.js → FlowOrchestrationWorkspace-AE1ZHTKF.js} +1 -1
  28. package/src/ui/public/assets/{IconButton-BltPKVbV.js → IconButton-BmSueF_H.js} +1 -1
  29. package/src/ui/public/assets/{LogList-BY1Haq9r.js → LogList-iaVA-_aC.js} +1 -1
  30. package/src/ui/public/assets/{MindmapView-BKsvCnYv.js → MindmapView-DcG4jpCh.js} +1 -1
  31. package/src/ui/public/assets/{MonitorView-B3_NaJRy.js → MonitorView-DO59syvl.js} +1 -1
  32. package/src/ui/public/assets/{ProjectStartupButton-DP3eDVRp.js → ProjectStartupButton-DYppceyw.js} +1 -1
  33. package/src/ui/public/assets/{RemoteManagerDialog-Bl9b_iRv.js → RemoteManagerDialog-CDbxG6pZ.js} +1 -1
  34. package/src/ui/public/assets/{RemoteRepoCard-TQ3DxDnq.js → RemoteRepoCard-Cko2KB8w.js} +1 -1
  35. package/src/ui/public/assets/{SourceMapView-BF-Mzv3V.js → SourceMapView-CwWPVdz-.js} +1 -1
  36. package/src/ui/public/assets/{SvgIcon-DxazW7dI.js → SvgIcon-Cf7nOKc8.js} +1 -1
  37. package/src/ui/public/assets/{TemplateManager-BN6gWFWX.js → TemplateManager-DWPDjWxM.js} +1 -1
  38. package/src/ui/public/assets/{UserInputNode-CPwvIfP6.js → UserInputNode-YOziN-5A.js} +1 -1
  39. package/src/ui/public/assets/WorkbenchView-DNnEpIYy.js +13 -0
  40. package/src/ui/public/assets/WorkbenchView-v3QFn6Ln.css +1 -0
  41. package/src/ui/public/assets/_plugin-vue_export-helper-CQjg8MXm.js +19 -0
  42. package/src/ui/public/assets/configStore-C7HOx5S1.js +1 -0
  43. package/src/ui/public/assets/{gitStore-CYrdjXPU.js → gitStore-DIsBd6n4.js} +1 -1
  44. package/src/ui/public/assets/index-FOe1cHkh.css +1 -0
  45. package/src/ui/public/assets/index-MmcnT2Dn.js +24 -0
  46. package/src/ui/public/assets/{previewSandbox-D_j5PWX1.js → previewSandbox-TKucqLxt.js} +0 -0
  47. package/src/ui/public/index.html +11 -11
  48. package/src/ui/server/routes/config.js +8 -3
  49. package/src/ui/server/routes/fileOpen.js +48 -2
  50. package/src/ui/server/routes/gAiLauncher.test.js +70 -0
  51. package/src/ui/server/routes/workbench/index.js +37 -6
  52. package/src/ui/server/routes/workbench/jobStore.js +4 -0
  53. package/src/ui/server/routes/workbench/taskRunner.js +276 -96
  54. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +126 -0
  55. package/src/ui/public/assets/WorkbenchView-CDu61WWs.js +0 -13
  56. package/src/ui/public/assets/WorkbenchView-DEejub9Z.css +0 -1
  57. package/src/ui/public/assets/_plugin-vue_export-helper-B7A-NgBp.js +0 -19
  58. package/src/ui/public/assets/configStore-Dx84lvTX.js +0 -1
  59. package/src/ui/public/assets/index-DNHOYMCg.js +0 -24
  60. package/src/ui/public/assets/index-Dsfmp6w8.css +0 -1
@@ -16,10 +16,10 @@
16
16
  //
17
17
  // - 盒式输入框(Codex composer 风格):╭───╮ + ❯ 提示符 + ╰───╯
18
18
  // - 等待 spinner(ora):首个 token 到达前持续转动
19
- // - 思考过程:✻ 思考 头 + 橙黄斜体流式输出
19
+ // - 思考过程:琥珀色标题 + 灰色流式预览,可切换完整显示
20
20
  // - 工具调用块(Claude Code 风格):⚙ 工具头 + 智能参数摘要,
21
21
  // 结果用 │ 缩进槽,按退出码/错误前缀着色
22
- // - 正文:➤ 子弹头 + 逐行缓冲的轻量 markdown 渲染
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).replace(/\x1b\[[0-9;]*m/g, '')
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
- if (spinner.isSpinning) spinner.stop()
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
- // ➤ 后留 2 个空格,后续行 3 空格对齐 — 图标与文字之间别太挤
405
- // 正文 🤖(亮绿):模型最终回复的视觉锚点;用亮绿 + 加粗图标,普通绿在深背景上偏暗
406
- const BULLET_FIRST = chalk.bold(chalk.hex('#5eff8b')('🤖')) + ' '
407
- const BULLET_REST = ' '
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
- // 思考用橙黄色斜体(gray/dim 太浅看不清;橙黄既醒目又与正文白、工具青区分开)
413
- const thinkStyle = (s) => chalk.hex('#e8a33d').italic(s)
414
- // 思考图标:🧠 大脑 — 紧贴最左(0 缩进),与工具头、正文对齐到同一左缘
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
- write(bullet + chalk.dim(inFence ? '┄ code ' + '┄'.repeat(8) : '┄'.repeat(14)) + (withNewline ? '\n' : ''))
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
- write(bullet + chalk.dim('│ ') + raw + (withNewline ? '\n' : ''))
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
- write(bullet + body + (withNewline ? '\n' : ''))
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 + ' ' + thinkStyle(chalk.bold(thinkingHeader)) + '\n')
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 parts = text.split('\n')
475
- for (let i = 0; i < parts.length; i++) {
476
- const seg = parts[i]
477
- if (seg) {
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 (i < parts.length - 1) { // 段间的换行(最后一段后不补)
483
- write('\n')
484
- thinkAtLineStart = true
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
- write('\n' + chalk.cyan('▶') + ' '
575
- + chalk.bold.white(name)
576
- + (summary ? ' ' + chalk.hex('#a0aec0')(summary) : '')
577
- + '\n')
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 = truncateDisplay(result)
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 lines = visible.split('\n')
605
- const rendered = lines.map((l) => chalk.dim(' │ ') + colorize(l)).join('\n')
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.dim(' │ ⏱ ') + chalk.hex('#7a87a0')(formatDuration(durationMs))
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.ok(lines.some(l => l.startsWith('🤖 第一行')))
120
- assert.ok(lines.some(l => l.startsWith(' 第二行')))
121
- assert.ok(lines.some(l => l.startsWith(' 第三行')))
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, '🤖 答案', `首行应为 🤖 答案,实际: ${JSON.stringify(lines)}`)
198
+ assert.equal(firstContent, '◆ 回答')
198
199
  // "答案"与"下一段"之间最多一个空行,且空行不带缩进
199
- const midBlank = lines.slice(1, lines.indexOf(' 下一段')).filter(l => l === '').length
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('▶ run_command'))
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(' │ ') || l === ''), `每行都应为 │ 槽线,实际: ${JSON.stringify(lines)}`)
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: 传入 durationMs 时末尾追加 ⏱ 计时行', () => {
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('⏱'), `应包含 ⏱,实际: ${lastLine}`)
262
+ assert.ok(lastLine.includes('完成'), `应包含状态,实际: ${lastLine}`)
262
263
  assert.ok(lastLine.includes('1.5s'), `应包含 1.5s,实际: ${lastLine}`)
263
- assert.ok(lastLine.startsWith(' │ '), `计时行应与结果块同缩进,实际: ${lastLine}`)
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))
@@ -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 child = exec(command, { ...options, encoding: 'buffer' }, (err, stdout, stderr) => {
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: !!err?.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 options.onChild === 'function') options.onChild(child)
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 {
@@ -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)