zen-gitsync 2.16.8 → 2.16.10

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 (56) hide show
  1. package/package.json +4 -4
  2. package/src/cli/ai/agent.js +123 -15
  3. package/src/cli/ai/modelSetup.js +68 -11
  4. package/src/cli/ai/modelSetup.test.js +36 -7
  5. package/src/cli/ai/termui.js +81 -6
  6. package/src/cli/ai/termui.test.js +130 -1
  7. package/src/ui/public/assets/{AgentView-DSmUynN-.js → AgentView-CioAzh-a.js} +2 -2
  8. package/src/ui/public/assets/AppVersionBadge-CZ3UbiAu.js +2 -0
  9. package/src/ui/public/assets/BranchSelector-Kwcj7e0Z.js +1 -0
  10. package/src/ui/public/assets/CommitForm-DueeSB7T.js +6 -0
  11. package/src/ui/public/assets/{CommonDialog-BFyhw0Qj.js → CommonDialog-Tlk0Fa7g.js} +1 -1
  12. package/src/ui/public/assets/EditorView-B2gm2OW1.js +0 -0
  13. package/src/ui/public/assets/{FlowExecutionViewer-C063Qjmk.js → FlowExecutionViewer-CFpiqZ2C.js} +1 -1
  14. package/src/ui/public/assets/FlowOrchestrationWorkspace-BRUlRQwp.js +21 -0
  15. package/src/ui/public/assets/{LogList-GSeER4cv.js → LogList-Do3C4BmA.js} +2 -2
  16. package/src/ui/public/assets/MindmapView-C1pPUu8W.js +2 -0
  17. package/src/ui/public/assets/MonitorView-C2EyggDk.js +1 -0
  18. package/src/ui/public/assets/ProjectStartupButton-D7mgEsmf.js +1 -0
  19. package/src/ui/public/assets/{RemoteRepoCard-CSMFlWDl.js → RemoteRepoCard-D582mbhK.js} +1 -1
  20. package/src/ui/public/assets/SourceMapView-gQRnN0Vj.js +3 -0
  21. package/src/ui/public/assets/{SvgIcon-CYZGHQAE.js → SvgIcon-Diox29ks.js} +1 -1
  22. package/src/ui/public/assets/{UserInputNode-3OAXiGVh.js → UserInputNode-CYxFuwuS.js} +1 -1
  23. package/src/ui/public/assets/{WorkbenchView-8xyxdWAg.js → WorkbenchView-CbWSllXy.js} +11 -11
  24. package/src/ui/public/assets/{_plugin-vue_export-helper-DgLDQTWK.js → _plugin-vue_export-helper-B9cwfrdH.js} +1 -1
  25. package/src/ui/public/assets/configStore-B8X980y1.js +1 -0
  26. package/src/ui/public/assets/{css.worker-CvXBzhp8.js → css.worker-Wv5dxAWO.js} +1 -1
  27. package/src/ui/public/assets/{dagre-CWIGcX9d.js → dagre-BCXBo6I9.js} +1 -1
  28. package/src/ui/public/assets/element-plus-BId3Dnij.js +25 -0
  29. package/src/ui/public/assets/{flow-mindmap-CUvJaYn2.js → flow-mindmap-DnyDhwJv.js} +38 -38
  30. package/src/ui/public/assets/{html.worker-BO6WuOEO.js → html.worker-CQP8QQsS.js} +1 -1
  31. package/src/ui/public/assets/{index-DDGvh1mp.js → index-DvrM8frM.js} +11 -11
  32. package/src/ui/public/assets/{json.worker-BkJRGcCJ.js → json.worker-DzV-CpCQ.js} +1 -1
  33. package/src/ui/public/assets/{monaco-BFLoT2VD.js → monaco-Dje8Rg13.js} +198 -198
  34. package/src/ui/public/assets/socket-io-CGezBd--.js +1 -0
  35. package/src/ui/public/assets/{ts.worker-B0J26iPs.js → ts.worker-Dth06zuC.js} +15 -15
  36. package/src/ui/public/assets/{vendor-jF--I87-.js → vendor-exjCs1jx.js} +38 -38
  37. package/src/ui/public/assets/vue-flow-Dj_X_Vr1.js +39 -0
  38. package/src/ui/public/index.html +11 -11
  39. package/src/ui/server/index.js +26 -10
  40. package/src/ui/server/routes/fs.js +11 -2
  41. package/src/ui/server/utils/perfMark.js +67 -0
  42. package/src/ui/public/assets/AppVersionBadge-CcFagCnq.js +0 -2
  43. package/src/ui/public/assets/BranchSelector-DcYVBQJh.js +0 -1
  44. package/src/ui/public/assets/CommitForm-CkTI8Xyz.js +0 -6
  45. package/src/ui/public/assets/EditorView-DuHkBSc3.js +0 -0
  46. package/src/ui/public/assets/FlowOrchestrationWorkspace-BeJRId5G.js +0 -21
  47. package/src/ui/public/assets/MindmapView-p7szLCtQ.js +0 -2
  48. package/src/ui/public/assets/MonitorView-Be6KEUCe.js +0 -1
  49. package/src/ui/public/assets/ProjectStartupButton-F1k5Km2p.js +0 -1
  50. package/src/ui/public/assets/SourceMapView-Dg5NP_gR.js +0 -3
  51. package/src/ui/public/assets/configStore-D5t05s4G.js +0 -1
  52. package/src/ui/public/assets/element-plus-CyGhXd7X.js +0 -25
  53. package/src/ui/public/assets/socket-io-BSgn8Jx2.js +0 -1
  54. package/src/ui/public/assets/vue-flow-CWmfpfPN.js +0 -39
  55. /package/src/ui/public/assets/{editor.worker-Cn2oRESe.js → editor.worker-Bd9IXS8d.js} +0 -0
  56. /package/src/ui/public/assets/{rolldown-runtime-CMxvf4Kt.js → rolldown-runtime-BM3Ffeng.js} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zen-gitsync",
3
- "version": "2.16.8",
3
+ "version": "2.16.10",
4
4
  "description": "Auto commit, scheduled sync, and visual GUI for Git. Run `g` in any repo for one-key commit & push, AI commit messages, scheduled background sync, and a drag-and-drop workflow builder.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -12,10 +12,10 @@
12
12
  "npm:publish": "npm publish --registry https://registry.npmjs.org/",
13
13
  "npm:whoami": "npm whoami --registry https://registry.npmjs.org/",
14
14
  "pull": "git pull",
15
- "dev:server": "cross-env PORT=5545 nodemon server.js --no-open",
16
- "dev:vue": "npm run install:vue && cd ./src/ui/client && npm run dev",
15
+ "dev:server": "cross-env ZEN_PERF=1 PORT=5545 nodemon server.js --no-open",
16
+ "dev:vue": "node scripts/ensure-client-deps.cjs && cd ./src/ui/client && cross-env ZEN_PERF=1 npm run dev",
17
17
  "install:vue": "cd ./src/ui/client && npm install",
18
- "dev": "concurrently -n backend,vite -c blue,green \"npm:dev:server\" \"npm:dev:vue\"",
18
+ "dev": "concurrently -n backend,vite -c blue,green --prefix \"[{time}][{name}]\" --timestamp-format \"HH:mm:ss.SSS\" \"npm:dev:server\" \"npm:dev:vue\"",
19
19
  "dev:ping": "node scripts/dev-ping.cjs",
20
20
  "start:server:no-open": "node server.js --no-open",
21
21
  "start:vue": "cd ./src/ui/client && npm run dev",
@@ -49,9 +49,10 @@ import { TOOL_DEFINITIONS, executeTool } from './tools.js'
49
49
  import { createThinkFilter } from './streamFilter.js'
50
50
  import {
51
51
  printBanner, printHelpPanel,
52
- filterSlashCommands, renderSlashHintBody,
52
+ filterSlashCommands, renderSlashHintBody, parseKeyForSlashHint,
53
53
  startSpinner, createAssistantWriter,
54
54
  summarizeToolArgs, printToolHeader, printToolResult,
55
+ formatDuration,
55
56
  printOk, printWarn, printError, printDim,
56
57
  } from './termui.js'
57
58
  import { readClipboardImage, checkImageFile, imageToDataUrl, formatBytes } from './images.js'
@@ -488,6 +489,7 @@ async function runAgentTurn(state, userText, t, images = []) {
488
489
  // 发送前消毒:确保历史里没有空 content 的消息(部分 provider 报 2013)
489
490
  sanitizeMessages(state.messages)
490
491
 
492
+ const llmStart = performance.now()
491
493
  let result
492
494
  try {
493
495
  result = await streamChatOnce({
@@ -512,6 +514,7 @@ async function runAgentTurn(state, userText, t, images = []) {
512
514
  if (last?.role === 'user') state.messages.pop()
513
515
  return
514
516
  }
517
+ const llmDuration = performance.now() - llmStart
515
518
  stopSpinner()
516
519
  writer.finish()
517
520
 
@@ -532,6 +535,11 @@ async function runAgentTurn(state, userText, t, images = []) {
532
535
  // content 为 null 而非空字符串:部分 provider 拒绝空字符串内容(2013 错误)
533
536
  state.messages.push({ role: 'assistant', content: content || null, tool_calls: toolCalls })
534
537
 
538
+ // LLM 耗时在进入工具执行前打印(仅本轮有工具调用时显示)
539
+ if (llmDuration > 0) {
540
+ printDim(` ⏱ LLM ${formatDuration(llmDuration)}`)
541
+ }
542
+
535
543
  for (const tc of toolCalls) {
536
544
  const name = tc.function?.name || ''
537
545
  const rawArgs = tc.function?.arguments || ''
@@ -550,9 +558,11 @@ async function runAgentTurn(state, userText, t, images = []) {
550
558
  printToolHeader(name, summarizeToolArgs(name, args, { chars: t.chars }))
551
559
  // 长命令执行期间给个 spinner,让用户知道没有卡死
552
560
  const toolSpinner = startSpinner(t.toolRunning(name))
561
+ const toolStart = performance.now()
553
562
  const output = await executeTool(name, args, state.ctx)
563
+ const toolDuration = performance.now() - toolStart
554
564
  toolSpinner.stop()
555
- printToolResult(output)
565
+ printToolResult(output, undefined, toolDuration)
556
566
  state.messages.push({ role: 'tool', tool_call_id: tc.id || name, name, content: output })
557
567
  }
558
568
  // 工具结果全部入历史后继续循环,让模型基于结果决定下一步
@@ -923,10 +933,16 @@ export async function runAiAgent(argv = []) {
923
933
  if (!state.busy) safeShowPrompt()
924
934
  }
925
935
 
926
- // ── 斜杠命令即时提示 ──
936
+ // ── 斜杠命令即时提示(支持 ↑↓ 切换 / Tab 补全 / Enter 提交) ──
927
937
  // 在提示符下方浮现匹配到的命令,随输入过滤;不移动光标(用 DECSC/DECRC 保存-恢复)。
928
938
  // slashHintRows 记录当前提示占了几行,下次刷新/提交时据此擦除,避免残影。
939
+ // slashHintBaseLine:本次 hint 显示时"输入行的基线值" — ↑↓ 时 readline 已经把
940
+ // rl.line 改成历史上一行,我们用基线回滚 + _refreshLine() 重绘,让用户视觉上看
941
+ // 不到"按 ↑ 输入行变了",只看到 hint 高亮跳了一格。
929
942
  let slashHintRows = 0
943
+ let slashHintBaseLine = ''
944
+ let slashHintMatches = []
945
+ let slashHintIndex = 0
930
946
  const eraseSlashHint = () => {
931
947
  if (!process.stdout.isTTY || slashHintRows === 0) return
932
948
  // 保存光标 → 下移到提示区逐行清空 → 恢复光标
@@ -936,11 +952,8 @@ export async function runAiAgent(argv = []) {
936
952
  process.stdout.write(seq)
937
953
  slashHintRows = 0
938
954
  }
939
- const renderSlashHint = () => {
940
- if (!process.stdout.isTTY || state.busy || state.inWizard) { return }
941
- const matches = filterSlashCommands(rl.line, state.locale)
942
- const body = renderSlashHintBody(matches)
943
- // 先擦掉旧提示(行数可能变化),再画新的
955
+ const drawSlashHint = () => {
956
+ const body = renderSlashHintBody(slashHintMatches, slashHintIndex)
944
957
  eraseSlashHint()
945
958
  if (!body) return
946
959
  const rows = body.split('\n')
@@ -951,6 +964,42 @@ export async function runAiAgent(argv = []) {
951
964
  process.stdout.write(seq)
952
965
  slashHintRows = rows.length
953
966
  }
967
+ // 重置 hint 上下文:重新计算匹配 + 重画。输入字符变化时调用。
968
+ const refreshSlashHint = () => {
969
+ if (!process.stdout.isTTY || state.busy || state.inWizard) {
970
+ eraseSlashHint()
971
+ slashHintMatches = []
972
+ slashHintIndex = 0
973
+ slashHintBaseLine = ''
974
+ return
975
+ }
976
+ slashHintBaseLine = rl.line
977
+ slashHintMatches = filterSlashCommands(rl.line, state.locale)
978
+ // 匹配列表缩短时夹紧 index(增删交替时不会越界)
979
+ slashHintIndex = slashHintIndex < slashHintMatches.length ? slashHintIndex : 0
980
+ drawSlashHint()
981
+ }
982
+ // ↑↓ / Shift+Tab:readline 已经把 rl.line 改成历史行或没动 → 强制恢复基线并重绘输入行
983
+ const restoreInputLineToBaseline = () => {
984
+ if (rl.line === slashHintBaseLine) return
985
+ rl.line = slashHintBaseLine
986
+ rl.cursor = slashHintBaseLine.length
987
+ if (typeof rl._refreshLine === 'function') rl._refreshLine()
988
+ }
989
+ // Tab / Enter:把 rl.line 替换为补全文本 + 清掉 hint 状态
990
+ // Tab:补全文本末尾加空格,便于用户继续打参数(不回车)
991
+ // Enter:不用此函数 — 用 state.slashHintSubmitLine 标志 + line handler 替换
992
+ // (readline 自己处理 Enter,我们不能事后改 rl.line 触发提交)
993
+ const applyHintCompletion = (newLine) => {
994
+ rl.line = newLine
995
+ rl.cursor = newLine.length
996
+ eraseSlashHint()
997
+ slashHintMatches = []
998
+ slashHintIndex = 0
999
+ slashHintRows = 0
1000
+ slashHintBaseLine = newLine
1001
+ if (typeof rl._refreshLine === 'function') rl._refreshLine()
1002
+ }
954
1003
 
955
1004
  // Alt+V 粘贴剪贴板图片(node 把 ESC+v 解析为 meta+v;部分终端不发 Alt,可用 /image 兜底)
956
1005
  const pasteFromClipboard = async () => {
@@ -973,14 +1022,73 @@ export async function runAiAgent(argv = []) {
973
1022
  }
974
1023
  }
975
1024
  rl.input.on('keypress', (ch, key) => {
976
- // Enter 提交由 line 处理器负责擦除提示,这里不重画(此刻 readline 已换行,
977
- // 光标不在输入行,重画会错位)
978
- if (!(key && (key.name === 'return' || key.name === 'enter'))) {
979
- // 每次按键后即时刷新斜杠命令提示(readline 已先处理完本次按键,rl.line 为最新值)。
980
- // 非 slash / 无匹配时 renderSlashHint 会把上一次的提示擦掉。
981
- renderSlashHint()
982
- }
983
1025
  if (!key) return
1026
+
1027
+ // ── 斜杠命令提示专用键盘:↑↓/Tab/Enter/Shift+Tab/Esc ──
1028
+ // 仅在 hint 已显示且有匹配项时拦截,readline 的 default 行为(历史浏览等)
1029
+ // 我们在 handler 里事后回滚 rl.line 来抵消
1030
+ const action = parseKeyForSlashHint(key)
1031
+ const hintActive = action === 'prev' || action === 'next' || action === 'complete' || action === 'submit' || action === 'cancel'
1032
+ ? (slashHintRows > 0 && slashHintMatches.length > 0 && !state.busy && !state.inWizard)
1033
+ : false
1034
+
1035
+ if (hintActive) {
1036
+ if (action === 'prev' || action === 'next') {
1037
+ const n = slashHintMatches.length
1038
+ slashHintIndex = action === 'prev'
1039
+ ? (slashHintIndex - 1 + n) % n
1040
+ : (slashHintIndex + 1) % n
1041
+ restoreInputLineToBaseline()
1042
+ drawSlashHint()
1043
+ return // 不再走 default update + Alt+V 分支
1044
+ }
1045
+ if (action === 'complete') {
1046
+ // Tab:把输入行替换为选中命令 + 末尾空格(便于继续打参数),
1047
+ // 光标跳到末尾;之后不再触发 hint(已经精确选了某个命令)
1048
+ const sel = slashHintMatches[slashHintIndex]
1049
+ const newLine = sel.cmd + ' '
1050
+ applyHintCompletion(newLine)
1051
+ return
1052
+ }
1053
+ if (action === 'submit') {
1054
+ // Enter:把 rl.line 替换为补全命令,然后手动调用 rl._line() 触发 line 事件。
1055
+ // 这样提交的就是补全后的命令,而不是用户敲到一半的前缀。
1056
+ // 必须 return — 否则 readline 自己也会按 Enter 把当前 rl.line(用户原始输入) 提交一遍。
1057
+ const sel = slashHintMatches[slashHintIndex]
1058
+ const newLine = sel.cmd
1059
+ console.error('[DEBUG-ENTER] sel.cmd=' + JSON.stringify(sel.cmd) + ' rl.line before=' + JSON.stringify(rl.line))
1060
+ rl.line = newLine
1061
+ rl.cursor = newLine.length
1062
+ eraseSlashHint()
1063
+ slashHintMatches = []
1064
+ slashHintIndex = 0
1065
+ slashHintRows = 0
1066
+ slashHintBaseLine = newLine
1067
+ if (typeof rl._refreshLine === 'function') rl._refreshLine()
1068
+ // readline 的 _line() 内部会 this.emit('line', this.line) — 用补全后的 line
1069
+ if (typeof rl._line === 'function') {
1070
+ try { rl._line(); console.error('[DEBUG-ENTER] _line ok, rl.line after=' + JSON.stringify(rl.line)) } catch (e) { console.error('[DEBUG-ENTER] _line err: ' + e.message); rl.emit('line', newLine) }
1071
+ } else {
1072
+ rl.emit('line', newLine)
1073
+ }
1074
+ return
1075
+ }
1076
+ if (action === 'cancel') {
1077
+ // Esc:只清 hint,不动输入行
1078
+ eraseSlashHint()
1079
+ slashHintMatches = []
1080
+ slashHintIndex = 0
1081
+ return
1082
+ }
1083
+ }
1084
+
1085
+ // submit(Enter)走默认 readline 提交,line 事件里统一擦除 hint;
1086
+ // 其他普通按键:重算匹配 + 重画 hint
1087
+ if (!(key.name === 'return' || key.name === 'enter')) {
1088
+ refreshSlashHint()
1089
+ }
1090
+
1091
+ // Alt+V 粘贴剪贴板图片(node 把 ESC+v 解析为 meta+v;部分终端不发 Alt,可用 /image 兜底)
984
1092
  const isAltV = (key.meta && key.name === 'v')
985
1093
  || (typeof key.sequence === 'string' && key.sequence.length === 2
986
1094
  && key.sequence.charCodeAt(0) === 27 && key.sequence[1] === 'v')
@@ -22,8 +22,8 @@
22
22
  // 1. 询问是否现在配置
23
23
  // 2. 选择服务商(预设列表 / 自定义)
24
24
  // 3. 确认接口地址(baseURL)
25
- // 4. 选择模型名称(常用模型列表 / 手动输入)
26
- // 5. 输入 API Key(本地模型可留空)
25
+ // 4. 输入 API Key(本地模型可留空)
26
+ // 5. 选择模型名称(先从 API 获取模型列表,失败则用内置列表 / 手动输入)
27
27
  // 6. 输入显示名称(可选)
28
28
  // 7. 测试连接
29
29
  // 8. 保存到配置文件并返回模型对象
@@ -137,6 +137,8 @@ const STRINGS = {
137
137
  endpointInvalid: '接口地址格式不正确(需为 http/https URL)',
138
138
  modelLabel: '模型名称',
139
139
  builtinModels: '常用模型:',
140
+ fetchingModels: '正在获取模型列表…',
141
+ fetchModelsFailed: '获取模型列表失败,使用内置列表',
140
142
  manualInput: '手动输入',
141
143
  modelPrompt: '请输入模型名称: ',
142
144
  modelRequired: '模型名称不能为空',
@@ -170,6 +172,8 @@ const STRINGS = {
170
172
  endpointInvalid: 'Invalid endpoint URL (must be http/https)',
171
173
  modelLabel: 'Model name',
172
174
  builtinModels: 'Popular models:',
175
+ fetchingModels: 'Fetching model list…',
176
+ fetchModelsFailed: 'Failed to fetch model list, using built-in list',
173
177
  manualInput: 'Manual input',
174
178
  modelPrompt: 'Enter model name: ',
175
179
  modelRequired: 'Model name cannot be empty',
@@ -259,6 +263,48 @@ export function buildModelConfig({ baseURL, model, apiKey, name, isDefault }) {
259
263
  }
260
264
  }
261
265
 
266
+ /**
267
+ * 从 API 获取可用模型列表(OpenAI 兼容 /models 接口)。
268
+ *
269
+ * @param {object} opts
270
+ * @param {string} opts.baseURL - API 基础地址
271
+ * @param {string} [opts.apiKey] - API Key(可选)
272
+ * @param {number} [opts.timeoutMs=5000] - 超时时间
273
+ * @param {typeof fetch} [opts.fetchFn] - 可注入 fetch(测试用)
274
+ * @returns {Promise<string[]>} 模型名数组,失败返回空数组
275
+ */
276
+ export async function fetchModelsFromApi({ baseURL, apiKey, timeoutMs = 5000, fetchFn } = {}) {
277
+ const fetch = fetchFn || globalThis.fetch
278
+ if (!fetch) return []
279
+
280
+ const url = `${String(baseURL || '').replace(/\/$/, '')}/models`
281
+ const headers = {}
282
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
283
+
284
+ const controller = new AbortController()
285
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
286
+
287
+ try {
288
+ const res = await fetch(url, {
289
+ method: 'GET',
290
+ headers,
291
+ signal: controller.signal,
292
+ })
293
+
294
+ if (!res.ok) return []
295
+
296
+ const data = await res.json()
297
+ // OpenAI 兼容接口返回 { data: [{ id: 'model-name', ... }] }
298
+ const models = data?.data || []
299
+ return models.map(m => m.id).filter(Boolean)
300
+ } catch (err) {
301
+ // 任何错误都静默返回空数组,由调用方决定是否回退
302
+ return []
303
+ } finally {
304
+ clearTimeout(timer)
305
+ }
306
+ }
307
+
262
308
  /**
263
309
  * 测试模型连接(OpenAI 兼容 /chat/completions)。
264
310
  *
@@ -463,11 +509,25 @@ export async function collectModelInput({ locale = 'zh-CN', rl: injectedRl, fetc
463
509
  baseURL = confirmedBaseURL
464
510
  }
465
511
 
466
- // 3. 选择/输入模型名称
467
- const builtin = getBuiltinModels(baseURL)
512
+ // 3. 输入 API Key(在获取模型列表之前,因为大部分服务商需要 Key 才能调 /models)
513
+ const apiKey = await asker.ask(t.apiKeyPrompt)
514
+
515
+ // 4. 选择/输入模型名称
516
+ // 先尝试从 API 获取模型列表(有了 apiKey 才能调通)
517
+ const fetchSpinner = startSpinner(t.fetchingModels)
518
+ const fetchedModels = await fetchModelsFromApi({ baseURL, apiKey, fetchFn })
519
+ fetchSpinner.stop()
520
+
521
+ // 使用 API 返回的模型列表,如果失败则使用内置列表
522
+ const modelList = fetchedModels.length > 0 ? fetchedModels : getBuiltinModels(baseURL)
523
+
524
+ if (fetchedModels.length === 0 && getBuiltinModels(baseURL).length > 0) {
525
+ console.log(chalk.dim(` ${t.fetchModelsFailed}`))
526
+ }
527
+
468
528
  let model
469
- if (builtin.length > 0) {
470
- const modelItems = builtin.map(m => ({ label: m, value: m }))
529
+ if (modelList.length > 0) {
530
+ const modelItems = modelList.map(m => ({ label: m, value: m }))
471
531
  const modelChoice = await selectFromList(asker, t.builtinModels, modelItems, t, t.manualInput)
472
532
  if (modelChoice) {
473
533
  model = modelChoice.value
@@ -480,7 +540,7 @@ export async function collectModelInput({ locale = 'zh-CN', rl: injectedRl, fetc
480
540
  }
481
541
  }
482
542
  } else {
483
- // 没有预设模型,直接手动输入
543
+ // 没有模型列表,直接手动输入
484
544
  while (true) {
485
545
  model = await asker.ask(t.modelPrompt)
486
546
  if (model) break
@@ -488,9 +548,6 @@ export async function collectModelInput({ locale = 'zh-CN', rl: injectedRl, fetc
488
548
  }
489
549
  }
490
550
 
491
- // 4. 输入 API Key
492
- const apiKey = await asker.ask(t.apiKeyPrompt)
493
-
494
551
  // 5. 输入显示名称(默认用 model 名)
495
552
  const displayName = await asker.ask(t.displayNamePrompt(model), model)
496
553
 
@@ -591,4 +648,4 @@ export async function runModelSetup({ locale = 'zh-CN', rl: injectedRl, fetchFn
591
648
  }
592
649
  }
593
650
 
594
- export default { runModelSetup, collectModelInput, testModelConnection, getBuiltinModels, findProviderByUrl, validateEndpoint, buildModelConfig, PROVIDERS, BUILTIN_MODELS }
651
+ export default { runModelSetup, collectModelInput, fetchModelsFromApi, testModelConnection, getBuiltinModels, findProviderByUrl, validateEndpoint, buildModelConfig, PROVIDERS, BUILTIN_MODELS }
@@ -409,8 +409,8 @@ function silenceConsole() {
409
409
  test('collectModelInput: 预设服务商 + 内置模型 + 测试成功 → 返回收集的字段', async () => {
410
410
  const restore = silenceConsole()
411
411
  try {
412
- // 答案:选 OpenAI(1) → 确认 baseURL(空=默认) → 选第一个模型(1) → apiKey → displayName(空=默认)
413
- const rl = createMockRl(['1', '', '1', 'sk-test', ''])
412
+ // 答案:选 OpenAI(1) → 确认 baseURL(空=默认) → apiKey → 选第一个模型(1) → displayName(空=默认)
413
+ const rl = createMockRl(['1', '', 'sk-test', '1', ''])
414
414
  const result = await collectModelInput({
415
415
  locale: 'zh-CN',
416
416
  rl,
@@ -429,8 +429,8 @@ test('collectModelInput: 预设服务商 + 内置模型 + 测试成功 → 返
429
429
  test('collectModelInput: 自定义服务商 + 手动输入模型名 → 返回收集的字段', async () => {
430
430
  const restore = silenceConsole()
431
431
  try {
432
- // 选自定义(0) → 输入 baseURL → 确认(空=默认) → 手动输入模型名 → apiKey(空) → displayName(空=默认)
433
- const rl = createMockRl(['0', 'https://my.custom.api/v1', '', 'my-model', '', ''])
432
+ // 选自定义(0) → 输入 baseURL → 确认(空=默认) → apiKey(空) → 手动输入模型名 → displayName(空=默认)
433
+ const rl = createMockRl(['0', 'https://my.custom.api/v1', '', '', 'my-model', ''])
434
434
  const result = await collectModelInput({
435
435
  locale: 'zh-CN',
436
436
  rl,
@@ -449,8 +449,8 @@ test('collectModelInput: 自定义服务商 + 手动输入模型名 → 返回
449
449
  test('collectModelInput: 测试失败 + 用户选择不保存 → 返回 null', async () => {
450
450
  const restore = silenceConsole()
451
451
  try {
452
- // 选 DeepSeek(3) → 确认 → 选第一个模型(1) → apiKey → displayName → 测试401失败 → 不保存(n)
453
- const rl = createMockRl(['3', '', '1', 'sk-bad', '', 'n'])
452
+ // 选 DeepSeek(3) → 确认 → apiKey → 选第一个模型(1) → displayName → 测试401失败 → 不保存(n)
453
+ const rl = createMockRl(['3', '', 'sk-bad', '1', '', 'n'])
454
454
  const result = await collectModelInput({
455
455
  locale: 'zh-CN',
456
456
  rl,
@@ -465,7 +465,7 @@ test('collectModelInput: 测试失败 + 用户选择不保存 → 返回 null',
465
465
  test('collectModelInput: 测试失败 + 用户选择继续 → 返回收集的字段', async () => {
466
466
  const restore = silenceConsole()
467
467
  try {
468
- const rl = createMockRl(['3', '', '1', 'sk-bad', '', 'y'])
468
+ const rl = createMockRl(['3', '', 'sk-bad', '1', '', 'y'])
469
469
  const result = await collectModelInput({
470
470
  locale: 'zh-CN',
471
471
  rl,
@@ -494,3 +494,32 @@ test('collectModelInput: 用户取消(触发 close)→ 返回 null', async () =>
494
494
  restore()
495
495
  }
496
496
  })
497
+
498
+ test('collectModelInput: API 返回模型列表时优先使用 → 返回收集的字段', async () => {
499
+ const restore = silenceConsole()
500
+ try {
501
+ // mock fetch: /models 返回模型列表, /chat/completions 返回 200
502
+ const smartFetch = async (url, opts) => {
503
+ if (url.endsWith('/models')) {
504
+ return {
505
+ ok: true,
506
+ status: 200,
507
+ json: async () => ({ data: [{ id: 'api-model-1' }, { id: 'api-model-2' }] }),
508
+ }
509
+ }
510
+ return { ok: true, status: 200, json: async () => ({}) }
511
+ }
512
+ // 选 OpenAI(1) → 确认 baseURL → apiKey → 选第一个 API 返回的模型(1) → displayName
513
+ const rl = createMockRl(['1', '', 'sk-test', '1', ''])
514
+ const result = await collectModelInput({
515
+ locale: 'zh-CN',
516
+ rl,
517
+ fetchFn: smartFetch,
518
+ })
519
+ assert.ok(result)
520
+ assert.equal(result.model, 'api-model-1', '应使用 API 返回的模型而非内置列表')
521
+ assert.equal(result.apiKey, 'sk-test')
522
+ } finally {
523
+ restore()
524
+ }
525
+ })
@@ -140,16 +140,59 @@ export function filterSlashCommands(input, locale) {
140
140
 
141
141
  /**
142
142
  * 生成即时提示面板的 ANSI 字符串(不含定位,由调用方负责保存/恢复光标)。
143
- * 每行:两空格缩进 + 青色命令名(左对齐补齐)+ 灰色说明。
144
- * 空数组返回空串。
143
+ * 每行:两空格缩进 + 蓝色命令名(左对齐补齐 12 字符宽)+ 灰色说明。
144
+ * 所有行的"视觉起点"一致(均为 2 空格缩进);选中行用整行反白区分(从缩进处
145
+ * 开始),不另加 ❯ 之类的偏移标记 —— 避免反白块起点与其他行不齐。
146
+ * - 越界 selectedIndex(<0 / >=matches.length) 自动回退为 -1(即不高亮)
147
+ * - 空数组返回空串
145
148
  */
146
- export function renderSlashHintBody(matches) {
149
+ export function renderSlashHintBody(matches, selectedIndex = -1) {
147
150
  if (!Array.isArray(matches) || matches.length === 0) return ''
151
+ const sel = (Number.isInteger(selectedIndex) && selectedIndex >= 0 && selectedIndex < matches.length)
152
+ ? selectedIndex
153
+ : -1
154
+ // 行宽固定 = 2 空格缩进 + 12 字符命令 + 说明;选中行反白整行(含缩进),
155
+ // 反白起点与未选中行严格对齐,视觉上选中行不会"偏左"。
156
+ const rowWidth = (m) => 2 + Math.max(12, String(m.cmd).length) + 1 + String(m.desc).length
148
157
  return matches
149
- .map((m) => ' ' + chalk.cyan(String(m.cmd).padEnd(12)) + chalk.dim(m.desc))
158
+ .map((m, i) => {
159
+ // 用 padEnd 凑满 rowWidth,这样反白背景能延伸到行尾,左右不留缝
160
+ const raw = ' ' + String(m.cmd).padEnd(12) + ' ' + m.desc
161
+ // 所有行命令名统一用青色(不带额外前缀),选中行靠反白区分
162
+ const line = chalk.cyan(String(m.cmd).padEnd(12)) + ' ' + chalk.dim(m.desc)
163
+ if (i !== sel) return ' ' + line
164
+ // 选中行:把命令名+说明之外的两空格缩进也纳入反白,这样视觉起点严格对齐
165
+ return chalk.inverse(raw.padEnd(rowWidth(m)))
166
+ })
150
167
  .join('\n')
151
168
  }
152
169
 
170
+ /**
171
+ * 解析 readline keypress 事件,识别"斜杠命令提示"专用快捷键。
172
+ * 返回动作字符串(消费方据此改 selectedIndex 或补全输入);
173
+ * 不识别的键返回 null(由 readline 正常处理)。
174
+ *
175
+ * - ↑ / Shift+Tab → 'prev'
176
+ * - ↓ → 'next'
177
+ * - Tab → 'complete'(补全选中命令到输入行)
178
+ * - Enter → 'submit'(由 readline 默认提交,本函数仅标记是补全时机)
179
+ * - Esc → 'cancel'
180
+ *
181
+ * 注意:readline 默认会把方向键当历史浏览。消费方需要在我们这个 handler
182
+ * 里"事后回滚":把 rl.line 恢复成 hint 显示时的基线,再 _refreshLine()。
183
+ * (详见 agent.js 的 keypress 监听与说明)
184
+ */
185
+ export function parseKeyForSlashHint(key) {
186
+ if (!key) return null
187
+ const name = key.name
188
+ if (name === 'tab') return key.shift ? 'prev' : 'complete'
189
+ if (name === 'up') return 'prev'
190
+ if (name === 'down') return 'next'
191
+ if (name === 'return' || name === 'enter') return 'submit'
192
+ if (name === 'escape') return 'cancel'
193
+ return null
194
+ }
195
+
153
196
  /** 盒式帮助面板 */
154
197
  export function printHelpPanel(title, lines) {
155
198
  const body = [chalk.bold(title), ...lines].join('\n')
@@ -376,6 +419,28 @@ export function createAssistantWriter({
376
419
  }
377
420
  }
378
421
 
422
+ // ──────────────────────────────────────────────────────
423
+ // 耗时格式化
424
+ // ──────────────────────────────────────────────────────
425
+
426
+ /**
427
+ * 格式化耗时(毫秒 → 人类可读)。
428
+ * < 1s → "123ms"
429
+ * < 60s → "1.2s"
430
+ * ≥ 60s → "2m30s"
431
+ * @param {number} ms
432
+ * @returns {string}
433
+ */
434
+ export function formatDuration(ms) {
435
+ if (!Number.isFinite(ms) || ms < 0) return ''
436
+ if (ms < 1000) return `${Math.round(ms)}ms`
437
+ const s = ms / 1000
438
+ if (s < 60) return `${s.toFixed(1)}s`
439
+ const m = Math.floor(s / 60)
440
+ const rem = Math.round(s % 60)
441
+ return `${m}m${rem}s`
442
+ }
443
+
379
444
  // ──────────────────────────────────────────────
380
445
  // 工具调用块(Claude Code 风格)
381
446
  // ──────────────────────────────────────────────
@@ -426,8 +491,12 @@ export function printToolHeader(name, summary, write = (s) => process.stdout.wri
426
491
  * run_command 的结果里首行是 `$ <command>` 回显——这条信息已经出现在上方的
427
492
  * `▶ run_command $ <summary>` 工具头里,这里再印一次就是重复。所以这里把首
428
493
  * 行 `$ ...` 剥掉,同时复用它来识别退出码(退出码仍驱动错误着色)。
494
+ *
495
+ * @param {string} result - 工具输出文本
496
+ * @param {(s: string) => void} [write] - 输出函数,默认写 stdout
497
+ * @param {number} [durationMs] - 执行耗时(毫秒),有值时在结果末尾追加 ⏱ 计时行
429
498
  */
430
- export function printToolResult(result, write = (s) => process.stdout.write(s)) {
499
+ export function printToolResult(result, write = (s) => process.stdout.write(s), durationMs) {
431
500
  const text = truncateDisplay(result)
432
501
  // 先把 "$ <command>\n" 这一行回显从展示里剥掉(退出码仍在第二行里识别)
433
502
  const visible = text.replace(/^\$[^\n]*\n/, '')
@@ -440,7 +509,11 @@ export function printToolResult(result, write = (s) => process.stdout.write(s))
440
509
  const colorize = isError ? chalk.hex('#f0a020') : chalk.hex('#94a3b8')
441
510
  const lines = visible.split('\n')
442
511
  const rendered = lines.map((l) => chalk.dim(' │ ') + colorize(l)).join('\n')
443
- write(rendered + '\n')
512
+ // 有耗时时在结果末尾追加 ⏱ 计时行(与结果块同缩进)
513
+ const timingLine = (durationMs != null && Number.isFinite(durationMs))
514
+ ? '\n' + chalk.dim(' │ ⏱ ') + chalk.hex('#7a87a0')(formatDuration(durationMs))
515
+ : ''
516
+ write(rendered + timingLine + '\n')
444
517
  }
445
518
 
446
519
  // ──────────────────────────────────────────────
@@ -456,11 +529,13 @@ export default {
456
529
  termWidth,
457
530
  stripAnsi,
458
531
  truncateDisplay,
532
+ formatDuration,
459
533
  printBanner,
460
534
  printHelpPanel,
461
535
  SLASH_COMMANDS,
462
536
  filterSlashCommands,
463
537
  renderSlashHintBody,
538
+ parseKeyForSlashHint,
464
539
  drawInputTop,
465
540
  drawInputBottom,
466
541
  inputBottomBorder,