zen-gitsync 2.17.6 → 2.17.8

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 (70) hide show
  1. package/README.md +39 -2
  2. package/package.json +1 -1
  3. package/scripts/release.js +139 -48
  4. package/src/cli/ai/agent.js +108 -324
  5. package/src/cli/ai/cli.integration.test.js +124 -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 +181 -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 +181 -51
  13. package/src/cli/ai/termui.test.js +165 -16
  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-CdJaeRh0.js +5 -0
  20. package/src/ui/public/assets/AgentView-DAY4S5WN.css +1 -0
  21. package/src/ui/public/assets/{AppVersionBadge-DV6KuLqb.js → AppVersionBadge-CiijSYfM.js} +2 -2
  22. package/src/ui/public/assets/{BranchSelector-DGXDAOkR.js → BranchSelector-BS7ajwvv.js} +1 -1
  23. package/src/ui/public/assets/{CommitForm-BYBj7UUa.js → CommitForm-CEOSqXCA.js} +3 -3
  24. package/src/ui/public/assets/{CommonDialog-DWMCeujE.js → CommonDialog-CAaEi-4T.js} +1 -1
  25. package/src/ui/public/assets/{EditorView-D-TEQZso.js → EditorView-DyaFyRDD.js} +1 -1
  26. package/src/ui/public/assets/{FileDiffViewer-C4ZANajI.js → FileDiffViewer-BvX9cQjR.js} +1 -1
  27. package/src/ui/public/assets/{FlowExecutionViewer-DUv7mBuG.js → FlowExecutionViewer-uXrzZjsW.js} +1 -1
  28. package/src/ui/public/assets/{FlowOrchestrationWorkspace-BiSuvzL7.js → FlowOrchestrationWorkspace-DIBpUSt_.js} +1 -1
  29. package/src/ui/public/assets/{IconButton-BltPKVbV.js → IconButton-CnoW2on6.js} +1 -1
  30. package/src/ui/public/assets/{LogList-BY1Haq9r.js → LogList-D2espSVo.js} +1 -1
  31. package/src/ui/public/assets/{MindmapView-BKsvCnYv.js → MindmapView-CZF-rZVs.js} +1 -1
  32. package/src/ui/public/assets/{MonitorView-B3_NaJRy.js → MonitorView-DjLr7qOG.js} +1 -1
  33. package/src/ui/public/assets/{ProjectStartupButton-DP3eDVRp.js → ProjectStartupButton-DzsU-QIO.js} +1 -1
  34. package/src/ui/public/assets/{RemoteManagerDialog-Bl9b_iRv.js → RemoteManagerDialog-CuuG2ucT.js} +1 -1
  35. package/src/ui/public/assets/{RemoteRepoCard-TQ3DxDnq.js → RemoteRepoCard-BGf8L0of.js} +1 -1
  36. package/src/ui/public/assets/{SourceMapView-BF-Mzv3V.js → SourceMapView-CFb02m6z.js} +1 -1
  37. package/src/ui/public/assets/{SvgIcon-DxazW7dI.js → SvgIcon-SRAeYjnN.js} +1 -1
  38. package/src/ui/public/assets/{TemplateManager-BN6gWFWX.js → TemplateManager-gedqAXl8.js} +1 -1
  39. package/src/ui/public/assets/{UserInputNode-CPwvIfP6.js → UserInputNode-BOCuMQW6.js} +1 -1
  40. package/src/ui/public/assets/WorkbenchView-CRoR21LQ.js +13 -0
  41. package/src/ui/public/assets/WorkbenchView-v3QFn6Ln.css +1 -0
  42. package/src/ui/public/assets/_plugin-vue_export-helper-C8OE7U9H.js +19 -0
  43. package/src/ui/public/assets/configStore-CuMElTgV.js +1 -0
  44. package/src/ui/public/assets/{gitStore-CYrdjXPU.js → gitStore-CpcS-MzM.js} +1 -1
  45. package/src/ui/public/assets/index-cApp6w0b.css +1 -0
  46. package/src/ui/public/assets/index-yXm9JLGv.js +24 -0
  47. package/src/ui/public/assets/{previewSandbox-D_j5PWX1.js → previewSandbox-DkXYEpLP.js} +0 -0
  48. package/src/ui/public/assets/{toolsStore-BiC2mmC3.js → toolsStore-DGxlRe3Y.js} +1 -1
  49. package/src/ui/public/assets/vendor-4ojsjDYc.css +1 -0
  50. package/src/ui/public/assets/{vendor-92fKgtnz.js → vendor-dO4B7wQf.js} +28 -28
  51. package/src/ui/public/index.html +14 -14
  52. package/src/ui/server/routes/config.js +8 -3
  53. package/src/ui/server/routes/fileOpen.js +48 -2
  54. package/src/ui/server/routes/fs.js +37 -0
  55. package/src/ui/server/routes/gAiLauncher.test.js +70 -0
  56. package/src/ui/server/routes/workbench/index.js +37 -6
  57. package/src/ui/server/routes/workbench/jobStore.js +4 -0
  58. package/src/ui/server/routes/workbench/taskRunner.js +276 -96
  59. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +126 -0
  60. package/src/ui/server/utils/directoryFetch.js +209 -0
  61. package/src/ui/server/utils/directoryFetch.test.js +232 -0
  62. package/src/ui/public/assets/AgentView-BC3F0F6m.js +0 -4
  63. package/src/ui/public/assets/AgentView-CzUeyQfR.css +0 -1
  64. package/src/ui/public/assets/WorkbenchView-CDu61WWs.js +0 -13
  65. package/src/ui/public/assets/WorkbenchView-DEejub9Z.css +0 -1
  66. package/src/ui/public/assets/_plugin-vue_export-helper-B7A-NgBp.js +0 -19
  67. package/src/ui/public/assets/configStore-Dx84lvTX.js +0 -1
  68. package/src/ui/public/assets/index-DNHOYMCg.js +0 -24
  69. package/src/ui/public/assets/index-Dsfmp6w8.css +0 -1
  70. package/src/ui/public/assets/vendor-B1UrVTTI.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,12 +29,15 @@
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
  // ──────────────────────────────────────────────
35
37
  // 常量
36
38
  // ──────────────────────────────────────────────
37
39
  export const DISPLAY_RESULT_LIMIT = 600 // 工具结果回显截断长度(完整结果仍进上下文)
40
+ export const THINKING_PREVIEW_LINES = 12
38
41
 
39
42
  // 回显截断的"最小省略量":只超出一两个字符时截断反而碍事(实测:git status
40
43
  // 输出 614 字符被截,省略 14 字符还把路径从中间切断),不值得就不截
@@ -54,7 +57,39 @@ export function termWidth(fallback = 100) {
54
57
 
55
58
  /** 去掉 ANSI 转义(测试断言用) */
56
59
  export function stripAnsi(s) {
57
- return String(s).replace(/\x1b\[[0-9;]*m/g, '')
60
+ return stripVTControlCharacters(String(s))
61
+ }
62
+
63
+ // Normalize before grapheme segmentation: Intl.Segmenter treats CRLF as one
64
+ // cluster, so checking only for '\n' would leave unindented physical newlines
65
+ // inside a row and incorrectly carry its width into the next output line.
66
+ function normalizeTerminalNewlines(text) {
67
+ return String(text).replace(/\r\n?/g, '\n')
68
+ }
69
+
70
+ /** Wrap terminal cells, preserving colour and CJK/emoji widths. */
71
+ export function wrapTerminalText(text, width = 100) {
72
+ const rows = []
73
+ let row = '', cells = 0, lastBreak = -1, breakCells = 0
74
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
75
+ for (const part of normalizeTerminalNewlines(text).split(/(\x1b\[[0-9;]*m)/g)) {
76
+ if (/^\x1b\[/.test(part)) { row += part; continue }
77
+ for (const { segment } of segmenter.segment(part.replace(/\t/g, ' '))) {
78
+ if (segment === '\n') { rows.push(row); row = ''; cells = 0; lastBreak = -1; continue }
79
+ const size = stringWidth(segment)
80
+ if (cells && cells + size > Math.max(1, width)) {
81
+ if (lastBreak > 0 && breakCells >= width / 3) {
82
+ rows.push(row.slice(0, lastBreak)); row = row.slice(lastBreak); cells -= breakCells
83
+ } else { rows.push(row); row = ''; cells = 0 }
84
+ lastBreak = -1
85
+ }
86
+ row += segment
87
+ cells += size
88
+ if (/^[ \t]$/.test(segment)) { lastBreak = row.length; breakCells = cells }
89
+ }
90
+ }
91
+ rows.push(row)
92
+ return rows
58
93
  }
59
94
 
60
95
  /**
@@ -116,7 +151,12 @@ export const SLASH_COMMANDS = [
116
151
  { cmd: '/addmodel', descZh: '添加模型配置(向导)', descEn: 'Add a model (wizard)' },
117
152
  { cmd: '/cd', descZh: '切换工作目录', descEn: 'Change working directory' },
118
153
  { cmd: '/image', descZh: '附加 / 查看图片', descEn: 'Attach / list images' },
119
- { cmd: '/think', descZh: '开关思考过程显示', descEn: 'Toggle thinking display' },
154
+ { cmd: '/think', descZh: '切换思考显示:完整 / 隐藏', descEn: 'Toggle full / hidden thinking' },
155
+ { cmd: '/think full', descZh: '完整显示后续思考(默认)', descEn: 'Show future thinking in full (default)' },
156
+ { cmd: '/think compact', descZh: '预览前 12 行思考', descEn: 'Preview the first 12 thinking lines' },
157
+ { cmd: '/think off', descZh: '隐藏思考(不影响模型推理)', descEn: 'Hide thinking (model still reasons)' },
158
+ { cmd: '/tools', descZh: '切换工具结果:精简 / 完整', descEn: 'Toggle compact / full tool output' },
159
+ { cmd: '/stats', descZh: '查看耗时与 Token 用量', descEn: 'Show timing and token usage' },
120
160
  { cmd: '/new', descZh: '开启新对话', descEn: 'Start a new chat' },
121
161
  { cmd: '/resume', descZh: '恢复之前的对话', descEn: 'Resume a previous chat' },
122
162
  { cmd: '/clear', descZh: '清空对话历史', descEn: 'Clear conversation' },
@@ -126,7 +166,7 @@ export const SLASH_COMMANDS = [
126
166
 
127
167
  /**
128
168
  * 按当前输入过滤斜杠命令(纯函数,便于单测)。
129
- * - 仅当输入以 / 开头、且尚未输入空格(还在敲命令名)时才提示
169
+ * - 输入以 / 开头时提示,支持 /think f 等已知子命令的参数补全
130
170
  * - 前缀匹配,大小写不敏感
131
171
  * - 精确等于某命令且无后续参数时仍然展示该命令(便于确认拼写)
132
172
  * @returns {{cmd:string, desc:string}[]}
@@ -134,8 +174,8 @@ export const SLASH_COMMANDS = [
134
174
  export function filterSlashCommands(input, locale) {
135
175
  const zh = !String(locale || '').startsWith('en')
136
176
  const line = String(input || '')
137
- if (!line.startsWith('/') || /\s/.test(line)) return []
138
- const q = line.toLowerCase()
177
+ if (!line.startsWith('/')) return []
178
+ const q = line.toLowerCase().replace(/\s+/g, ' ')
139
179
  return SLASH_COMMANDS
140
180
  .filter((c) => c.cmd.startsWith(q))
141
181
  .map((c) => ({ cmd: c.cmd, desc: zh ? c.descZh : c.descEn }))
@@ -155,9 +195,10 @@ export function renderSlashHintBody(matches, selectedIndex = -1, locale = 'zh-CN
155
195
  : -1
156
196
  const zh = !String(locale || '').startsWith('en')
157
197
  const lines = []
198
+ const commandWidth = Math.max(12, ...matches.map(m => String(m.cmd).length))
158
199
  for (let i = 0; i < matches.length; i++) {
159
200
  const m = matches[i]
160
- const command = String(m.cmd).padEnd(12)
201
+ const command = String(m.cmd).padEnd(commandWidth)
161
202
  if (i === sel) {
162
203
  lines.push(`${chalk.cyanBright('›')} ${chalk.cyan(command)} ${chalk.white(m.desc)}`)
163
204
  } else {
@@ -340,8 +381,9 @@ export function drawInputBottom(write = (s) => process.stdout.write(s)) {
340
381
  // 首个 token 到达前转动;到达后 stop() 清行,接着流式输出。
341
382
  // 非 TTY 时 ora 自动退化为只打印一次文本。
342
383
 
343
- export function startSpinner(text) {
344
- const spinner = ora({
384
+ export function startSpinner(text, { spinnerFactory = ora } = {}) {
385
+ const started = performance.now()
386
+ const spinner = spinnerFactory({
345
387
  // 琥珀色加粗,与思考内容同色系,dim 太浅看不清
346
388
  text: chalk.hex('#e8a33d').bold(text),
347
389
  spinner: 'dots',
@@ -355,9 +397,19 @@ export function startSpinner(text) {
355
397
  discardStdin: false,
356
398
  })
357
399
  spinner.start()
400
+ const timer = spinner.isSpinning ? setInterval(() => {
401
+ spinner.text = chalk.hex('#e8a33d')(text) + chalk.gray(` · ${formatDuration(performance.now() - started)}`)
402
+ }, 100) : null
403
+ timer?.unref()
404
+ let stopped = false
358
405
  return {
359
406
  stop() {
360
- if (spinner.isSpinning) spinner.stop()
407
+ // ora.stop() moves the cursor even after it has stopped. Repeated calls
408
+ // during streaming would overwrite the text already on the current row.
409
+ if (stopped) return
410
+ stopped = true
411
+ clearInterval(timer)
412
+ spinner.stop()
361
413
  },
362
414
  }
363
415
  }
@@ -392,7 +444,11 @@ function renderInline(line, resetFg = '') {
392
444
  */
393
445
  export function createAssistantWriter({
394
446
  showThinking = true,
395
- thinkingHeader = '✻ 思考',
447
+ thinkingHeader = '思考',
448
+ answerHeader = '回答',
449
+ thinkingLimit = Infinity,
450
+ thinkingHint = '… 思考预览已收起,/think full 显示完整过程',
451
+ width = Math.min(100, termWidth() - 4),
396
452
  write = (s) => process.stdout.write(s),
397
453
  } = {}) {
398
454
  let mode = null // null | 'thinking' | 'content'
@@ -401,18 +457,36 @@ export function createAssistantWriter({
401
457
  let contentLines = 0 // 已输出正文行数(首行带 ➤ 子弹头)
402
458
  let lastBlank = false // 上一行是空白行(连续空行合并,避免模型输出头部/分隔空行刷屏)
403
459
  let thinkAtLineStart = true // 思考流当前是否在行首(用于逐行缩进 + 计算与正文的分隔)
404
- // ➤ 后留 2 个空格,后续行 3 空格对齐 — 图标与文字之间别太挤
405
- // 正文 🤖(亮绿):模型最终回复的视觉锚点;用亮绿 + 加粗图标,普通绿在深背景上偏暗
406
- const BULLET_FIRST = chalk.bold(chalk.hex('#5eff8b')('🤖')) + ' '
407
- const BULLET_REST = ' '
460
+ let thinkingCells = 0, thinkingRows = 0, thinkingClipped = false
461
+ let answerShown = false
462
+ // 回答正文统一缩进;标题作为独立的视觉锚点。
463
+ const BULLET_FIRST = ' '
464
+ const BULLET_REST = ' '
408
465
  // whiteBright ANSI 码:行内 code 的 \x1b[39m 会清掉外层色,用此恢复正文亮白色
409
- const WB = '\x1b[97m'
466
+ const WB = chalk.level ? '\x1b[97m' : ''
410
467
  // 思考内容整体右移,与正文文字左缘对齐,视觉上成为独立子块
411
- const THINK_INDENT = ' '
412
- // 思考用橙黄色斜体(gray/dim 太浅看不清;橙黄既醒目又与正文白、工具青区分开)
413
- const thinkStyle = (s) => chalk.hex('#e8a33d').italic(s)
414
- // 思考图标:🧠 大脑 — 紧贴最左(0 缩进),与工具头、正文对齐到同一左缘
415
- const THINK_ICON = '🧠'
468
+ const THINK_INDENT = chalk.gray(' │ ')
469
+ // 思考正文降低视觉权重,仅标题用琥珀色。
470
+ const thinkStyle = (s) => chalk.hex('#aab2c0')(s)
471
+ // 使用单色符号,避免 emoji 在不同终端中的宽度差异。
472
+ const THINK_ICON = chalk.hex('#e8a33d')('◇')
473
+ const clipThinking = () => {
474
+ if (!thinkAtLineStart) write('\n')
475
+ write(wrapTerminalText(thinkingHint, Math.max(8, width - 2)).map(row => THINK_INDENT + chalk.gray(row)).join('\n') + '\n')
476
+ thinkingClipped = true
477
+ thinkAtLineStart = true
478
+ }
479
+
480
+ const showAnswerHeader = () => {
481
+ if (answerShown) return
482
+ answerShown = true
483
+ write(chalk.greenBright.bold(`◆ ${answerHeader}`) + '\n\n')
484
+ }
485
+ const writeContentRows = (prefix, body, withNewline) => {
486
+ showAnswerHeader()
487
+ const rows = wrapTerminalText(body, Math.max(8, width))
488
+ write(rows.map(row => prefix + row).join('\n') + (withNewline ? '\n' : ''))
489
+ }
416
490
 
417
491
  const emitContentLine = (raw, withNewline = true) => {
418
492
  // 围栏标记行:切换状态,用一个淡淡的槽线代替裸 ```
@@ -421,7 +495,8 @@ export function createAssistantWriter({
421
495
  const bullet = contentLines === 0 ? BULLET_FIRST : BULLET_REST
422
496
  contentLines++
423
497
  lastBlank = false
424
- write(bullet + chalk.dim(inFence ? '┄ code ' + '┄'.repeat(8) : '┄'.repeat(14)) + (withNewline ? '\n' : ''))
498
+ const language = raw.trim().slice(3).trim() || 'code'
499
+ writeContentRows(bullet, chalk.gray(inFence ? '┌─ ' + language : '└' + '─'.repeat(12)), withNewline)
425
500
  return
426
501
  }
427
502
  // 围栏内:代码行原样带槽线(空行也保留,代码格式不能动)
@@ -429,7 +504,8 @@ export function createAssistantWriter({
429
504
  const bullet = contentLines === 0 ? BULLET_FIRST : BULLET_REST
430
505
  contentLines++
431
506
  lastBlank = false
432
- write(bullet + chalk.dim('│ ') + raw + (withNewline ? '\n' : ''))
507
+ showAnswerHeader()
508
+ write(wrapTerminalText(raw, Math.max(8, width - 2)).map(row => bullet + chalk.gray('│ ') + row).join('\n') + (withNewline ? '\n' : ''))
433
509
  return
434
510
  }
435
511
  // 空白行:首个正文行之前不输出;连续空行合并为一行
@@ -447,7 +523,7 @@ export function createAssistantWriter({
447
523
  const body = h
448
524
  ? chalk.bold.whiteBright(renderInline(h[2], WB))
449
525
  : chalk.whiteBright(renderInline(raw, WB))
450
- write(bullet + body + (withNewline ? '\n' : ''))
526
+ writeContentRows(bullet, body, withNewline)
451
527
  }
452
528
 
453
529
  const flushLineBuf = (withNewline = true) => {
@@ -459,30 +535,34 @@ export function createAssistantWriter({
459
535
  return {
460
536
  /** 思考段:橙黄斜体 + 整体右缩进(不做 markdown),段头只打印一次 */
461
537
  writeThinking(text) {
462
- if (!showThinking || !text) return
538
+ if (!showThinking || !text || thinkingClipped) return
463
539
  if (mode !== 'thinking') {
464
540
  flushLineBuf()
465
541
  mode = 'thinking'
466
542
  // 图标 🧠 靠最左(0 缩进),与 ⚙ 工具头、➤ 正文对齐同一左缘
467
543
  // 段头前空一行与上文(spinner / 上一轮输出)分隔,视觉更清晰
468
- write('\n' + THINK_ICON + ' ' + thinkStyle(chalk.bold(thinkingHeader)) + '\n')
544
+ write('\n' + THINK_ICON + ' ' + chalk.hex('#e8a33d').bold(thinkingHeader) + '\n')
469
545
  thinkAtLineStart = true
470
546
  }
471
- // 按换行切段,整段着色(避免逐字符 escape 刷屏);每逢行首补一层缩进,
472
- // 使多行思考整体右移成独立子块。流式 token 可能不以换行结尾,
473
- // 故用 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
547
+ // 保留跨分片的行宽和缩进;预览合并空白行,让行数用于实际内容。
548
+ // 完整模式仍保留模型返回的空行。
549
+ for (const char of text) {
550
+ if (Number.isFinite(thinkingLimit) && char === '\n' && thinkAtLineStart) continue
551
+ if (thinkingRows >= thinkingLimit) {
552
+ clipThinking()
553
+ break
481
554
  }
482
- if (i < parts.length - 1) { // 段间的换行(最后一段后不补)
483
- write('\n')
484
- thinkAtLineStart = true
555
+ if (char === '\n' || thinkingCells + stringWidth(char) > Math.max(8, width - 2)) {
556
+ write('\n'); thinkingRows++; thinkingCells = 0; thinkAtLineStart = true
557
+ if (char === '\n') continue
558
+ if (thinkingRows >= thinkingLimit) {
559
+ clipThinking(); break
560
+ }
485
561
  }
562
+ if (thinkAtLineStart) write(THINK_INDENT)
563
+ write(thinkStyle(char))
564
+ thinkingCells += stringWidth(char)
565
+ thinkAtLineStart = false
486
566
  }
487
567
  },
488
568
 
@@ -535,6 +615,43 @@ export function formatDuration(ms) {
535
615
  return `${m}m${rem}s`
536
616
  }
537
617
 
618
+ export function renderTurnSummary(stats, { locale = 'zh-CN', session, width = Math.min(100, termWidth() - 4) } = {}) {
619
+ const zh = !String(locale).startsWith('en')
620
+ const number = n => n == null ? '—' : Math.round(n).toLocaleString('en-US')
621
+ const usageLine = (usage, reported, requests) => {
622
+ if (!usage) return zh ? 'Token:服务端未返回用量' : 'Tokens: not reported by provider'
623
+ const parts = [
624
+ `Token ${number(usage.totalTokens)}`,
625
+ `${zh ? '输入' : 'in'} ${number(usage.inputTokens)}`,
626
+ `${zh ? '输出' : 'out'} ${number(usage.outputTokens)}`,
627
+ ]
628
+ if (usage.cachedTokens != null) parts.push(`${zh ? '输入含缓存' : 'cached input'} ${number(usage.cachedTokens)}`)
629
+ if (usage.reasoningTokens != null) parts.push(`${zh ? '输出含推理' : 'reasoning output'} ${number(usage.reasoningTokens)}`)
630
+ if (reported < requests) parts.push(zh ? `部分用量(${reported}/${requests} 次请求)` : `partial usage (${reported}/${requests} requests)`)
631
+ return parts.join(' · ')
632
+ }
633
+ const status = zh
634
+ ? ({ completed: '完成', cancelled: '已停止', failed: '失败', limit: '达到调用上限' }[stats.status] || '进行中')
635
+ : ({ completed: 'Done', cancelled: 'Stopped', failed: 'Failed', limit: 'Tool limit reached' }[stats.status] || 'Running')
636
+ const time = stats.completedAt ? new Date(stats.completedAt).toLocaleTimeString(zh ? 'zh-CN' : 'en-GB', { hour12: false }) : ''
637
+ const timing = [status + (time ? ` ${time}` : ''), `${zh ? '总耗时' : 'total'} ${formatDuration(stats.totalMs)}`]
638
+ if (stats.firstTokenMs != null) timing.push(`${zh ? '首响应' : 'first token'} ${formatDuration(stats.firstTokenMs)}`)
639
+ if (stats.firstAnswerMs != null) timing.push(`${zh ? '正文等待' : 'first answer'} ${formatDuration(stats.firstAnswerMs)}`)
640
+ const lines = [
641
+ timing.join(' · '),
642
+ `${zh ? '模型' : 'model'} ${formatDuration(stats.llmMs)} · ${zh ? '工具' : 'tools'} ${formatDuration(stats.toolsMs)} (${stats.toolCalls}) · ${zh ? '请求' : 'requests'} ${stats.requests}`,
643
+ usageLine(stats.usage, stats.usageRequests, stats.requests),
644
+ ]
645
+ if (session) lines.push(`${zh ? '会话累计' : 'Session'} (${session.turns}) · ${usageLine(session.usage, session.usageRequests, session.requests)}`)
646
+ const rows = lines.flatMap(line => wrapTerminalText(line, Math.max(12, width)))
647
+ return '\n' + chalk.gray(' ' + '─'.repeat(Math.max(12, Math.min(56, width)))) + '\n'
648
+ + rows.map(row => ' ' + chalk.hex('#aab2c0')(row)).join('\n') + '\n'
649
+ }
650
+
651
+ export function printTurnSummary(stats, options, write = s => process.stdout.write(s)) {
652
+ write(renderTurnSummary(stats, options))
653
+ }
654
+
538
655
  // ──────────────────────────────────────────────
539
656
  // 工具调用块(Claude Code 风格)
540
657
  // ──────────────────────────────────────────────
@@ -568,13 +685,14 @@ export function summarizeToolArgs(name, args, { chars = '字符' } = {}) {
568
685
  }
569
686
 
570
687
  /** 工具头:▶ name 参数摘要(青色三角表示"工具执行";name 加粗白色,摘要灰色,避免整块青色) */
571
- export function printToolHeader(name, summary, write = (s) => process.stdout.write(s)) {
688
+ export function printToolHeader(name, summary, write = (s) => process.stdout.write(s), { locale = 'zh-CN' } = {}) {
572
689
  // ▶ 保留青色作为工具执行的标识色;name 用白色加粗与下方结果区分;
573
690
  // summary 用浅灰(#a0aec0)— dim 在黑底下太暗看不清,浅灰可读且仍比 name 低调
574
- write('\n' + chalk.cyan('▶') + ' '
575
- + chalk.bold.white(name)
576
- + (summary ? ' ' + chalk.hex('#a0aec0')(summary) : '')
577
- + '\n')
691
+ const labels = { run_command: '执行命令', read_file: '读取文件', write_file: '写入文件', edit_file: '修改文件', list_files: '浏览目录', search_text: '搜索内容' }
692
+ const rows = wrapTerminalText(summary || '', Math.max(16, Math.min(100, termWidth() - 6)))
693
+ const label = String(locale).startsWith('en') ? name : labels[name] || name
694
+ write('\n' + chalk.cyan('▸ ') + chalk.bold.white(label) + (label !== name ? chalk.gray(` ${name}`) : '') + '\n')
695
+ if (summary) write(rows.map(row => ' ' + chalk.hex('#aab2c0')(row)).join('\n') + '\n')
578
696
  }
579
697
 
580
698
  /**
@@ -590,22 +708,31 @@ export function printToolHeader(name, summary, write = (s) => process.stdout.wri
590
708
  * @param {(s: string) => void} [write] - 输出函数,默认写 stdout
591
709
  * @param {number} [durationMs] - 执行耗时(毫秒),有值时在结果末尾追加 ⏱ 计时行
592
710
  */
593
- export function printToolResult(result, write = (s) => process.stdout.write(s), durationMs) {
594
- const text = truncateDisplay(result)
711
+ export function printToolResult(result, write = (s) => process.stdout.write(s), durationMs, { full = false, locale = 'zh-CN', width = Math.min(100, termWidth() - 6) } = {}) {
712
+ const text = normalizeTerminalNewlines(stripAnsi(result))
595
713
  // 先把 "$ <command>\n" 这一行回显从展示里剥掉(退出码仍在第二行里识别)
596
- const visible = text.replace(/^\$[^\n]*\n/, '')
714
+ const visible = text.replace(/^\$[^\n]*\n/, '').replace(/^\(exit \d+\)\n?/, '')
597
715
  const exitMatch = text.match(/^\$[^\n]*\n\(exit (\d+)\)/)
598
716
  const exitCode = exitMatch ? Number(exitMatch[1]) : null
717
+ const isCancelled = /命令已由用户停止|Cancelled by user/.test(text)
599
718
  const isError = exitCode !== null && exitCode !== 0
600
719
  || /^(错误|已拒绝|Error)/.test(text.trim())
601
720
  // 正文用柔和灰蓝(#94a3b8)— 不再用青色,避免与工具头同色连成一片;
602
721
  // 错误用琥珀色(#f0a020)比黄色更醒目
603
722
  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')
723
+ const zh = !String(locale).startsWith('en')
724
+ let lines = wrapTerminalText(visible.trimEnd(), Math.max(8, width))
725
+ const maxRows = isError ? 10 : 6
726
+ if (!full && lines.length > maxRows) {
727
+ const omitted = lines.length - maxRows + 1
728
+ const hint = width < 35 ? `… +${omitted}` : zh ? `… 回显省略 ${omitted} 行 · /tools full` : `… +${omitted} lines · /tools full`
729
+ lines = [...lines.slice(0, 3), hint, ...lines.slice(-(maxRows - 4))]
730
+ }
731
+ const rendered = lines.map((l) => chalk.gray(' │ ') + colorize(l)).join('\n')
606
732
  // 有耗时时在结果末尾追加 ⏱ 计时行(与结果块同缩进)
607
733
  const timingLine = (durationMs != null && Number.isFinite(durationMs))
608
- ? '\n' + chalk.dim(' │ ⏱ ') + chalk.hex('#7a87a0')(formatDuration(durationMs))
734
+ ? '\n' + chalk.gray(' └ ') + colorize(isCancelled ? (zh ? '已停止' : 'Stopped') : isError ? (zh ? '失败' : 'Failed') : (zh ? '完成' : 'Done'))
735
+ + chalk.gray(` · ${formatDuration(durationMs)}${exitCode !== null ? ` · exit ${exitCode}` : ''}`)
609
736
  : ''
610
737
  write(rendered + timingLine + '\n')
611
738
  }
@@ -640,6 +767,9 @@ export default {
640
767
  summarizeToolArgs,
641
768
  printToolHeader,
642
769
  printToolResult,
770
+ printTurnSummary,
771
+ renderTurnSummary,
772
+ wrapTerminalText,
643
773
  printOk,
644
774
  printWarn,
645
775
  printError,