zen-gitsync 2.16.10 → 2.16.11

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 (65) hide show
  1. package/README.md +9 -0
  2. package/package.json +1 -1
  3. package/src/cli/ai/modelSetup.js +197 -14
  4. package/src/cli/ai/sessionStore.js +97 -97
  5. package/src/cli/ai/termui.js +92 -0
  6. package/src/cli/ai/termui.test.js +131 -0
  7. package/src/ui/public/assets/AgentView-d83DuyQq.js +4 -0
  8. package/src/ui/public/assets/AppVersionBadge-UNabzjNF.js +2 -0
  9. package/src/ui/public/assets/BranchSelector-DUbFSWiQ.js +1 -0
  10. package/src/ui/public/assets/CommitForm-HiRHE42z.js +6 -0
  11. package/src/ui/public/assets/EditorView-BvLP-NL1.js +0 -0
  12. package/src/ui/public/assets/FlowExecutionViewer-6mr8M454.js +1 -0
  13. package/src/ui/public/assets/FlowOrchestrationWorkspace-Cw2ADY-p.js +21 -0
  14. package/src/ui/public/assets/LogList-DLg6RUCd.js +15 -0
  15. package/src/ui/public/assets/MindmapView-BKIsdNvz.js +2 -0
  16. package/src/ui/public/assets/MonitorView-BjjHr8a5.js +1 -0
  17. package/src/ui/public/assets/ProjectStartupButton-C-qI63k1.js +1 -0
  18. package/src/ui/public/assets/RemoteRepoCard-B0q3WipX.js +5 -0
  19. package/src/ui/public/assets/SourceMapView-BZgc-Ylv.js +3 -0
  20. package/src/ui/public/assets/UserInputNode-s3Dfg2Gm.js +1 -0
  21. package/src/ui/public/assets/WorkbenchView-BakrUWDH.js +28 -0
  22. package/src/ui/public/assets/element-plus-C0loJL0J.js +32 -0
  23. package/src/ui/public/assets/element-plus-CXfbWXaJ.css +1 -0
  24. package/src/ui/public/assets/{flow-mindmap-DnyDhwJv.js → flow-mindmap-CGn6wqsF.js} +1 -1
  25. package/src/ui/public/assets/index-Bumd4MSq.css +1 -0
  26. package/src/ui/public/assets/index-Cr5-U-r6.js +82 -0
  27. package/src/ui/public/assets/{monaco-Dje8Rg13.js → monaco-DPQ3OaiL.js} +2 -2
  28. package/src/ui/public/assets/socket-io-B9UuzwTN.js +1 -0
  29. package/src/ui/public/assets/{ts.worker-Dth06zuC.js → ts.worker-METxwbDZ.js} +1 -16
  30. package/src/ui/public/assets/{vendor-DVvq7qtS.css → vendor-C2ZpZPB4.css} +1 -1
  31. package/src/ui/public/assets/{vendor-exjCs1jx.js → vendor-pbWglt5U.js} +26 -67
  32. package/src/ui/public/assets/vue-flow-DRwHh4gM.js +39 -0
  33. package/src/ui/public/index.html +9 -15
  34. package/src/ui/server/index.js +4 -0
  35. package/src/ui/server/routes/workbench/agentChat.js +14 -3
  36. package/src/ui/server/routes/workbench/agentRoutes.js +218 -212
  37. package/src/ui/server/routes/workbench/agentSessionStore.js +173 -173
  38. package/src/ui/public/assets/AgentView-CioAzh-a.js +0 -4
  39. package/src/ui/public/assets/AppVersionBadge-CZ3UbiAu.js +0 -2
  40. package/src/ui/public/assets/BranchSelector-Kwcj7e0Z.js +0 -1
  41. package/src/ui/public/assets/CommitForm-DueeSB7T.js +0 -6
  42. package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +0 -1
  43. package/src/ui/public/assets/CommonDialog-Tlk0Fa7g.js +0 -1
  44. package/src/ui/public/assets/EditorView-B2gm2OW1.js +0 -0
  45. package/src/ui/public/assets/FlowExecutionViewer-CFpiqZ2C.js +0 -1
  46. package/src/ui/public/assets/FlowOrchestrationWorkspace-BRUlRQwp.js +0 -21
  47. package/src/ui/public/assets/LogList-Do3C4BmA.js +0 -15
  48. package/src/ui/public/assets/MindmapView-C1pPUu8W.js +0 -2
  49. package/src/ui/public/assets/MonitorView-C2EyggDk.js +0 -1
  50. package/src/ui/public/assets/ProjectStartupButton-D7mgEsmf.js +0 -1
  51. package/src/ui/public/assets/RemoteRepoCard-D582mbhK.js +0 -5
  52. package/src/ui/public/assets/SourceMapView-gQRnN0Vj.js +0 -3
  53. package/src/ui/public/assets/SvgIcon-CDNsk4-Q.css +0 -1
  54. package/src/ui/public/assets/SvgIcon-Diox29ks.js +0 -1
  55. package/src/ui/public/assets/UserInputNode-CYxFuwuS.js +0 -1
  56. package/src/ui/public/assets/WorkbenchView-CbWSllXy.js +0 -28
  57. package/src/ui/public/assets/_plugin-vue_export-helper-B9cwfrdH.js +0 -15
  58. package/src/ui/public/assets/configStore-B8X980y1.js +0 -1
  59. package/src/ui/public/assets/element-plus-BId3Dnij.js +0 -25
  60. package/src/ui/public/assets/element-plus-D3Dn7nX0.css +0 -1
  61. package/src/ui/public/assets/index--onqtBRQ.css +0 -1
  62. package/src/ui/public/assets/index-DvrM8frM.js +0 -68
  63. package/src/ui/public/assets/socket-io-CGezBd--.js +0 -1
  64. package/src/ui/public/assets/vue-flow-Dj_X_Vr1.js +0 -39
  65. /package/src/ui/public/assets/{dagre-BCXBo6I9.js → dagre-CePNlbK4.js} +0 -0
package/README.md CHANGED
@@ -437,6 +437,11 @@ $ g ai "fix the failing test" # one-shot task, then exit
437
437
  $ g ai --model=2 # pick the 2nd configured model (index or name)
438
438
  ```
439
439
 
440
+ The first-run setup wizard and `/addmodel` provider/model lists support **↑↓ keys to switch
441
+ selection + Enter to confirm** (typing a number also jumps directly; `0` selects the trailing
442
+ "custom / manual input" entry). Non-TTY environments (CI, piped input) automatically fall back
443
+ to numeric input. `Esc` or `Ctrl+C` cancels the wizard cleanly.
444
+
440
445
  In-session commands: `/help`, `/model`, `/addmodel`, `/cd <path>`, `/image [path]`, `/think`, `/clear`, `/exit`.
441
446
 
442
447
  Images: press `Alt+V` in the REPL to paste a clipboard image (screenshot), or attach a
@@ -966,6 +971,10 @@ $ g ai "修复失败的测试" # 单发模式:执行一轮后退出
966
971
  $ g ai --model=2 # 使用第 2 个已配置的模型(序号或名称)
967
972
  ```
968
973
 
974
+ 启动配置向导与 `/addmodel` 的"服务商 / 模型"列表支持 **↑↓ 键切换 + Enter 确认**(也可
975
+ 直接输入数字跳转,`0` = 列表底部的"自定义 / 手动输入");非 TTY 环境下自动回退为数字输入。
976
+ `Esc` 或 `Ctrl+C` 一键取消整个向导。
977
+
969
978
  会话内命令:`/help`、`/model`、`/addmodel`、`/cd <路径>`、`/image [路径]`、`/think`、`/clear`、`/exit`。
970
979
 
971
980
  图片:在 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.11",
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",
@@ -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 };
@@ -193,6 +193,96 @@ export function parseKeyForSlashHint(key) {
193
193
  return null
194
194
  }
195
195
 
196
+ // ──────────────────────────────────────────────────
197
+ // 交互式可选列表(供 modelSetup 等一次性向导使用:上下键切换 + Enter 提交)
198
+ // ──────────────────────────────────────────────────
199
+ //
200
+ // 与 renderSlashHintBody 的差异:
201
+ // - 必须支持 Enter 提交(parseKeyForSlashHint 把它当 'submit',但那个由 readline 默认接管;
202
+ // 可选列表场景下 wizard 自己消费 Enter,不能丢给 readline)
203
+ // - 多支持直接按数字 1..9/0 跳到指定项(给经常用键盘的用户省一趟 ↑↓)
204
+ // - Ctrl+C 在可选列表场景下与 Esc 等价(都视作取消)
205
+ //
206
+ // 渲染/解析都做成纯函数,wizard 内部(modelSetup.selectFromList)负责:
207
+ // - 调用 renderSelectableListBody 把列表写到屏幕
208
+ // - 在 rl.input 上挂 keypress 监听,把 readline 默认行为(历史浏览)回滚掉
209
+ // - 每次 selectedIndex 变化时,重绘列表(用 ANSI 把光标上移、清行、再写)
210
+
211
+ /**
212
+ * 渲染可选项列表 body(纯函数,便于单测)。
213
+ *
214
+ * - 标准项:` [k]. label`(未选中)
215
+ * - 选中项:`▶ [k]. label`(整行反白,含前缀空格,跟下方其它行起点对齐)
216
+ * - 额外项:编号 0,dim 灰;选中时整行反白(与普通选中项视觉一致)
217
+ *
218
+ * @param {Array<{label: string, value: any}>} items
219
+ * @param {number} [selectedIndex=0] - 选中项下标;范围 [0, items.length+extra)
220
+ * 越界回退为 0(默认选第一项,避免空数组报错)
221
+ * @param {string|null} [extraOptionLabel=null] - 额外项(如"自定义" / "手动输入")
222
+ * @returns {string} body 字符串(不含末尾换行 — 由调用方控制)
223
+ */
224
+ export function renderSelectableListBody(items, selectedIndex = 0, extraOptionLabel = null) {
225
+ const safeItems = Array.isArray(items) ? items : []
226
+ const extraActive = !!extraOptionLabel
227
+ const totalLen = safeItems.length + (extraActive ? 1 : 0)
228
+ // selectedIndex 越界时静默回退为 0(首次调用时通常如此)
229
+ if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= totalLen) {
230
+ selectedIndex = 0
231
+ }
232
+ const rows = []
233
+ for (let i = 0; i < safeItems.length; i++) {
234
+ const num = String(i + 1).padStart(2)
235
+ const label = safeItems[i].label
236
+ if (i === selectedIndex) {
237
+ // 整行反白(含 ' ' 缩进) — 起点与未选中行严格对齐,不留左侧白边
238
+ rows.push(chalk.inverse(` ${num}. ${label}`))
239
+ } else {
240
+ rows.push(` ${chalk.cyan(num)}. ${label}`)
241
+ }
242
+ }
243
+ if (extraActive) {
244
+ const extraIdx = safeItems.length
245
+ if (extraIdx === selectedIndex) {
246
+ // extra 选中时也整行反白,这样高亮跨度统一(虽然 label 是 dim,但反白让它可读)
247
+ rows.push(chalk.inverse(` ${chalk.dim('0')}. ${extraOptionLabel}`))
248
+ } else {
249
+ rows.push(` ${chalk.dim('0')}. ${chalk.dim(extraOptionLabel)}`)
250
+ }
251
+ }
252
+ return rows.join('\n')
253
+ }
254
+
255
+ /**
256
+ * 解析 readline keypress 事件,识别"可选列表"专用键。
257
+ * 返回动作字符串(消费方据此改 selectedIndex 或提交);
258
+ * 不识别的键返回 null(让 readline 正常处理 — 但消费方应恢复 line buffer
259
+ * 防止方向键的历史浏览把当前输入覆盖)。
260
+ *
261
+ * - ↑ / Shift+Tab → 'prev'
262
+ * - ↓ → 'next'
263
+ * - Enter → 'confirm'
264
+ * - Esc / Ctrl+C → 'cancel'
265
+ * - 数字 0..9 → 'jump:N'(1-based;0 = extra option,需消费方映射)
266
+ *
267
+ * 与 parseKeyForSlashHint 的差别:
268
+ * - 这里 Enter 直接是 'confirm'——wizard 不会让 readline 默认提交(否则进 line 事件
269
+ * 走 REPL 的命令分发,体验错乱);Esc / Ctrl+C 都是 'cancel';不支持 Tab 补全
270
+ * (列表项不是命令片段,不需要补全语义)
271
+ */
272
+ export function parseKeyForSelectableList(key) {
273
+ if (!key) return null
274
+ const name = key.name
275
+ if (name === 'up') return 'prev'
276
+ if (name === 'tab') return key.shift ? 'prev' : 'next'
277
+ if (name === 'down') return 'next'
278
+ if (name === 'return' || name === 'enter') return 'confirm'
279
+ if (name === 'escape') return 'cancel'
280
+ if (key.ctrl && name === 'c') return 'cancel'
281
+ // 数字键 0..9:readline keypress 事件的 key.name 会是该字符
282
+ if (typeof name === 'string' && /^[0-9]$/.test(name)) return `jump:${name}`
283
+ return null
284
+ }
285
+
196
286
  /** 盒式帮助面板 */
197
287
  export function printHelpPanel(title, lines) {
198
288
  const body = [chalk.bold(title), ...lines].join('\n')
@@ -536,6 +626,8 @@ export default {
536
626
  filterSlashCommands,
537
627
  renderSlashHintBody,
538
628
  parseKeyForSlashHint,
629
+ renderSelectableListBody,
630
+ parseKeyForSelectableList,
539
631
  drawInputTop,
540
632
  drawInputBottom,
541
633
  inputBottomBorder,