zen-gitsync 2.16.10 → 2.16.12

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 (67) hide show
  1. package/README.md +17 -16
  2. package/package.json +1 -1
  3. package/scripts/release.js +3 -3
  4. package/src/cli/ai/modelSetup.js +197 -14
  5. package/src/cli/ai/sessionStore.js +97 -97
  6. package/src/cli/ai/termui.js +92 -0
  7. package/src/cli/ai/termui.test.js +131 -0
  8. package/src/cli/customCommand.exec.test.js +52 -13
  9. package/src/ui/public/assets/AgentView-B_SeSMYO.js +4 -0
  10. package/src/ui/public/assets/AppVersionBadge-BeXH_zPw.js +2 -0
  11. package/src/ui/public/assets/BranchSelector-DiKVTd6_.js +1 -0
  12. package/src/ui/public/assets/CommitForm-bj3k-DPA.js +6 -0
  13. package/src/ui/public/assets/EditorView-B9Px9HM-.js +0 -0
  14. package/src/ui/public/assets/FlowExecutionViewer-B5j4P2AL.js +1 -0
  15. package/src/ui/public/assets/FlowOrchestrationWorkspace-CvpUR_GE.js +21 -0
  16. package/src/ui/public/assets/LogList-CRf4Qp6G.js +15 -0
  17. package/src/ui/public/assets/MindmapView-F9BzvQzm.js +2 -0
  18. package/src/ui/public/assets/MonitorView-Cp74zQIe.js +1 -0
  19. package/src/ui/public/assets/ProjectStartupButton-CAJ6NfgE.js +1 -0
  20. package/src/ui/public/assets/RemoteRepoCard-DbxZKwZQ.js +5 -0
  21. package/src/ui/public/assets/SourceMapView-SLERMi2h.js +3 -0
  22. package/src/ui/public/assets/UserInputNode-BJdehl9K.js +1 -0
  23. package/src/ui/public/assets/WorkbenchView-Ds2-DJ0d.js +28 -0
  24. package/src/ui/public/assets/element-plus-C0loJL0J.js +32 -0
  25. package/src/ui/public/assets/element-plus-CXfbWXaJ.css +1 -0
  26. package/src/ui/public/assets/{flow-mindmap-DnyDhwJv.js → flow-mindmap-CGn6wqsF.js} +1 -1
  27. package/src/ui/public/assets/index-BEPT2LNX.js +82 -0
  28. package/src/ui/public/assets/index-Fbjp0DKn.css +1 -0
  29. package/src/ui/public/assets/{monaco-Dje8Rg13.js → monaco-DPQ3OaiL.js} +2 -2
  30. package/src/ui/public/assets/socket-io-B9UuzwTN.js +1 -0
  31. package/src/ui/public/assets/{ts.worker-Dth06zuC.js → ts.worker-METxwbDZ.js} +1 -16
  32. package/src/ui/public/assets/{vendor-DVvq7qtS.css → vendor-C2ZpZPB4.css} +1 -1
  33. package/src/ui/public/assets/{vendor-exjCs1jx.js → vendor-pbWglt5U.js} +26 -67
  34. package/src/ui/public/assets/vue-flow-DRwHh4gM.js +39 -0
  35. package/src/ui/public/index.html +9 -15
  36. package/src/ui/server/index.js +4 -0
  37. package/src/ui/server/routes/workbench/agentChat.js +14 -3
  38. package/src/ui/server/routes/workbench/agentRoutes.js +218 -212
  39. package/src/ui/server/routes/workbench/agentSessionStore.js +173 -173
  40. package/src/ui/public/assets/AgentView-CioAzh-a.js +0 -4
  41. package/src/ui/public/assets/AppVersionBadge-CZ3UbiAu.js +0 -2
  42. package/src/ui/public/assets/BranchSelector-Kwcj7e0Z.js +0 -1
  43. package/src/ui/public/assets/CommitForm-DueeSB7T.js +0 -6
  44. package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +0 -1
  45. package/src/ui/public/assets/CommonDialog-Tlk0Fa7g.js +0 -1
  46. package/src/ui/public/assets/EditorView-B2gm2OW1.js +0 -0
  47. package/src/ui/public/assets/FlowExecutionViewer-CFpiqZ2C.js +0 -1
  48. package/src/ui/public/assets/FlowOrchestrationWorkspace-BRUlRQwp.js +0 -21
  49. package/src/ui/public/assets/LogList-Do3C4BmA.js +0 -15
  50. package/src/ui/public/assets/MindmapView-C1pPUu8W.js +0 -2
  51. package/src/ui/public/assets/MonitorView-C2EyggDk.js +0 -1
  52. package/src/ui/public/assets/ProjectStartupButton-D7mgEsmf.js +0 -1
  53. package/src/ui/public/assets/RemoteRepoCard-D582mbhK.js +0 -5
  54. package/src/ui/public/assets/SourceMapView-gQRnN0Vj.js +0 -3
  55. package/src/ui/public/assets/SvgIcon-CDNsk4-Q.css +0 -1
  56. package/src/ui/public/assets/SvgIcon-Diox29ks.js +0 -1
  57. package/src/ui/public/assets/UserInputNode-CYxFuwuS.js +0 -1
  58. package/src/ui/public/assets/WorkbenchView-CbWSllXy.js +0 -28
  59. package/src/ui/public/assets/_plugin-vue_export-helper-B9cwfrdH.js +0 -15
  60. package/src/ui/public/assets/configStore-B8X980y1.js +0 -1
  61. package/src/ui/public/assets/element-plus-BId3Dnij.js +0 -25
  62. package/src/ui/public/assets/element-plus-D3Dn7nX0.css +0 -1
  63. package/src/ui/public/assets/index--onqtBRQ.css +0 -1
  64. package/src/ui/public/assets/index-DvrM8frM.js +0 -68
  65. package/src/ui/public/assets/socket-io-CGezBd--.js +0 -1
  66. package/src/ui/public/assets/vue-flow-Dj_X_Vr1.js +0 -39
  67. /package/src/ui/public/assets/{dagre-BCXBo6I9.js → dagre-CePNlbK4.js} +0 -0
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # zen-gitsync
1
+ # zen-gitsync
2
2
 
3
3
  [English](#zen-gitsync) | [中文](#zh)
4
4
 
@@ -7,7 +7,7 @@ A Git automation platform with interactive commits, scheduled sync, custom comma
7
7
  ## Table of Contents
8
8
 
9
9
  - [Installation](#installation)
10
- - [What''s New](#v2xx--whats-new)
10
+ - [What's New](#v2xx--whats-new)
11
11
  - [GUI](#gui)
12
12
  - [Core Git Panel](#core-git-panel)
13
13
  - [Branch Management](#branch-management)
@@ -38,7 +38,7 @@ npm install -g zen-gitsync
38
38
 
39
39
  ---
40
40
 
41
- ## v2.x.x — What''s New
41
+ ## v2.x.x — What's New
42
42
 
43
43
  - **Visual GUI** — Full graphical interface for Git operations
44
44
  - **Branch management** — Create, switch, and track local/remote branches
@@ -62,7 +62,7 @@ npm install -g zen-gitsync
62
62
  - **Accessibility (WCAG 2.1 AA)** — Dialog focus trap & restore, role-based separators, keyboard-only panel resize (`← → ↑ ↓`), screen-reader friendly commit context menu, ARIA-pressed toggle buttons, commit button `aria-busy` during in-flight commits, Git SHA hashes meet ≥ 4.5:1 contrast in both light and dark themes
63
63
  - **Faster cold start** — `monaco-editor` / `@vue-flow` / `flow-mindmap` / `dagre` are split into independent chunks and lazy-loaded so the Git panel boots without waiting on the code editor or visual workflow designer
64
64
 
65
- > Detailed per-release change log (a11y/perf fixes, i18n missing translations, dead-code removals, etc.) lives in [CHANGELOG.md](./CHANGELOG.md).
65
+ > Detailed per-release changes can be found via `git log` or the [GitHub Releases](https://github.com/xz333221/zen-gitsync/releases) page.
66
66
 
67
67
  ---
68
68
 
@@ -410,16 +410,7 @@ git add --renormalize .
410
410
 
411
411
  ### Package manager
412
412
 
413
- All `package.json` scripts use **npm** (`npm install`, `npm run dev`, `npm run release`, etc.). The repo also has a `pnpm-lock.yaml` checked in for reproducibility on machines that prefer pnpm — both managers work because `package.json` is the single source of truth. The CLI's own `bin` entry and most devDeps are pinned to caret ranges; either manager will resolve to compatible versions.
414
-
415
- If you switch managers, delete the other manager's lockfile so only one is in the tree:
416
-
417
- ```bash
418
- rm -f pnpm-lock.yaml # when running on npm
419
- rm -f package-lock.json # when running on pnpm
420
- ```
421
-
422
- `.gitignore` already lists both `pnpm-lock.yaml` and `package-lock.json`, so neither is forced into version control.
413
+ All `package.json` scripts use **npm** (`npm install`, `npm run dev`, `npm run release`, etc.). The `package-lock.json` is git-ignored, so each developer generates it locally. The CLI's own `bin` entry and most devDeps are pinned to caret ranges.
423
414
 
424
415
  ---
425
416
 
@@ -437,6 +428,11 @@ $ g ai "fix the failing test" # one-shot task, then exit
437
428
  $ g ai --model=2 # pick the 2nd configured model (index or name)
438
429
  ```
439
430
 
431
+ The first-run setup wizard and `/addmodel` provider/model lists support **↑↓ keys to switch
432
+ selection + Enter to confirm** (typing a number also jumps directly; `0` selects the trailing
433
+ "custom / manual input" entry). Non-TTY environments (CI, piped input) automatically fall back
434
+ to numeric input. `Esc` or `Ctrl+C` cancels the wizard cleanly.
435
+
440
436
  In-session commands: `/help`, `/model`, `/addmodel`, `/cd <path>`, `/image [path]`, `/think`, `/clear`, `/exit`.
441
437
 
442
438
  Images: press `Alt+V` in the REPL to paste a clipboard image (screenshot), or attach a
@@ -603,7 +599,7 @@ npm install -g zen-gitsync
603
599
  - **可访问性(WCAG 2.1 AA)** — 弹窗焦点陷阱与归还、`role="separator"` 键盘可达的分隔条、纯键盘(`← → ↑ ↓`)调整面板宽度、屏幕阅读器友好的提交右键菜单、ARIA-pressed 切换按钮、提交按钮在提交过程中挂 `aria-busy`、Git 提交哈希在明/暗主题下对比度均 ≥ 4.5:1
604
600
  - **更快的冷启动** — `monaco-editor` / `@vue-flow` / `flow-mindmap` / `dagre` 拆分为独立 chunk 并按需懒加载,Git 面板首屏不再等待代码编辑器或可视化流程编排模块
605
601
 
606
- > 每个版本的详细变更日志(可访问性/性能修复、i18n 漏译、死代码清理等)见 [CHANGELOG.md](./CHANGELOG.md)
602
+ > 每个版本的详细变更可通过 `git log` 或 [GitHub Releases](https://github.com/xz333221/zen-gitsync/releases) 页面查看。
607
603
 
608
604
  ---
609
605
 
@@ -918,7 +914,7 @@ Activity Bar 中的机器人图标视图,可直接在浏览器中与内置 AI
918
914
 
919
915
  ![用户设置弹窗 — 通用 tab](https://raw.githubusercontent.com/xz333221/zen-gitsync/main/public/images/settings-general.png)
920
916
 
921
- > 点击顶部条右上角齿轮图标。tab 包含 **通用 / Git / 提交 / AI / 主题 / 语言 / 文件锁定 / NPM 路径**。大部分开关即时生效,无需重启 GUI。
917
+ > 点击顶部条右上角齿轮图标。tab 包含 **通用 / Git / 提交 / AI / 主题 / 语言 / 文件锁定 / NPM 路径**。大部分开关即时生效,无需重启 GUI。点击底栏的 **默认模型** 名称可一键定位到「AI 模型」tab
922
918
 
923
919
  | 设置项 | 说明 |
924
920
  |---|---|
@@ -928,6 +924,7 @@ Activity Bar 中的机器人图标视图,可直接在浏览器中与内置 AI
928
924
  | 语言 | 中文 / English |
929
925
  | 文件锁定 | 锁定文件,使其永远不被暂存或储藏 |
930
926
  | NPM 路径 | 配置 `package.json` 的扫描位置 |
927
+ | AI 模型 | 配置 OpenAI / Claude 等兼容 OpenAI 协议的 LLM 端点(API Key、baseURL、模型名),可设置默认模型,多套配置并存 |
931
928
 
932
929
  ---
933
930
 
@@ -966,6 +963,10 @@ $ g ai "修复失败的测试" # 单发模式:执行一轮后退出
966
963
  $ g ai --model=2 # 使用第 2 个已配置的模型(序号或名称)
967
964
  ```
968
965
 
966
+ 启动配置向导与 `/addmodel` 的"服务商 / 模型"列表支持 **↑↓ 键切换 + Enter 确认**(也可
967
+ 直接输入数字跳转,`0` = 列表底部的"自定义 / 手动输入");非 TTY 环境下自动回退为数字输入。
968
+ `Esc` 或 `Ctrl+C` 一键取消整个向导。
969
+
969
970
  会话内命令:`/help`、`/model`、`/addmodel`、`/cd <路径>`、`/image [路径]`、`/think`、`/clear`、`/exit`。
970
971
 
971
972
  图片:在 REPL 中按 `Alt+V` 粘贴剪贴板图片(截图),或用 `/image <路径>` 附加本地图片;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zen-gitsync",
3
- "version": "2.16.10",
3
+ "version": "2.16.12",
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",
@@ -50,10 +50,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
50
50
 
51
51
  // 显式白名单:只有这些文件路径会被 git add。
52
52
  // 禁止 `git add .` 防止把工作区脏文件 / 临时调试文件一起带上 release commit。
53
- // lockfile (package-lock.json / pnpm-lock.yaml) 都在 .gitignore 里,
53
+ // lockfile (package-lock.json) .gitignore 里,
54
54
  // 即便列在白名单也会被 git 拒绝 add,所以这里不列。
55
- // 实际入 release commit 的只有 package.json(版本号)+ CHANGELOG.md
56
- const RELEASE_FILES = ['package.json', 'CHANGELOG.md']
55
+ // 实际入 release commit 的只有 package.json(版本号)。
56
+ const RELEASE_FILES = ['package.json']
57
57
 
58
58
  function createReadlineInterface() {
59
59
  return readline.createInterface({ input: process.stdin, output: process.stdout })
@@ -34,7 +34,7 @@
34
34
  import readline from 'node:readline'
35
35
  import chalk from 'chalk'
36
36
  import config from '../../config.js'
37
- import { startSpinner } from './termui.js'
37
+ import { startSpinner, renderSelectableListBody, parseKeyForSelectableList } from './termui.js'
38
38
 
39
39
  // ──────────────────────────────────────────────
40
40
  // 预设服务商列表
@@ -131,6 +131,7 @@ const STRINGS = {
131
131
  selectProvider: '选择 AI 服务商:',
132
132
  customOption: '自定义',
133
133
  selectPrompt: (min, max) => `请选择 (${min}-${max}): `,
134
+ navHint: '↑↓ 切换,Enter 确认,数字直跳(0 = 上一行)',
134
135
  invalidChoice: (min, max) => `无效选择,请输入 ${min}-${max} 之间的数字`,
135
136
  endpointLabel: '接口地址',
136
137
  endpointPrompt: (def) => `接口地址 [${def}]: `,
@@ -166,6 +167,7 @@ const STRINGS = {
166
167
  selectProvider: 'Select an AI provider:',
167
168
  customOption: 'Custom',
168
169
  selectPrompt: (min, max) => `Choose (${min}-${max}): `,
170
+ navHint: '↑↓ to switch, Enter to confirm, number to jump (0 = extra)',
169
171
  invalidChoice: (min, max) => `Invalid choice, enter a number between ${min} and ${max}`,
170
172
  endpointLabel: 'Endpoint URL',
171
173
  endpointPrompt: (def) => `Endpoint [${def}]: `,
@@ -413,32 +415,201 @@ async function askYesNo(asker, prompt) {
413
415
  }
414
416
 
415
417
  /**
416
- * 让用户从数字列表中选择一项。
417
- * @param {object} asker
418
- * @param {string} title - 列表标题
419
- * @param {Array<{label: string, value: any}>} items
420
- * @param {object} t - i18n strings
421
- * @param {number} [extraOption] - 额外选项的序号(如"手动输入"用 0)
422
- * @param {string} [extraLabel]
423
- * @returns {Promise<{index: number, value: any}|null>} 选中的项,null = 用户选了额外选项
418
+ * 让用户从列表中选择一项。
419
+ *
420
+ * 两条路径:
421
+ * - TTY(终端)且传入了 rl 上下键切换 + Enter 提交(类似 Codex CLI 的列表选择)
422
+ * - 数字键 1..9 / 0 也可直跳(0 = 额外项)
423
+ * - Esc / Ctrl+C 视作取消整个 wizard(调用方按 { cancelled: true } catch 分支)
424
+ * - 其他情况 回退到原数字输入(由 asker.ask 提示,数字 + 回车确认;非法输入不退出,
425
+ * 提示 t.invalidChoice 让用户重新输)
426
+ *
427
+ * 设计要点:
428
+ * - 列表用 renderSelectableListBody 渲染,选中行整行反白(可视化锚点固定,跟斜杠命令
429
+ * 提示一样的"反白起点严格对齐"思路)
430
+ * - 方向键会触发 readline 默认历史浏览,我们用"清空 rl.line + 调 _refreshLine"撤销
431
+ * (参考 agent.js 对斜杠命令提示的处理)
432
+ * - 按 Enter 时 rl.question 回调自然拿到 line 字符串;空字符串 → 用当前 selectedIndex,
433
+ * 数字 → 直接选中,非数字 → 视为无效,保持当前列表继续等待输入(不重画列表)
434
+ *
435
+ * @param {object} opts
436
+ * @param {import('node:readline').Interface} [opts.rl] - 用于挂 keypress + rl.question;
437
+ * 不传或非 TTY 时退回数字输入
438
+ * @param {{ask:(p:string,d?:string)=>Promise<string>}} opts.asker - 数字输入回退路径的 asker;
439
+ * TTY 路径不需要,但参数签名上必须传(避免 collectModelInput 内部拆开两个调用)
440
+ * @param {string} opts.title
441
+ * @param {Array<{label: string, value: any}>} opts.items
442
+ * @param {object} opts.t - i18n strings
443
+ * @param {string} [opts.extraOptionLabel] - 额外项(编号 0)
444
+ * @returns {Promise<{index: number, value: any}|null>}
445
+ * - { index, value }:选中标准项
446
+ * - null:选中额外项
447
+ * - 抛出 { cancelled: true }:用户取消整个 wizard(Ctrl+C / Esc / readline close)
424
448
  */
425
- async function selectFromList(asker, title, items, t, extraOptionLabel) {
449
+ async function selectFromList({ rl, asker, title, items, t, extraOptionLabel } = {}) {
450
+ const safeItems = Array.isArray(items) ? items : []
451
+ const extraActive = !!extraOptionLabel
452
+ const totalItems = safeItems.length + (extraActive ? 1 : 0)
453
+ if (totalItems === 0) return null
454
+
426
455
  console.log()
427
456
  console.log(chalk.bold(title))
457
+
458
+ // 判断走哪条路
459
+ const interactive = !!(rl && rl.input && process.stdout.isTTY && process.stdin.isTTY)
460
+
461
+ if (!interactive) {
462
+ // 非 TTY / 没 rl:走数字输入回退(原行为,保留 CI / 管道环境的可脚本化能力)
463
+ return selectByDigits({ asker, items: safeItems, t, extraOptionLabel })
464
+ }
465
+
466
+ // ── TTY 路径:方向键列表选择 ──
467
+ let selectedIndex = 0
468
+ let finished = false
469
+
470
+ // 渲染初始列表
471
+ const initialBody = renderSelectableListBody(safeItems, 0, extraOptionLabel)
472
+ process.stdout.write(initialBody + '\n')
473
+ const listRows = initialBody.split('\n').length
474
+
475
+ /**
476
+ * 重画列表:用 ANSI 把光标逐行上移 + 清行,然后重新打印,光标回到列表首行行首,
477
+ * 不影响 readline 在 listRows+1 行的 prompt 行。
478
+ * 用 \x1b[2K(清整行)+ \x1b[1A(上移一行)循环 N 次,等价于
479
+ * "清掉光标以上 N 行"但不污染更下面的内容;最后 \r 回到行首方便后续输出起点对齐。
480
+ */
481
+ const redraw = () => {
482
+ for (let i = 0; i < listRows; i++) {
483
+ process.stdout.write('\x1b[1A\x1b[2K')
484
+ }
485
+ process.stdout.write('\r')
486
+ process.stdout.write(renderSelectableListBody(safeItems, selectedIndex, extraOptionLabel) + '\n')
487
+ }
488
+
489
+ /**
490
+ * 撤销 readline 内置行为对 rl.line 的副作用:
491
+ * - 方向键:readline 读 history 一条回填到 rl.line + _refreshLine 重画 prompt
492
+ * 用户本来在 prompt 行没敲任何字符,历史回填会让用户突然看到历史的脏字符。
493
+ * 所以清空 line buffer 让 prompt 行保持空(类似 agent.js 对斜杠命令提示的做法)。
494
+ * - 数字键(jump:N):用户在 prompt 行确实敲了数字,该数字已经在 rl.line 里,
495
+ * 不能撤销(否则用户按 "8" 看不到 "8")— 但目前的 jump 路径用 rl.line.length === 0 守门,
496
+ * 等于已经避免和 readline 的字符输入重叠,所以 prev/next 路径才需要清,数字路径不用清。
497
+ */
498
+ const clearLineBuffer = () => {
499
+ if (!rl) return
500
+ rl.line = ''
501
+ rl.cursor = 0
502
+ if (typeof rl._refreshLine === 'function') {
503
+ try { rl._refreshLine() } catch { /* 兜底 — 某些 readline 版本没有 _refreshLine */ }
504
+ }
505
+ }
506
+
507
+ return new Promise((resolve, reject) => {
508
+ const cleanup = () => {
509
+ if (finished) return
510
+ finished = true
511
+ if (rl && rl.input) rl.input.removeListener('keypress', keyHandler)
512
+ if (rl) rl.removeListener('close', onClose)
513
+ }
514
+ const onClose = () => {
515
+ cleanup()
516
+ reject({ cancelled: true })
517
+ }
518
+
519
+ const keyHandler = (_str, key) => {
520
+ if (finished || !key) return
521
+ const action = parseKeyForSelectableList(key)
522
+ const max = totalItems - 1
523
+
524
+ if (action === 'prev' || action === 'next') {
525
+ if (action === 'prev') {
526
+ selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : max
527
+ } else {
528
+ selectedIndex = selectedIndex < max ? selectedIndex + 1 : 0
529
+ }
530
+ clearLineBuffer() // 撤销 readline 历史回填
531
+ redraw()
532
+ return
533
+ }
534
+
535
+ if (typeof action === 'string' && action.startsWith('jump:')) {
536
+ // 只在用户当前没敲字符时接受 jump,避免和 readline 正常字符输入冲突
537
+ if (rl.line && rl.line.length > 0) return
538
+ const n = Number(action.slice(5))
539
+ let target = -1
540
+ if (n === 0 && extraActive) target = safeItems.length
541
+ else if (n >= 1 && n <= safeItems.length) target = n - 1
542
+ if (target >= 0) {
543
+ selectedIndex = target
544
+ redraw() // 数字已进 rl.line,不清;用户会看到 prompt 行同步显示这个数字
545
+ }
546
+ return
547
+ }
548
+
549
+ if (action === 'cancel') {
550
+ // Esc / Ctrl+C — 让用户取消整个 wizard
551
+ cleanup()
552
+ reject({ cancelled: true })
553
+ return
554
+ }
555
+
556
+ // 'confirm' (Enter) 不处理,让 readline 自然触发 line 事件 → rl.question 回调拿 answer
557
+ // 其他字符(字母/数字但 line buffer 非空)让 readline 自然处理
558
+ }
559
+
560
+ rl.input.on('keypress', keyHandler)
561
+ rl.once('close', onClose)
562
+
563
+ // 提示词:列出可选范围 + 导航提示
564
+ const minIdx = extraActive ? 0 : 1
565
+ const maxIdx = safeItems.length
566
+ const navHint = t.navHint // "↑↓ 切换,Enter 确认,数字直跳"
567
+ const promptStr = `${chalk.cyan(t.selectPrompt(minIdx, maxIdx))}${chalk.dim(navHint ? ' ' + navHint : '')} `
568
+
569
+ rl.question(promptStr, (answer) => {
570
+ cleanup()
571
+ const trimmed = (answer || '').trim()
572
+ let idx = Number.parseInt(trimmed, 10)
573
+ if (Number.isFinite(idx) && idx >= minIdx && idx <= maxIdx) {
574
+ if (idx === 0) return resolve(null)
575
+ return resolve({ index: idx - 1, value: safeItems[idx - 1].value })
576
+ }
577
+ if (trimmed === '') {
578
+ // 空回答:用当前 selectedIndex
579
+ if (selectedIndex >= safeItems.length) return resolve(null)
580
+ return resolve({ index: selectedIndex, value: safeItems[selectedIndex].value })
581
+ }
582
+ // 非法输入:在当前列表下方打印提示,不重画列表(列表还在可见)
583
+ console.log(chalk.yellow(t.invalidChoice(minIdx, maxIdx)))
584
+ resolve('__retry__')
585
+ })
586
+ }).then(async (r) => {
587
+ if (r === '__retry__') {
588
+ // 递归重试(重新调一次)— 列表会再画一遍,实测可接受;保持 wizard 流程的简洁
589
+ return selectFromList({ rl, asker, title, items, t, extraOptionLabel })
590
+ }
591
+ return r
592
+ })
593
+ }
594
+
595
+ /**
596
+ * 数字输入回退路径(原 selectFromList 的实现)。
597
+ * 保留给非 TTY / 无 readline 的场景(管道、CI、测试)。
598
+ */
599
+ async function selectByDigits({ asker, items, t, extraOptionLabel }) {
428
600
  items.forEach((item, i) => {
429
601
  console.log(` ${chalk.cyan(String(i + 1).padStart(2))}. ${item.label}`)
430
602
  })
431
603
  if (extraOptionLabel) {
432
604
  console.log(` ${chalk.dim('0')}. ${chalk.dim(extraOptionLabel)}`)
433
605
  }
434
-
435
606
  const min = extraOptionLabel ? 0 : 1
436
607
  const max = items.length
437
608
  while (true) {
438
609
  const answer = await asker.ask(t.selectPrompt(min, max))
439
610
  const idx = Number.parseInt(answer, 10)
440
611
  if (Number.isFinite(idx) && idx >= min && idx <= max) {
441
- if (idx === 0) return null // 额外选项
612
+ if (idx === 0) return null
442
613
  return { index: idx - 1, value: items[idx - 1].value }
443
614
  }
444
615
  console.log(chalk.yellow(t.invalidChoice(min, max)))
@@ -481,7 +652,13 @@ export async function collectModelInput({ locale = 'zh-CN', rl: injectedRl, fetc
481
652
  label: `${p.label} ${chalk.dim(p.url)}`,
482
653
  value: p,
483
654
  }))
484
- const providerChoice = await selectFromList(asker, t.selectProvider, providerItems, t, t.customOption)
655
+ const providerChoice = await selectFromList({
656
+ rl, asker,
657
+ title: t.selectProvider,
658
+ items: providerItems,
659
+ t,
660
+ extraOptionLabel: t.customOption,
661
+ })
485
662
 
486
663
  let baseURL
487
664
  if (providerChoice) {
@@ -528,7 +705,13 @@ export async function collectModelInput({ locale = 'zh-CN', rl: injectedRl, fetc
528
705
  let model
529
706
  if (modelList.length > 0) {
530
707
  const modelItems = modelList.map(m => ({ label: m, value: m }))
531
- const modelChoice = await selectFromList(asker, t.builtinModels, modelItems, t, t.manualInput)
708
+ const modelChoice = await selectFromList({
709
+ rl, asker,
710
+ title: t.builtinModels,
711
+ items: modelItems,
712
+ t,
713
+ extraOptionLabel: t.manualInput,
714
+ })
532
715
  if (modelChoice) {
533
716
  model = modelChoice.value
534
717
  } else {
@@ -1,97 +1,97 @@
1
- // Copyright 2026 xz333221
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
- //
15
- // CLI 侧智能体会话持久化。
16
- //
17
- // 与 Web 端 (src/ui/server/routes/workbench/agentSessionStore.js) 共享同一数据目录
18
- // (~/.zen-gitsync/agent-sessions/) 和同一 JSON 格式,这样:
19
- // - CLI 对话在 Web UI 的智能体 tab 里可见(带 CLI 标记)
20
- // - Web UI 可以读取/继续 CLI 创建的会话
21
- //
22
- // 本模块刻意不依赖服务器代码,仅用 node 内置 fs/path/os,保持 CLI 自包含。
23
-
24
- import fsp from 'fs/promises';
25
- import path from 'path';
26
- import os from 'os';
27
-
28
- const SESSIONS_DIR = path.join(os.homedir(), '.zen-gitsync', 'agent-sessions');
29
- const MAX_SESSIONS = 200;
30
- const KEEP_SESSIONS = 100;
31
-
32
- /**
33
- * 生成会话 ID: ag-{时间戳base36}-{随机base36}
34
- */
35
- export function genSessionId() {
36
- return `ag-${Date.now().toString(36).slice(-8)}-${Math.random().toString(36).slice(2, 10)}`;
37
- }
38
-
39
- /**
40
- * 从第一条 user 消息自动生成标题(取第一行,截断 40 字符)
41
- */
42
- export function autoTitle(messages) {
43
- for (const m of messages) {
44
- if (m.role === 'user') {
45
- let text = '';
46
- if (typeof m.content === 'string') {
47
- text = m.content;
48
- } else if (Array.isArray(m.content)) {
49
- text = m.content.filter(p => p?.type === 'text').map(p => p.text).join(' ');
50
- }
51
- text = text.trim();
52
- if (text) {
53
- const firstLine = text.split('\n')[0].trim();
54
- return firstLine.length > 40 ? firstLine.slice(0, 40) + '…' : firstLine;
55
- }
56
- }
57
- }
58
- return '(新会话)';
59
- }
60
-
61
- /**
62
- * 写入会话(原子操作: tmp + rename)
63
- */
64
- export async function writeSession(sessionId, data) {
65
- await fsp.mkdir(SESSIONS_DIR, { recursive: true });
66
- const file = path.join(SESSIONS_DIR, `${sessionId}.json`);
67
- const tmp = `${file}.tmp.${process.pid}.${Date.now()}`;
68
- await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
69
- await fsp.rename(tmp, file);
70
- }
71
-
72
- /**
73
- * 读取会话
74
- */
75
- export async function readSession(sessionId) {
76
- const file = path.join(SESSIONS_DIR, `${sessionId}.json`);
77
- return JSON.parse(await fsp.readFile(file, 'utf-8'));
78
- }
79
-
80
- /**
81
- * 保留策略:超过 MAX_SESSIONS 时删最旧的,保留 KEEP_SESSIONS 个
82
- */
83
- export async function enforceRetention() {
84
- const files = await fsp.readdir(SESSIONS_DIR).catch(() => []);
85
- const jsonFiles = files.filter(f => f.endsWith('.json'));
86
- if (jsonFiles.length < MAX_SESSIONS) return;
87
- const stats = await Promise.all(jsonFiles.map(async f => {
88
- const full = path.join(SESSIONS_DIR, f);
89
- const s = await fsp.stat(full);
90
- return { file: full, mtime: s.mtimeMs };
91
- }));
92
- stats.sort((a, b) => b.mtime - a.mtime);
93
- const toDelete = stats.slice(KEEP_SESSIONS);
94
- await Promise.all(toDelete.map(s => fsp.unlink(s.file).catch(() => {})));
95
- }
96
-
97
- export { SESSIONS_DIR };
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // CLI 侧智能体会话持久化。
16
+ //
17
+ // 与 Web 端 (src/ui/server/routes/workbench/agentSessionStore.js) 共享同一数据目录
18
+ // (~/.zen-gitsync/agent-sessions/) 和同一 JSON 格式,这样:
19
+ // - CLI 对话在 Web UI 的智能体 tab 里可见(带 CLI 标记)
20
+ // - Web UI 可以读取/继续 CLI 创建的会话
21
+ //
22
+ // 本模块刻意不依赖服务器代码,仅用 node 内置 fs/path/os,保持 CLI 自包含。
23
+
24
+ import fsp from 'fs/promises';
25
+ import path from 'path';
26
+ import os from 'os';
27
+
28
+ const SESSIONS_DIR = path.join(os.homedir(), '.zen-gitsync', 'agent-sessions');
29
+ const MAX_SESSIONS = 200;
30
+ const KEEP_SESSIONS = 100;
31
+
32
+ /**
33
+ * 生成会话 ID: ag-{时间戳base36}-{随机base36}
34
+ */
35
+ export function genSessionId() {
36
+ return `ag-${Date.now().toString(36).slice(-8)}-${Math.random().toString(36).slice(2, 10)}`;
37
+ }
38
+
39
+ /**
40
+ * 从第一条 user 消息自动生成标题(取第一行,截断 40 字符)
41
+ */
42
+ export function autoTitle(messages) {
43
+ for (const m of messages) {
44
+ if (m.role === 'user') {
45
+ let text = '';
46
+ if (typeof m.content === 'string') {
47
+ text = m.content;
48
+ } else if (Array.isArray(m.content)) {
49
+ text = m.content.filter(p => p?.type === 'text').map(p => p.text).join(' ');
50
+ }
51
+ text = text.trim();
52
+ if (text) {
53
+ const firstLine = text.split('\n')[0].trim();
54
+ return firstLine.length > 40 ? firstLine.slice(0, 40) + '…' : firstLine;
55
+ }
56
+ }
57
+ }
58
+ return '(新会话)';
59
+ }
60
+
61
+ /**
62
+ * 写入会话(原子操作: tmp + rename)
63
+ */
64
+ export async function writeSession(sessionId, data) {
65
+ await fsp.mkdir(SESSIONS_DIR, { recursive: true });
66
+ const file = path.join(SESSIONS_DIR, `${sessionId}.json`);
67
+ const tmp = `${file}.tmp.${process.pid}.${Date.now()}`;
68
+ await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
69
+ await fsp.rename(tmp, file);
70
+ }
71
+
72
+ /**
73
+ * 读取会话
74
+ */
75
+ export async function readSession(sessionId) {
76
+ const file = path.join(SESSIONS_DIR, `${sessionId}.json`);
77
+ return JSON.parse(await fsp.readFile(file, 'utf-8'));
78
+ }
79
+
80
+ /**
81
+ * 保留策略:超过 MAX_SESSIONS 时删最旧的,保留 KEEP_SESSIONS 个
82
+ */
83
+ export async function enforceRetention() {
84
+ const files = await fsp.readdir(SESSIONS_DIR).catch(() => []);
85
+ const jsonFiles = files.filter(f => f.endsWith('.json'));
86
+ if (jsonFiles.length < MAX_SESSIONS) return;
87
+ const stats = await Promise.all(jsonFiles.map(async f => {
88
+ const full = path.join(SESSIONS_DIR, f);
89
+ const s = await fsp.stat(full);
90
+ return { file: full, mtime: s.mtimeMs };
91
+ }));
92
+ stats.sort((a, b) => b.mtime - a.mtime);
93
+ const toDelete = stats.slice(KEEP_SESSIONS);
94
+ await Promise.all(toDelete.map(s => fsp.unlink(s.file).catch(() => {})));
95
+ }
96
+
97
+ export { SESSIONS_DIR };