zen-gitsync 2.15.34 → 2.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +37 -3
  2. package/package.json +1 -1
  3. package/src/cli/ai/agent.js +493 -83
  4. package/src/cli/ai/agent.test.js +190 -0
  5. package/src/cli/ai/images.js +211 -0
  6. package/src/cli/ai/images.test.js +98 -0
  7. package/src/cli/ai/modelSetup.js +594 -0
  8. package/src/cli/ai/modelSetup.test.js +496 -0
  9. package/src/cli/ai/platformGuard.js +200 -0
  10. package/src/cli/ai/platformGuard.test.js +253 -0
  11. package/src/cli/ai/streamFilter.js +82 -0
  12. package/src/cli/ai/streamFilter.test.js +88 -0
  13. package/src/cli/ai/termui.js +453 -0
  14. package/src/cli/ai/termui.test.js +253 -0
  15. package/src/cli/ai/tools.js +54 -18
  16. package/src/cli/ai/tools.test.js +28 -0
  17. package/src/ui/public/assets/AppVersionBadge-BTznOVSJ.js +2 -0
  18. package/src/ui/public/assets/AppVersionBadge-CS7Trvp-.css +1 -0
  19. package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +1 -0
  20. package/src/ui/public/assets/BranchSelector-BISqgXoZ.js +1 -0
  21. package/src/ui/public/assets/CommitForm-C7FmI0Sl.js +6 -0
  22. package/src/ui/public/assets/CommitForm-DdK8dymc.css +1 -0
  23. package/src/ui/public/assets/CommonDialog-BA4xg5l3.js +1 -0
  24. package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +1 -0
  25. package/src/ui/public/assets/EditorView-BVod-rjn.css +1 -0
  26. package/src/ui/public/assets/EditorView-CSWy4q7G.js +0 -0
  27. package/src/ui/public/assets/FlowExecutionViewer-CZdF1f1e.js +1 -0
  28. package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +1 -0
  29. package/src/ui/public/assets/FlowOrchestrationWorkspace-BECFOAcQ.js +21 -0
  30. package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +1 -0
  31. package/src/ui/public/assets/LogList-CeFFytjB.css +1 -0
  32. package/src/ui/public/assets/LogList-DYoGtOou.js +15 -0
  33. package/src/ui/public/assets/MindmapView-3rMfOoNm.css +1 -0
  34. package/src/ui/public/assets/MindmapView-CU3tXgbR.js +1 -0
  35. package/src/ui/public/assets/MonitorView-CSAqU7oV.js +1 -0
  36. package/src/ui/public/assets/MonitorView-TaS4kL0Y.css +1 -0
  37. package/src/ui/public/assets/ProjectStartupButton-2By0-fuq.js +1 -0
  38. package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +1 -0
  39. package/src/ui/public/assets/RemoteRepoCard-BRGNlbeO.css +1 -0
  40. package/src/ui/public/assets/RemoteRepoCard-DHsJwTEX.js +5 -0
  41. package/src/ui/public/assets/SourceMapView-BNRjvgzH.css +1 -0
  42. package/src/ui/public/assets/SourceMapView-DskfUia5.js +3 -0
  43. package/src/ui/public/assets/SvgIcon-BqWp2WS8.js +1 -0
  44. package/src/ui/public/assets/{SvgIcon-BQ6bR7l0.css → SvgIcon-CDNsk4-Q.css} +1 -1
  45. package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +1 -0
  46. package/src/ui/public/assets/UserInputNode-Ccnh0e-0.js +1 -0
  47. package/src/ui/public/assets/WorkbenchView-1q0xaqyV.js +28 -0
  48. package/src/ui/public/assets/{WorkbenchView-CQfjF0Gr.css → WorkbenchView-DbF4hH5s.css} +1 -1
  49. package/src/ui/public/assets/{_plugin-vue_export-helper-B7s-K_Aj.js → _plugin-vue_export-helper-BQRl7WAH.js} +1 -1
  50. package/src/ui/public/assets/{configStore-DPGao81A.js → configStore-BkxDLXGa.js} +1 -1
  51. package/src/ui/public/assets/element-plus-DNnePjco.js +32 -0
  52. package/src/ui/public/assets/{flow-mindmap-5SDymYbY.js → flow-mindmap-DRmzYE4F.js} +1 -1
  53. package/src/ui/public/assets/index-Br2VZQBT.js +68 -0
  54. package/src/ui/public/assets/{index-CWoujklW.css → index-CaIu73UM.css} +1 -1
  55. package/src/ui/public/assets/socket-io-pmu5tRPo.js +1 -0
  56. package/src/ui/public/assets/{vendor-BteTDIoK.js → vendor-CcK8n8T4.js} +44 -37
  57. package/src/ui/public/assets/vue-flow-pAz--378.js +39 -0
  58. package/src/ui/public/index.html +12 -12
  59. package/src/ui/public/assets/AppVersionBadge-DL0Y81fU.js +0 -2
  60. package/src/ui/public/assets/AppVersionBadge-Dj6H729N.css +0 -1
  61. package/src/ui/public/assets/BranchSelector-B_VMrSKv.js +0 -1
  62. package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +0 -1
  63. package/src/ui/public/assets/CommitForm-BNuwp3ha.js +0 -6
  64. package/src/ui/public/assets/CommitForm-CUvl0VcF.css +0 -1
  65. package/src/ui/public/assets/CommonDialog-C2dbNSrw.js +0 -1
  66. package/src/ui/public/assets/CommonDialog-Cyh3xu0p.css +0 -1
  67. package/src/ui/public/assets/EditorView-BdTQsQw7.js +0 -0
  68. package/src/ui/public/assets/EditorView-CRYGVw_r.css +0 -1
  69. package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +0 -1
  70. package/src/ui/public/assets/FlowExecutionViewer-D5DBcy8S.js +0 -1
  71. package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +0 -1
  72. package/src/ui/public/assets/FlowOrchestrationWorkspace-omHN8HaT.js +0 -21
  73. package/src/ui/public/assets/LogList-DJYpDUTZ.css +0 -1
  74. package/src/ui/public/assets/LogList-Dc1RMEFq.js +0 -7
  75. package/src/ui/public/assets/MindmapView-BPTh-XAf.js +0 -1
  76. package/src/ui/public/assets/MindmapView-CWoqfZRL.css +0 -1
  77. package/src/ui/public/assets/MonitorView-DxFogQIt.css +0 -1
  78. package/src/ui/public/assets/MonitorView-RTbnLZlO.js +0 -1
  79. package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +0 -1
  80. package/src/ui/public/assets/ProjectStartupButton-CcQ8SaPb.js +0 -1
  81. package/src/ui/public/assets/RemoteRepoCard-Cr1yQ4pF.js +0 -1
  82. package/src/ui/public/assets/RemoteRepoCard-d0klonVb.css +0 -1
  83. package/src/ui/public/assets/SourceMapView-BbiYmCHB.js +0 -3
  84. package/src/ui/public/assets/SourceMapView-DG_a_076.css +0 -1
  85. package/src/ui/public/assets/SvgIcon-D3jL_b3c.js +0 -1
  86. package/src/ui/public/assets/UserInputNode-DCk_cvaT.js +0 -1
  87. package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +0 -1
  88. package/src/ui/public/assets/WorkbenchView-KX3nKGZc.js +0 -12
  89. package/src/ui/public/assets/element-plus-BAcS68uS.js +0 -24
  90. package/src/ui/public/assets/index-B8oXZO4w.js +0 -38
  91. package/src/ui/public/assets/socket-io-BC2cap_-.js +0 -1
  92. package/src/ui/public/assets/vue-flow-CtZ_ZWKZ.js +0 -39
@@ -21,9 +21,15 @@
21
21
  // isDefault 优先,否则取第一个);--model=<序号|名称> 或会话内 /model 切换
22
22
  // - OpenAI 兼容流式 function calling:模型可以调用 run_command /
23
23
  // read_file / write_file / edit_file / list_files / search_text
24
+ // - 图片:Alt+V 粘贴剪贴板图片、/image <路径> 附加本地图片,
25
+ // 以多模态(image_url)消息发给模型(需模型支持视觉)
24
26
  // - 权限:启动目录内全开放,其他目录可读写;唯一红线由 safety.js 拦截
25
27
  // (格式化磁盘、删根目录、关机等系统级破坏操作)
26
28
  //
29
+ // 视觉风格(termui.js):对标 Codex CLI / Claude Code / OpenCode —
30
+ // 盒式输入框、ora 等待 spinner、✻ 思考灰斜体、⏺ 工具块 + ⎿ 结果槽、
31
+ // 正文逐行轻量 markdown(**bold** / `code` / # 标题 / ``` 围栏 / - 列表)
32
+ //
27
33
  // 使用方式:
28
34
  // g ai 交互模式(REPL)
29
35
  // g ai "帮我修个 bug" 单发模式:执行一轮后退出
@@ -38,9 +44,21 @@ import readline from 'node:readline'
38
44
  import chalk from 'chalk'
39
45
  import config from '../../config.js'
40
46
  import { getCwd } from '../../utils/index.js'
41
- import { boxenAdaptive } from '../ui.js'
42
47
  import { registerCleanup } from '../cleanup.js'
43
48
  import { TOOL_DEFINITIONS, executeTool } from './tools.js'
49
+ import { createThinkFilter } from './streamFilter.js'
50
+ import {
51
+ printBanner, printHelpPanel,
52
+ filterSlashCommands, renderSlashHintBody,
53
+ startSpinner, createAssistantWriter,
54
+ summarizeToolArgs, printToolHeader, printToolResult,
55
+ printOk, printWarn, printError, printDim,
56
+ } from './termui.js'
57
+ import { readClipboardImage, checkImageFile, imageToDataUrl, formatBytes } from './images.js'
58
+ import { runModelSetup, collectModelInput, buildModelConfig } from './modelSetup.js'
59
+
60
+ // truncateDisplay 已迁移到 termui.js;这里 re-export 保持既有测试/外部引用不断
61
+ export { truncateDisplay } from './termui.js'
44
62
 
45
63
  // ──────────────────────────────────────────────
46
64
  // 常量
@@ -48,8 +66,6 @@ import { TOOL_DEFINITIONS, executeTool } from './tools.js'
48
66
  const MAX_TOOL_ITERATIONS = 40 // 单轮用户输入允许的最大工具调用循环数(防失控)
49
67
  const MAX_HISTORY_MESSAGES = 40 // 历史消息上限(超出后从最旧的整段对话裁剪)
50
68
  const LLM_TIMEOUT_MS = 300000 // 单次 LLM 请求超时(5 分钟,长推理模型够用)
51
- const DISPLAY_ARGS_LIMIT = 160 // 终端回显工具参数的截断长度
52
- const DISPLAY_RESULT_LIMIT = 600 // 终端回显工具结果的截断长度(完整结果仍进上下文)
53
69
 
54
70
  // ──────────────────────────────────────────────
55
71
  // i18n 字符串表(CLI 其他部分以中文为主,这里按 locale 给双语)
@@ -58,12 +74,15 @@ const STRINGS = {
58
74
  zh: {
59
75
  noModel: '未配置 AI 模型,请先在 GUI 通用设置中添加模型(运行 g ui 打开设置)',
60
76
  unknownModel: (q) => `未找到匹配的模型: ${q},使用默认模型`,
61
- waiting: '等待模型响应…',
77
+ waiting: '思考中…',
62
78
  llmError: (msg) => `LLM 请求失败: ${msg}`,
63
79
  toolIterLimit: (n) => `已达单轮最大工具调用次数(${n}),本轮结束。如需继续请再发一条消息。`,
80
+ toolRunning: (name) => `执行 ${name}…`,
64
81
  busy: '智能体正在执行中,请稍候(Ctrl+C 结束会话)…',
65
82
  bye: '已退出 g ai',
66
83
  cleared: '对话历史已清空',
84
+ thinkOn: '思考过程显示: 开',
85
+ thinkOff: '思考过程显示: 关(模型仍会思考,只是不回显)',
67
86
  modelSwitched: (label) => `已切换模型: ${label}`,
68
87
  invalidModelIndex: '无效的模型序号',
69
88
  cdOk: (p) => `工作目录已切换: ${p}`,
@@ -71,21 +90,39 @@ const STRINGS = {
71
90
  currentModel: '当前模型',
72
91
  availableModels: '可用模型',
73
92
  oneShotNoModel: '未配置模型,无法启动',
93
+ oneShotDone: '单发模式完成 · 需要多轮连续对话请直接运行 g ai(不带参数)',
74
94
  helpTitle: 'g ai 命令',
75
- bannerTip: '/help 查看命令 · /exit 退出 · Ctrl+C 结束会话',
95
+ bannerTip: 'Alt+V 粘贴图片 · /help 查看命令 · /exit 退出 · Ctrl+C 结束会话',
76
96
  prompt: '❯ ',
77
97
  bannerModel: '模型',
78
98
  bannerCwd: '目录',
99
+ thinkingLabel: '✻ 思考',
100
+ chars: '字符',
101
+ imagePasting: '正在读取剪贴板图片…',
102
+ imageAttached: (n, size) => `📎 图片 #${n} 已附加(${size}),将随下一条消息发送`,
103
+ imageEmpty: '剪贴板中没有图片;可先截图再按 Alt+V,或用 /image <路径> 附加本地图片',
104
+ imageBadPath: (p) => `图片不存在或格式不支持: ${p}(支持 png/jpg/jpeg/gif/webp/bmp)`,
105
+ imageListTitle: '待发送图片',
106
+ imageListEmpty: '当前没有待发送的图片(Alt+V 粘贴或 /image <路径> 附加)',
107
+ imageCleared: '已清除待发送的图片',
108
+ imageSending: (n) => `📎 附带 ${n} 张图片`,
109
+ addModelTitle: '添加模型配置',
110
+ addModelSaved: (name) => `✓ 模型 "${name}" 已添加并切换为当前模型`,
111
+ addModelCancelled: '已取消添加模型',
112
+ addModelSaveError: (msg) => `保存配置失败: ${msg}`,
79
113
  },
80
114
  en: {
81
115
  noModel: 'No AI model configured. Add one in GUI settings first (run `g ui`).',
82
116
  unknownModel: (q) => `No model matching "${q}", falling back to default`,
83
- waiting: 'Waiting for model…',
117
+ waiting: 'Thinking…',
84
118
  llmError: (msg) => `LLM request failed: ${msg}`,
85
119
  toolIterLimit: (n) => `Hit max tool iterations (${n}) for this turn. Send another message to continue.`,
120
+ toolRunning: (name) => `Running ${name}…`,
86
121
  busy: 'Agent is working, please wait (Ctrl+C to end session)…',
87
122
  bye: 'Bye',
88
123
  cleared: 'Conversation cleared',
124
+ thinkOn: 'Thinking display: on',
125
+ thinkOff: 'Thinking display: off (model still thinks, just hidden)',
89
126
  modelSwitched: (label) => `Switched model: ${label}`,
90
127
  invalidModelIndex: 'Invalid model index',
91
128
  cdOk: (p) => `Working directory changed: ${p}`,
@@ -93,11 +130,26 @@ const STRINGS = {
93
130
  currentModel: 'Current model',
94
131
  availableModels: 'Available models',
95
132
  oneShotNoModel: 'No model configured, aborting',
133
+ oneShotDone: 'One-shot done · for multi-turn chat run `g ai` with no arguments',
96
134
  helpTitle: 'g ai commands',
97
- bannerTip: '/help for commands · /exit to quit · Ctrl+C to end session',
135
+ bannerTip: 'Alt+V paste image · /help for commands · /exit to quit · Ctrl+C to end session',
98
136
  prompt: '❯ ',
99
137
  bannerModel: 'Model',
100
138
  bannerCwd: 'CWD',
139
+ thinkingLabel: '✻ Thinking',
140
+ chars: 'chars',
141
+ imagePasting: 'Reading clipboard image…',
142
+ imageAttached: (n, size) => `📎 Image #${n} attached (${size}); sent with your next message`,
143
+ imageEmpty: 'No image in clipboard; take a screenshot first, or use /image <path>',
144
+ imageBadPath: (p) => `Not a supported image: ${p} (png/jpg/jpeg/gif/webp/bmp)`,
145
+ imageListTitle: 'Pending images',
146
+ imageListEmpty: 'No pending images (Alt+V to paste, or /image <path>)',
147
+ imageCleared: 'Pending images cleared',
148
+ imageSending: (n) => `📎 ${n} image(s) attached`,
149
+ addModelTitle: 'Add Model Configuration',
150
+ addModelSaved: (name) => `✓ Model "${name}" added and switched to`,
151
+ addModelCancelled: 'Add model cancelled',
152
+ addModelSaveError: (msg) => `Failed to save: ${msg}`,
101
153
  },
102
154
  }
103
155
 
@@ -116,6 +168,7 @@ function modelLabel(m) {
116
168
  function buildSystemPrompt({ cwd, locale, shellDesc }) {
117
169
  const zh = !String(locale || '').startsWith('en')
118
170
  const now = new Date().toLocaleString()
171
+ const isWin = process.platform === 'win32'
119
172
  if (zh) {
120
173
  return `你是 "g ai" —— zen-gitsync CLI 内置的终端编码智能体,通过工具在用户真实电脑上完成编码任务。
121
174
 
@@ -125,6 +178,20 @@ function buildSystemPrompt({ cwd, locale, shellDesc }) {
125
178
  - 当前工作目录: ${cwd}(用户在此启动 g ai,也是所有相对路径的基准)
126
179
  - 当前时间: ${now}
127
180
 
181
+ # 平台兼容性(重要!)
182
+ - 必须使用与当前 Shell 兼容的命令,禁止盲套 Unix 写法
183
+ ${isWin ? `- 当前是 Windows cmd.exe,以下 Unix 命令**不存在**,用了必定报"不是内部或外部命令":
184
+ tail / head / cat / grep / ls / sed / awk / wc / cut / uniq / xargs / which / touch
185
+ 平台守卫会在执行前拦截这些命令,但请主动避免,不要浪费一轮调用
186
+ - 跨平台替代方案:
187
+ · 列目录 → list_files 工具 或 cmd 的 dir
188
+ · 搜内容 → search_text 工具 或 cmd 的 findstr
189
+ · 看文件 → read_file 工具 或 cmd 的 type
190
+ · 看输出末尾 → PowerShell "命令 | Select-Object -Last N"
191
+ · 文本处理 → node -e "..." 或 PowerShell
192
+ · 查命令路径 → cmd 的 where(不是 which)
193
+ - 必须跑 shell 时优先跨平台写法(如 node -e "..."),别用 Unix 专属命令` : `- 当前是 POSIX 环境,Unix 命令可用`}
194
+
128
195
  # 权限(用户已明确授权,无需反复征求同意)
129
196
  - 工作目录内:读写文件、执行命令等所有操作直接执行
130
197
  - 其他目录:同样可以读取和修改
@@ -137,8 +204,17 @@ function buildSystemPrompt({ cwd, locale, shellDesc }) {
137
204
  - 编辑文件优先 edit_file 精确替换;先 read_file 看原文,old_string 必须与文件内容完全一致(含缩进换行)
138
205
  - 大文件用 offset/limit 分段读取,不要一次读爆上下文
139
206
  - git 操作用 run_command 执行;提交代码可以用 git 命令,也可以用本 CLI 的 g -y(默认信息提交并推送)或 g --ai(AI 生成提交信息)
140
- - run_command 默认超时 120 秒,长任务加大 timeout_seconds(最大 600)
207
+ - run_command 默认就在工作目录执行,不要再加 cd / cd /d 前缀;默认超时 120 秒,长任务加大 timeout_seconds(最大 600)
141
208
  - 命令在 ${shellDesc} 下执行,注意语法兼容
209
+ - 本项目跑测试用 npm test(node --test 不支持直接传目录路径)
210
+
211
+ # 与用户交互
212
+ - 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出 —— 用户能实时看到你的文本;
213
+ 不要调用不存在的工具,可用工具只有上面列出的 6 个
214
+ - 用户可能通过 Alt+V 或 /image 附加图片:图片以 image_url 部件出现在 user 消息里;
215
+ 如果当前模型不支持视觉(带图请求报错),提醒用户换用支持视觉的模型
216
+ - 发现高风险或状态不一致的情况(例如版本号 / git tag / CHANGELOG 对不上、发布前环境异常、
217
+ 仓库状态与预期不符)时:先用文本说明发现和影响,停下来等用户指示,不要擅自继续破坏性操作
142
218
 
143
219
  # 输出
144
220
  - 你的文本输出直接显示在用户终端,用简体中文交流
@@ -152,6 +228,20 @@ function buildSystemPrompt({ cwd, locale, shellDesc }) {
152
228
  - Working directory: ${cwd} (where the user launched g ai; base for all relative paths)
153
229
  - Current time: ${now}
154
230
 
231
+ # Platform compatibility (important!)
232
+ - You MUST use commands compatible with the current shell. Do NOT blindly copy Unix patterns.
233
+ ${isWin ? `- This is Windows cmd.exe. The following Unix commands do NOT exist here and will fail with "not recognized":
234
+ tail / head / cat / grep / ls / sed / awk / wc / cut / uniq / xargs / which / touch
235
+ A platform guard will block these before execution, but avoid them proactively — don't waste a turn.
236
+ - Cross-platform alternatives:
237
+ · List dirs → list_files tool, or cmd's dir
238
+ · Search content → search_text tool, or cmd's findstr
239
+ · Read files → read_file tool, or cmd's type
240
+ · Tail output → PowerShell "command | Select-Object -Last N"
241
+ · Text processing → node -e "..." or PowerShell
242
+ · Find executable → cmd's where (not which)
243
+ - For shell one-offs, prefer cross-platform forms like node -e "..."` : `- POSIX environment: Unix commands are available`}
244
+
155
245
  # Permissions (explicitly granted by the user — do not keep asking)
156
246
  - Inside the working directory: read/write files and run commands directly
157
247
  - Other directories: may also be read and modified
@@ -163,8 +253,14 @@ function buildSystemPrompt({ cwd, locale, shellDesc }) {
163
253
  - Prefer edit_file for precise replacements; read_file first, old_string must match the file exactly
164
254
  - Read large files in segments (offset/limit)
165
255
  - Git operations go through run_command; to commit, use git commands or this CLI's g -y / g --ai
166
- - run_command defaults to a 120s timeout; raise timeout_seconds (max 600) for long tasks
256
+ - run_command already executes in the working directory — do NOT prefix with cd; default timeout 120s, raise timeout_seconds (max 600) for long tasks
167
257
  - Commands run under ${shellDesc}; keep syntax compatible
258
+ - Run this project's tests with npm test (node --test does not accept a bare directory)
259
+
260
+ # Talking to the user
261
+ - When you need to confirm something, ask a question, or report an important decision, just write plain text — the user sees your output in real time. Never call tools that do not exist; only the 6 tools listed above are available
262
+ - The user can attach images (Alt+V or /image); they arrive as image_url parts in your user messages. If the current model rejects images (no vision support), tell the user to switch to a vision-capable model
263
+ - When you spot high-risk or inconsistent state (version number / git tag / CHANGELOG mismatch, abnormal release environment, unexpected repo state), explain the finding and its impact in text, then STOP and wait for the user's decision instead of proceeding with destructive operations
168
264
 
169
265
  # Output
170
266
  - Your text output goes straight to the user's terminal; reply in English
@@ -271,41 +367,104 @@ function trimHistory(messages) {
271
367
  }
272
368
 
273
369
  // ──────────────────────────────────────────────
274
- // 终端回显辅助
370
+ // 消息消毒:确保发给 LLM 的消息数组里没有空内容。
371
+ //
372
+ // 背景:部分 LLM provider(如 Moonshot/Kimi、智谱、火山引擎等)对 assistant
373
+ // 消息的 content 字段校验严格 —— 当模型只返回 tool_calls 而没有文本时,
374
+ // content 为空字符串 "" 会被拒绝并报 "chat content is empty (2013)"。
375
+ // OpenAI 官方规范允许 assistant 消息在带 tool_calls 时 content 为 null,
376
+ // 本函数将空字符串统一转为 null,并对 tool/user 消息做兜底防止空内容。
275
377
  // ──────────────────────────────────────────────
276
- function printToolCall(name, rawArgs) {
277
- let preview = String(rawArgs || '').replace(/\s+/g, ' ').trim()
278
- if (preview.length > DISPLAY_ARGS_LIMIT) preview = preview.slice(0, DISPLAY_ARGS_LIMIT) + ''
279
- process.stdout.write(`\n${chalk.cyan('▸ ' + name)} ${chalk.dim(preview)}\n`)
378
+ export function sanitizeMessages(messages) {
379
+ for (const m of messages) {
380
+ if (typeof m.content !== 'string') continue
381
+ if (m.content === '') {
382
+ if (m.role === 'assistant') {
383
+ // assistant 带 tool_calls 时 content 用 null(OpenAI 规范);
384
+ // 不带 tool_calls 的空内容也用 null,provider 通常可接受
385
+ m.content = null
386
+ } else if (m.role === 'tool') {
387
+ m.content = '(no output)'
388
+ } else if (m.role === 'user') {
389
+ m.content = ' '
390
+ }
391
+ }
392
+ }
393
+ return messages
280
394
  }
281
395
 
282
- function printToolResult(result) {
283
- let text = String(result || '')
284
- if (text.length > DISPLAY_RESULT_LIMIT) text = text.slice(0, DISPLAY_RESULT_LIMIT) + `\n… [回显截断,完整结果已提供给模型]`
285
- const indented = text.split('\n').map(l => ' ' + l).join('\n')
286
- process.stdout.write(chalk.dim(indented) + '\n')
396
+ // ──────────────────────────────────────────────
397
+ // 多模态历史:base64 图片很占上下文,只保留"最近一条带图消息"里的图片,
398
+ // 更早消息里的 image_url 部件降级为文字占位(模型仍知道这里曾有图)
399
+ // ──────────────────────────────────────────────
400
+ export function stripStaleImages(messages, locale) {
401
+ const placeholder = String(locale || '').startsWith('en')
402
+ ? '[image omitted from history]'
403
+ : '[图片已从历史中省略]'
404
+ let seenLatest = false
405
+ for (let i = messages.length - 1; i >= 0; i--) {
406
+ const m = messages[i]
407
+ if (m?.role !== 'user' || !Array.isArray(m.content)) continue
408
+ const hasImage = m.content.some(p => p?.type === 'image_url')
409
+ if (!hasImage) continue
410
+ if (!seenLatest) { seenLatest = true; continue }
411
+ m.content = m.content.map(p => p?.type === 'image_url'
412
+ ? { type: 'text', text: placeholder }
413
+ : p)
414
+ }
287
415
  }
288
416
 
289
417
  // ──────────────────────────────────────────────
290
418
  // 单轮 agent 循环:用户一句话 → 流式输出 → 工具调用 → 再调用模型 … 直到模型给出最终文本
419
+ // images: [{path, bytes}] 待发送图片(可为空数组)
291
420
  // ──────────────────────────────────────────────
292
- async function runAgentTurn(state, userText, t) {
293
- state.messages.push({ role: 'user', content: userText })
421
+ async function runAgentTurn(state, userText, t, images = []) {
422
+ // 有图片:编码为 OpenAI 多模态 content parts;没图片保持纯字符串
423
+ if (images.length > 0) {
424
+ const parts = [{ type: 'text', text: userText }]
425
+ for (const img of images) {
426
+ try {
427
+ const url = await imageToDataUrl(img.path)
428
+ parts.push({ type: 'image_url', image_url: { url } })
429
+ } catch (err) {
430
+ printWarn(t.imageBadPath(img.path))
431
+ }
432
+ }
433
+ state.messages.push({ role: 'user', content: parts })
434
+ } else {
435
+ state.messages.push({ role: 'user', content: userText })
436
+ }
437
+ // 旧消息里的图片降级为占位文字,防止 base64 随对话轮次累积撑爆上下文
438
+ stripStaleImages(state.messages, state.locale)
294
439
 
295
440
  for (let iter = 0; iter < MAX_TOOL_ITERATIONS; iter++) {
296
441
  trimHistory(state.messages)
297
442
 
298
- // 首个 token 到达前显示等待提示,到达后清掉
299
- let firstToken = true
300
- let wroteThinking = false
301
- const showWaiting = () => process.stdout.write(chalk.dim(t.waiting))
302
- const clearWaiting = () => {
303
- if (firstToken) {
304
- process.stdout.write('\r\x1b[K')
305
- firstToken = false
306
- }
443
+ // 首个 token 到达前转 spinner,到达后停掉并让位给流式渲染
444
+ const spinner = startSpinner(t.waiting)
445
+ let spinnerStopped = false
446
+ const stopSpinner = () => {
447
+ if (!spinnerStopped) { spinner.stop(); spinnerStopped = true }
448
+ }
449
+ // 每次 LLM 调用一个 writer:思考灰斜体、正文 ⏺ + 逐行 markdown
450
+ const writer = createAssistantWriter({
451
+ showThinking: state.showThinking,
452
+ thinkingHeader: t.thinkingLabel,
453
+ })
454
+ // MiniMax 系把 <think> 标签内联在 content 流里,用过滤器剥离并置灰显示
455
+ // (历史里仍保存模型原始输出,见 streamChatOnce 返回的 content)
456
+ const thinkFilter = createThinkFilter()
457
+ const renderSeg = (seg) => {
458
+ const kind = seg.content !== undefined ? 'content' : 'thinking'
459
+ const text = seg.content ?? seg.thinking
460
+ if (!text) return
461
+ stopSpinner()
462
+ if (kind === 'thinking') writer.writeThinking(text)
463
+ else writer.writeContent(text)
307
464
  }
308
- showWaiting()
465
+
466
+ // 发送前消毒:确保历史里没有空 content 的消息(部分 provider 报 2013)
467
+ sanitizeMessages(state.messages)
309
468
 
310
469
  let result
311
470
  try {
@@ -314,21 +473,16 @@ async function runAgentTurn(state, userText, t) {
314
473
  messages: state.messages,
315
474
  signal: state.abortController?.signal,
316
475
  onToken: ({ thinking, content }) => {
317
- if (thinking) {
318
- clearWaiting()
319
- wroteThinking = true
320
- process.stdout.write(chalk.dim(thinking))
321
- }
322
- if (content) {
323
- clearWaiting()
324
- if (wroteThinking) { process.stdout.write('\n'); wroteThinking = false }
325
- process.stdout.write(content)
326
- }
476
+ if (thinking) renderSeg({ thinking })
477
+ if (content) for (const seg of thinkFilter.feed(content)) renderSeg(seg)
327
478
  },
328
479
  })
480
+ // 流结束,冲刷过滤器里残留的半拉标签/未闭合 think 块
481
+ for (const seg of thinkFilter.flush()) renderSeg(seg)
329
482
  } catch (err) {
330
- clearWaiting()
331
- process.stdout.write('\n' + chalk.red(t.llmError(err.message)) + '\n')
483
+ stopSpinner()
484
+ writer.finish()
485
+ printError(t.llmError(err.message))
332
486
  // 请求失败时,如果最后一条仍是本轮塞进去的 user 消息则撤掉,
333
487
  // 避免历史里留一条没有回应的消息;若已进入工具循环(末尾是 tool
334
488
  // 结果),消息链本身是完整的,保持不动
@@ -336,10 +490,11 @@ async function runAgentTurn(state, userText, t) {
336
490
  if (last?.role === 'user') state.messages.pop()
337
491
  return
338
492
  }
339
- clearWaiting()
493
+ stopSpinner()
494
+ writer.finish()
340
495
 
341
496
  if (result.aborted) {
342
- process.stdout.write('\n' + chalk.yellow('⛔ 已中止') + '\n')
497
+ printWarn('⛔ 已中止')
343
498
  return
344
499
  }
345
500
 
@@ -347,38 +502,48 @@ async function runAgentTurn(state, userText, t) {
347
502
 
348
503
  // 无工具调用:本轮结束,assistant 文本入历史
349
504
  if (toolCalls.length === 0) {
350
- if (content) process.stdout.write('\n')
351
- state.messages.push({ role: 'assistant', content: content || '' })
505
+ state.messages.push({ role: 'assistant', content: content || null })
352
506
  return
353
507
  }
354
508
 
355
509
  // 有工具调用:assistant(带 tool_calls)入历史,然后逐个执行
356
- if (content) process.stdout.write('\n')
357
- state.messages.push({ role: 'assistant', content: content || '', tool_calls: toolCalls })
510
+ // content 为 null 而非空字符串:部分 provider 拒绝空字符串内容(2013 错误)
511
+ state.messages.push({ role: 'assistant', content: content || null, tool_calls: toolCalls })
358
512
 
359
513
  for (const tc of toolCalls) {
360
514
  const name = tc.function?.name || ''
361
515
  const rawArgs = tc.function?.arguments || ''
362
- printToolCall(name, rawArgs)
363
516
 
364
517
  let args
365
518
  try {
366
519
  args = rawArgs ? JSON.parse(rawArgs) : {}
367
520
  } catch {
521
+ printToolHeader(name, printDimInline(rawArgs))
368
522
  const errResult = `错误: 工具参数不是合法 JSON: ${rawArgs.slice(0, 200)}`
369
523
  printToolResult(errResult)
370
524
  state.messages.push({ role: 'tool', tool_call_id: tc.id || name, name, content: errResult })
371
525
  continue
372
526
  }
373
527
 
528
+ printToolHeader(name, summarizeToolArgs(name, args, { chars: t.chars }))
529
+ // 长命令执行期间给个 spinner,让用户知道没有卡死
530
+ const toolSpinner = startSpinner(t.toolRunning(name))
374
531
  const output = await executeTool(name, args, state.ctx)
532
+ toolSpinner.stop()
375
533
  printToolResult(output)
376
534
  state.messages.push({ role: 'tool', tool_call_id: tc.id || name, name, content: output })
377
535
  }
378
536
  // 工具结果全部入历史后继续循环,让模型基于结果决定下一步
379
537
  }
380
538
 
381
- process.stdout.write('\n' + chalk.yellow(t.toolIterLimit(MAX_TOOL_ITERATIONS)) + '\n')
539
+ printWarn(t.toolIterLimit(MAX_TOOL_ITERATIONS))
540
+ }
541
+
542
+ // JSON 解析失败时的参数回显:单行截断,不进 summarizeToolArgs(它没有结构化参数可用)
543
+ function printDimInline(rawArgs) {
544
+ let preview = String(rawArgs || '').replace(/\s+/g, ' ').trim()
545
+ if (preview.length > 160) preview = preview.slice(0, 160) + '…'
546
+ return preview
382
547
  }
383
548
 
384
549
  // ──────────────────────────────────────────────
@@ -387,27 +552,35 @@ async function runAgentTurn(state, userText, t) {
387
552
  function printSlashHelp(t, locale) {
388
553
  const zh = !String(locale || '').startsWith('en')
389
554
  const lines = zh ? [
390
- `${t.helpTitle}:`,
391
555
  ' /help 显示本帮助',
392
556
  ' /model 列出可用模型;/model <序号> 切换模型',
557
+ ' /addmodel 添加新的模型配置(交互式向导)',
393
558
  ' /cd <路径> 切换智能体工作目录',
559
+ ' /image [路径] 查看待发送图片;/image <路径> 附加本地图片;/image clear 清除',
560
+ ' /think 开关思考过程显示',
394
561
  ' /clear 清空对话历史',
395
562
  ' /exit, /quit 退出',
396
563
  '',
564
+ ' Alt+V 粘贴剪贴板图片,随下一条消息发送(需视觉模型)',
565
+ '',
397
566
  '权限说明: 工作目录内全部操作直接执行;其他目录可读写;',
398
567
  '仅系统级破坏命令(格式化、删根目录、关机等)会被安全守卫拦截。',
399
568
  ] : [
400
- `${t.helpTitle}:`,
401
569
  ' /help Show this help',
402
570
  ' /model List models; /model <n> to switch',
571
+ ' /addmodel Add a new model (interactive wizard)',
403
572
  ' /cd <path> Change agent working directory',
573
+ ' /image [path] List pending images; attach a file; /image clear to reset',
574
+ ' /think Toggle thinking display',
404
575
  ' /clear Clear conversation history',
405
576
  ' /exit, /quit Quit',
406
577
  '',
578
+ ' Alt+V Paste clipboard image (needs a vision-capable model)',
579
+ '',
407
580
  'Permissions: full access in the working directory; other dirs readable/writable;',
408
581
  'only system-destroying commands (format, rm -rf /, shutdown, ...) are blocked.',
409
582
  ]
410
- console.log(lines.join('\n'))
583
+ printHelpPanel(t.helpTitle, lines)
411
584
  }
412
585
 
413
586
  async function handleSlashCommand(state, input, t) {
@@ -418,7 +591,40 @@ async function handleSlashCommand(state, input, t) {
418
591
  if (cmd === '/help') { printSlashHelp(t, state.locale); return 'ok' }
419
592
  if (cmd === '/clear') {
420
593
  state.messages = [state.messages[0]]
421
- console.log(chalk.green(t.cleared))
594
+ printOk(t.cleared)
595
+ return 'ok'
596
+ }
597
+ if (cmd === '/think') {
598
+ state.showThinking = !state.showThinking
599
+ printOk(state.showThinking ? t.thinkOn : t.thinkOff)
600
+ return 'ok'
601
+ }
602
+ if (cmd === '/image') {
603
+ if (arg === 'clear') {
604
+ state.pendingImages = []
605
+ printOk(t.imageCleared)
606
+ return 'ok'
607
+ }
608
+ if (!arg) {
609
+ if (state.pendingImages.length === 0) {
610
+ printDim(t.imageListEmpty)
611
+ } else {
612
+ console.log(chalk.bold(t.imageListTitle) + ':')
613
+ state.pendingImages.forEach((img, i) => {
614
+ console.log(` ${i + 1}. ${img.path} ${chalk.dim('(' + formatBytes(img.bytes) + ')')}`)
615
+ })
616
+ }
617
+ return 'ok'
618
+ }
619
+ const { resolve } = await import('node:path')
620
+ const target = resolve(state.ctx.cwd, arg)
621
+ const img = await checkImageFile(target)
622
+ if (!img) {
623
+ printError(t.imageBadPath(target))
624
+ return 'ok'
625
+ }
626
+ state.pendingImages.push(img)
627
+ printOk(t.imageAttached(state.pendingImages.length, formatBytes(img.bytes)))
422
628
  return 'ok'
423
629
  }
424
630
  if (cmd === '/model') {
@@ -434,11 +640,52 @@ async function handleSlashCommand(state, input, t) {
434
640
  }
435
641
  const idx = Number.parseInt(arg, 10)
436
642
  if (!Number.isFinite(idx) || idx < 1 || idx > state.models.length) {
437
- console.log(chalk.red(t.invalidModelIndex))
643
+ printError(t.invalidModelIndex)
438
644
  return 'ok'
439
645
  }
440
646
  state.model = state.models[idx - 1]
441
- console.log(chalk.green(t.modelSwitched(modelLabel(state.model))))
647
+ printOk(t.modelSwitched(modelLabel(state.model)))
648
+ return 'ok'
649
+ }
650
+ if (cmd === '/addmodel') {
651
+ // 智能体执行中不允许启动向导:向导的 rl.question 会与流式输出交错,体验混乱
652
+ if (state.busy) {
653
+ printDim(t.busy)
654
+ return 'ok'
655
+ }
656
+ state.inWizard = true
657
+ try {
658
+ console.log(chalk.cyan.bold('\n' + t.addModelTitle))
659
+ // 复用 modelSetup 的交互式收集逻辑(服务商 → 接口 → 模型名 → Key → 显示名 → 测试)
660
+ const collected = await collectModelInput({
661
+ locale: state.locale,
662
+ rl: state.rl,
663
+ cancelMessage: t.addModelCancelled,
664
+ })
665
+ if (!collected) return 'ok'
666
+ const { baseURL, model, apiKey, displayName } = collected
667
+ // 构建模型配置(isDefault=false:/addmodel 只是追加,不改默认模型)
668
+ const newModel = buildModelConfig({ baseURL, model, apiKey, name: displayName, isDefault: false })
669
+ // 持久化到 ~/.git-commit-tool.json 顶层 models 数组
670
+ const cfg = await config.loadConfig()
671
+ const models = Array.isArray(cfg.models) ? cfg.models : []
672
+ models.push(newModel)
673
+ cfg.models = models
674
+ try {
675
+ await config.saveConfig(cfg)
676
+ } catch (err) {
677
+ printError(t.addModelSaveError(err.message))
678
+ return 'ok'
679
+ }
680
+ // 同步 REPL 状态:更新模型列表 + 自动切换到新模型(用户刚添加,大概率想立刻用)
681
+ state.models = models
682
+ state.model = newModel
683
+ printOk(t.addModelSaved(displayName || model))
684
+ } catch (err) {
685
+ printError(err.message)
686
+ } finally {
687
+ state.inWizard = false
688
+ }
442
689
  return 'ok'
443
690
  }
444
691
  if (cmd === '/cd') {
@@ -452,13 +699,13 @@ async function handleSlashCommand(state, input, t) {
452
699
  state.ctx.cwd = target
453
700
  // 同步更新 system prompt 里的 cwd 说明(重建首条消息)
454
701
  state.messages[0] = { role: 'system', content: buildSystemPrompt({ cwd: target, locale: state.locale, shellDesc: state.shellDesc }) }
455
- console.log(chalk.green(t.cdOk(target)))
702
+ printOk(t.cdOk(target))
456
703
  } catch {
457
- console.log(chalk.red(t.cdFail(target)))
704
+ printError(t.cdFail(target))
458
705
  }
459
706
  return 'ok'
460
707
  }
461
- console.log(chalk.yellow(`未知命令: ${cmd},输入 /help 查看可用命令`))
708
+ printWarn(`未知命令: ${cmd},输入 /help 查看可用命令`)
462
709
  return 'ok'
463
710
  }
464
711
 
@@ -476,11 +723,15 @@ export async function runAiAgent(argv = []) {
476
723
  return
477
724
  }
478
725
 
479
- const models = Array.isArray(cfg.models) ? cfg.models : []
726
+ let models = Array.isArray(cfg.models) ? cfg.models : []
480
727
  if (models.length === 0) {
481
- console.error(chalk.red(t.noModel))
482
- process.exitCode = 1
483
- return
728
+ // 未配置模型:启动交互式配置向导,参照 g ui 设置里的"添加模型"
729
+ const setupResult = await runModelSetup({ locale })
730
+ if (!setupResult) {
731
+ process.exitCode = 1
732
+ return
733
+ }
734
+ models = setupResult.models
484
735
  }
485
736
 
486
737
  // --model=<序号|名称> 选择模型;默认 isDefault,否则第一个
@@ -494,7 +745,7 @@ export async function runAiAgent(argv = []) {
494
745
  } else {
495
746
  const found = models.find(m => modelLabel(m).toLowerCase().includes(String(q).toLowerCase()))
496
747
  if (found) model = found
497
- else console.log(chalk.yellow(t.unknownModel(q)))
748
+ else printWarn(t.unknownModel(q))
498
749
  }
499
750
  }
500
751
 
@@ -520,6 +771,11 @@ export async function runAiAgent(argv = []) {
520
771
  currentChild: null,
521
772
  abortController: null,
522
773
  busy: false,
774
+ showThinking: true, // /think 切换:是否回显模型的思考过程
775
+ pendingImages: [], // Alt+V / /image 附加的待发送图片 [{path, bytes}]
776
+ pasting: false, // 剪贴板读取进行中(防 Alt+V 连打并发)
777
+ inWizard: false, // /addmodel 交互式向导进行中:忽略 REPL 的 line 事件
778
+ rl: null, // REPL readline 引用(供 /addmodel 复用做 rl.question)
523
779
  }
524
780
 
525
781
  // SIGINT 时中止进行中的 LLM 请求 + 正在跑的子命令
@@ -534,60 +790,214 @@ export async function runAiAgent(argv = []) {
534
790
  if (oneShot) {
535
791
  console.log(chalk.dim(`[${t.bannerModel}] ${modelLabel(model)} · [${t.bannerCwd}] ${cwd}`))
536
792
  await runAgentTurn(state, oneShot, t)
793
+ printDim(t.oneShotDone)
537
794
  return
538
795
  }
539
796
 
540
797
  // ── 交互模式(REPL)──
541
- const banner = [
542
- chalk.green.bold('g ai — AI Agent'),
543
- `${t.bannerModel}: ${chalk.cyan(modelLabel(model))} ${chalk.dim(model.baseURL || '')}`,
544
- `${t.bannerCwd}: ${chalk.cyan(cwd)}`,
545
- chalk.dim(t.bannerTip),
546
- ].join('\n')
547
- console.log(boxenAdaptive(banner, { padding: 1, margin: { top: 1, bottom: 0, left: 0, right: 0 }, borderColor: 'cyan', borderStyle: 'round' }))
798
+ printBanner({
799
+ title: 'Zen GitSync — AI Agent',
800
+ modelLabel: modelLabel(model),
801
+ baseURL: model.baseURL || '',
802
+ cwd,
803
+ modelText: t.bannerModel,
804
+ cwdText: t.bannerCwd,
805
+ tip: t.bannerTip,
806
+ })
548
807
 
549
808
  const rl = readline.createInterface({
550
809
  input: process.stdin,
551
810
  output: process.stdout,
552
- prompt: chalk.green(t.prompt),
811
+ prompt: chalk.cyan.bold(t.prompt),
553
812
  historySize: 200,
554
813
  })
814
+ state.rl = rl // 供 /addmodel 等需要 rl.question 的斜杠命令复用
815
+
816
+ // 输入提示符管理。
817
+ //
818
+ // readline 的 _refreshLine() 用 prevRows 追踪“提示符占了几行”,
819
+ // 下次刷新时 moveCursor(0, -prevRows) 上移光标。但模型输出直接写
820
+ // stdout,readline 不知道这些行,导致 prevRows 过期 → 光标错位。
821
+ //
822
+ // 修复:每次画提示符前,先写 \r\n 确保光标在新行第 0 列,
823
+ // 然后重置 prevRows=0 告诉 readline “当前行就是提示符行”。
824
+ // 这样 _refreshLine() 的 clearScreenDown() 只清当前空行,不影响模型输出。
825
+
826
+ const showPrompt = () => {
827
+ if (rl.closed) return
828
+ // 双保险:确保 stdin 处于 flowing 模式(ora 的 stdin-discarder 可能在
829
+ // stop() 时 pause 了 stdin,即使 discardStdin:false 已设,仍防止其他
830
+ // 库意外 pause)
831
+ if (process.stdin.isTTY && !process.stdin.readableFlowing) {
832
+ process.stdin.resume()
833
+ }
834
+ if (process.stdout.isTTY) {
835
+ // 确保光标在新行第 0 列(\r 回行首,\n 换行)
836
+ process.stdout.write('\r\n')
837
+ // 重置 readline 内部光标追踪
838
+ rl.prevRows = 0
839
+ }
840
+ rl.prompt()
841
+ }
842
+
843
+ // 安全版:防止 finally 块里的异常变成 unhandledRejection 杀掉会话
844
+ const safeShowPrompt = () => {
845
+ try {
846
+ showPrompt()
847
+ } catch (_) {
848
+ try { rl.prompt() } catch { /* 给予宽容 */ }
849
+ }
850
+ }
851
+
852
+ // 仅重画提示符,不写 \r\n(用于空输入、busy 等不需要换行的场景)
853
+ const safeRefreshPrompt = () => {
854
+ try {
855
+ if (rl.closed) return
856
+ rl.prevRows = 0
857
+ rl.prompt()
858
+ } catch (_) {
859
+ try { rl.prompt() } catch { /* noop */ }
860
+ }
861
+ }
862
+
863
+ // readline 的 clearLine() 已经写了 \r\n,不需要我们再补换行。
864
+ const closeInputFrame = () => {}
865
+
866
+ // 在提示符上方插一行通知(粘贴进度等)。
867
+ const notifyAbovePrompt = (text) => {
868
+ console.log(text)
869
+ if (!state.busy) safeShowPrompt()
870
+ }
871
+
872
+ // ── 斜杠命令即时提示 ──
873
+ // 在提示符下方浮现匹配到的命令,随输入过滤;不移动光标(用 DECSC/DECRC 保存-恢复)。
874
+ // slashHintRows 记录当前提示占了几行,下次刷新/提交时据此擦除,避免残影。
875
+ let slashHintRows = 0
876
+ const eraseSlashHint = () => {
877
+ if (!process.stdout.isTTY || slashHintRows === 0) return
878
+ // 保存光标 → 下移到提示区逐行清空 → 恢复光标
879
+ let seq = '\x1b7'
880
+ for (let i = 0; i < slashHintRows; i++) seq += '\x1b[1B\x1b[2K'
881
+ seq += '\x1b8'
882
+ process.stdout.write(seq)
883
+ slashHintRows = 0
884
+ }
885
+ const renderSlashHint = () => {
886
+ if (!process.stdout.isTTY || state.busy || state.inWizard) { return }
887
+ const matches = filterSlashCommands(rl.line, state.locale)
888
+ const body = renderSlashHintBody(matches)
889
+ // 先擦掉旧提示(行数可能变化),再画新的
890
+ eraseSlashHint()
891
+ if (!body) return
892
+ const rows = body.split('\n')
893
+ // 保存光标,逐行下移打印(每行先清行防止与旧内容叠字),最后恢复光标回输入行
894
+ let seq = '\x1b7'
895
+ for (const r of rows) seq += '\x1b[1B\x1b[2K\r' + r
896
+ seq += '\x1b8'
897
+ process.stdout.write(seq)
898
+ slashHintRows = rows.length
899
+ }
900
+
901
+ // Alt+V 粘贴剪贴板图片(node 把 ESC+v 解析为 meta+v;部分终端不发 Alt,可用 /image 兜底)
902
+ const pasteFromClipboard = async () => {
903
+ if (state.pasting) return
904
+ state.pasting = true
905
+ try {
906
+ notifyAbovePrompt(chalk.dim(t.imagePasting))
907
+ const img = await readClipboardImage()
908
+ if (!img) {
909
+ notifyAbovePrompt(chalk.yellow(t.imageEmpty))
910
+ } else {
911
+ state.pendingImages.push(img)
912
+ notifyAbovePrompt(chalk.green(t.imageAttached(state.pendingImages.length, formatBytes(img.bytes))))
913
+ }
914
+ } catch (err) {
915
+ // 不能让粘贴异常变成 unhandledRejection 杀掉会话
916
+ printError(`粘贴图片失败: ${err.message}`)
917
+ } finally {
918
+ state.pasting = false
919
+ }
920
+ }
921
+ rl.input.on('keypress', (ch, key) => {
922
+ // Enter 提交由 line 处理器负责擦除提示,这里不重画(此刻 readline 已换行,
923
+ // 光标不在输入行,重画会错位)
924
+ if (!(key && (key.name === 'return' || key.name === 'enter'))) {
925
+ // 每次按键后即时刷新斜杠命令提示(readline 已先处理完本次按键,rl.line 为最新值)。
926
+ // 非 slash / 无匹配时 renderSlashHint 会把上一次的提示擦掉。
927
+ renderSlashHint()
928
+ }
929
+ if (!key) return
930
+ const isAltV = (key.meta && key.name === 'v')
931
+ || (typeof key.sequence === 'string' && key.sequence.length === 2
932
+ && key.sequence.charCodeAt(0) === 27 && key.sequence[1] === 'v')
933
+ if (isAltV) pasteFromClipboard()
934
+ })
935
+
936
+ // 初始提示符:banner 后直接画(不需要 \r\n)
937
+ rl.prevRows = 0
555
938
  rl.prompt()
556
939
 
557
940
  rl.on('line', async (line) => {
558
- const input = line.trim()
559
- if (!input) { rl.prompt(); return }
560
- if (state.busy) {
561
- console.log(chalk.dim(t.busy))
562
- return
941
+ // 提交时光标已落到提示符下一行(即提示区起点),直接清到屏幕末尾擦掉整块提示,
942
+ // DECSC/DECRC 相对定位更稳(此时光标不在输入行)
943
+ if (process.stdout.isTTY && slashHintRows > 0) {
944
+ process.stdout.write('\x1b[0J')
945
+ slashHintRows = 0
563
946
  }
947
+ // /addmodel 等交互式向导进行中时,用户的回答由向导自身的 rl.question 处理,
948
+ // 不应进入 REPL 的正常输入流程(否则会把向导的答案当成命令/消息发给模型)
949
+ if (state.inWizard) return
950
+ const input = line.trim()
564
951
 
565
952
  if (input.startsWith('/')) {
566
953
  const r = await handleSlashCommand(state, input, t)
567
954
  if (r === 'exit') { rl.close(); return }
568
- rl.prompt()
955
+ safeRefreshPrompt()
956
+ return
957
+ }
958
+
959
+ if (!input) { safeRefreshPrompt(); return }
960
+ if (state.busy) {
961
+ printDim(t.busy)
962
+ safeRefreshPrompt()
569
963
  return
570
964
  }
571
965
 
966
+ // 取出待发送图片(取出即清空队列,用户每条消息独立决定带不带图)
967
+ const images = state.pendingImages.splice(0)
968
+ if (images.length > 0) printDim(t.imageSending(images.length))
969
+
572
970
  state.busy = true
573
971
  state.abortController = new AbortController()
574
972
  try {
575
- await runAgentTurn(state, input, t)
973
+ await runAgentTurn(state, input, t, images)
576
974
  } catch (err) {
577
- console.error(chalk.red(err.message))
975
+ printError(err.message)
578
976
  } finally {
579
977
  state.busy = false
580
978
  state.abortController = null
581
- rl.prompt()
979
+ safeShowPrompt()
582
980
  }
583
981
  })
584
982
 
585
983
  rl.on('close', () => {
586
- console.log(chalk.yellow('\n' + t.bye))
984
+ printWarn('\n' + t.bye)
587
985
  })
588
986
 
987
+ // 全局安全网:任何未捕获的 Promise rejection / 异常都不应该静默杀掉
988
+ // 整个 REPL 会话。这里记录到 stderr 但不退出,让 readline 继续运行。
989
+ const rejectionHandler = (reason) => {
990
+ const msg = reason instanceof Error ? reason.message : String(reason)
991
+ printError(`内部错误(已捕获,会话继续): ${msg}`)
992
+ safeShowPrompt()
993
+ }
994
+ process.on('unhandledRejection', rejectionHandler)
995
+
589
996
  // 等 readline 关闭后返回(进程随后自然退出)
590
997
  await new Promise((resolve) => rl.on('close', resolve))
998
+
999
+ // 清理:移除本次会话注册的全局处理器
1000
+ process.removeListener('unhandledRejection', rejectionHandler)
591
1001
  }
592
1002
 
593
1003
  export default { runAiAgent }