zen-gitsync 2.16.16 → 2.16.17

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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/cli/ai/agent.js +157 -36
  3. package/src/cli/ai/modelSetup.js +48 -8
  4. package/src/cli/ai/modelSetup.test.js +61 -17
  5. package/src/cli/ai/sessionStore.js +24 -0
  6. package/src/cli/ai/sessionStore.test.js +35 -0
  7. package/src/cli/ai/termui.js +32 -28
  8. package/src/cli/ai/termui.test.js +22 -28
  9. package/src/ui/public/assets/AgentView-BPKmqqZl.js +4 -0
  10. package/src/ui/public/assets/{AppVersionBadge-CDoN_glL.js → AppVersionBadge-CFOXkBPL.js} +2 -2
  11. package/src/ui/public/assets/{BranchSelector-gboCD3tv.js → BranchSelector-Wchf8YFj.js} +1 -1
  12. package/src/ui/public/assets/{CommitForm-CJgh5GcK.js → CommitForm-BG5mmgRC.js} +1 -1
  13. package/src/ui/public/assets/{CommonDialog-BDZCLepZ.js → CommonDialog-JM-MxEwb.js} +1 -1
  14. package/src/ui/public/assets/{EditorView-Dyr7vMRf.js → EditorView-CMN-uriB.js} +0 -0
  15. package/src/ui/public/assets/{FlowExecutionViewer-CU9-dz9w.js → FlowExecutionViewer-CFbWKv24.js} +1 -1
  16. package/src/ui/public/assets/{FlowOrchestrationWorkspace-c43AA7LF.js → FlowOrchestrationWorkspace-BDKwODIH.js} +1 -1
  17. package/src/ui/public/assets/{LogList-iCDptIPg.js → LogList-CSc-ZNzP.js} +1 -1
  18. package/src/ui/public/assets/{MindmapView-CQjUAlgQ.js → MindmapView-BqekPzIe.js} +1 -1
  19. package/src/ui/public/assets/{MonitorView-iYl_sijH.js → MonitorView-C1HenUy5.js} +1 -1
  20. package/src/ui/public/assets/{ProjectStartupButton-DkT5mET4.js → ProjectStartupButton-D-8X6yBa.js} +1 -1
  21. package/src/ui/public/assets/{RemoteRepoCard-CGsDr1KU.js → RemoteRepoCard-D5JvNYVx.js} +1 -1
  22. package/src/ui/public/assets/{SourceMapView-DEZyzSlQ.js → SourceMapView-DqKXPsx0.js} +1 -1
  23. package/src/ui/public/assets/{SvgIcon-B9e7SPKv.js → SvgIcon-BI4SErO0.js} +1 -1
  24. package/src/ui/public/assets/{UserInputNode-BR7SDL9I.js → UserInputNode-BMlBt2DM.js} +1 -1
  25. package/src/ui/public/assets/WorkbenchView-KwZ2PyW6.js +12 -0
  26. package/src/ui/public/assets/{_plugin-vue_export-helper-D38CJ5Mo.js → _plugin-vue_export-helper-CSWqbJLa.js} +1 -1
  27. package/src/ui/public/assets/{configStore-bI1LM_pM.js → configStore-DhoZfUJ9.js} +1 -1
  28. package/src/ui/public/assets/{index-vrSVIcKF.js → index-DDjvX04t.js} +6 -6
  29. package/src/ui/public/assets/vendor-C2ZpZPB4.css +1 -0
  30. package/src/ui/public/assets/{vendor-Dx-XcZ4p.js → vendor-Ifx2s0GZ.js} +38 -35
  31. package/src/ui/public/index.html +7 -7
  32. package/src/ui/public/assets/AgentView-BDvLuOtp.js +0 -4
  33. package/src/ui/public/assets/WorkbenchView-DS3ZcaUd.js +0 -12
  34. package/src/ui/public/assets/ai-chat-ui-86nZ2Zbh.js +0 -45
  35. package/src/ui/public/assets/ai-chat-ui-BwlJovCq.css +0 -1
  36. package/src/ui/public/assets/vendor-CdOLCu7-.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zen-gitsync",
3
- "version": "2.16.16",
3
+ "version": "2.16.17",
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",
@@ -56,8 +56,8 @@ import {
56
56
  printOk, printWarn, printError, printDim,
57
57
  } from './termui.js'
58
58
  import { readClipboardImage, checkImageFile, imageToDataUrl, formatBytes } from './images.js'
59
- import { runModelSetup, collectModelInput, buildModelConfig } from './modelSetup.js'
60
- import { genSessionId, autoTitle, writeSession, enforceRetention } from './sessionStore.js'
59
+ import { runModelSetup, collectModelInput, buildModelConfig, selectFromList } from './modelSetup.js'
60
+ import { genSessionId, autoTitle, writeSession, enforceRetention, listSessions } from './sessionStore.js'
61
61
 
62
62
  // truncateDisplay 已迁移到 termui.js;这里 re-export 保持既有测试/外部引用不断
63
63
  export { truncateDisplay } from './termui.js'
@@ -83,6 +83,8 @@ const STRINGS = {
83
83
  busy: '智能体正在执行中,请稍候(Ctrl+C 结束会话)…',
84
84
  bye: '已退出 g ai',
85
85
  cleared: '对话历史已清空',
86
+ newConversation: '已开启新对话',
87
+ operationCancelled: '已取消当前操作',
86
88
  thinkOn: '思考过程显示: 开',
87
89
  thinkOff: '思考过程显示: 关(模型仍会思考,只是不回显)',
88
90
  modelSwitched: (label) => `已切换模型: ${label}`,
@@ -112,6 +114,10 @@ const STRINGS = {
112
114
  addModelSaved: (name) => `✓ 模型 "${name}" 已添加并切换为当前模型`,
113
115
  addModelCancelled: '已取消添加模型',
114
116
  addModelSaveError: (msg) => `保存配置失败: ${msg}`,
117
+ resumeTitle: '选择要恢复的对话',
118
+ resumeEmpty: '没有可恢复的历史对话',
119
+ resumeCancelled: '已取消恢复对话',
120
+ resumeDone: (title, count) => `已恢复对话: ${title} (${count} 条消息)`,
115
121
  },
116
122
  en: {
117
123
  noModel: 'No AI model configured. Add one in GUI settings first (run `g ui`).',
@@ -123,6 +129,8 @@ const STRINGS = {
123
129
  busy: 'Agent is working, please wait (Ctrl+C to end session)…',
124
130
  bye: 'Bye',
125
131
  cleared: 'Conversation cleared',
132
+ newConversation: 'Started a new conversation',
133
+ operationCancelled: 'Current operation cancelled',
126
134
  thinkOn: 'Thinking display: on',
127
135
  thinkOff: 'Thinking display: off (model still thinks, just hidden)',
128
136
  modelSwitched: (label) => `Switched model: ${label}`,
@@ -152,6 +160,10 @@ const STRINGS = {
152
160
  addModelSaved: (name) => `✓ Model "${name}" added and switched to`,
153
161
  addModelCancelled: 'Add model cancelled',
154
162
  addModelSaveError: (msg) => `Failed to save: ${msg}`,
163
+ resumeTitle: 'Choose a conversation to resume',
164
+ resumeEmpty: 'No previous conversations to resume',
165
+ resumeCancelled: 'Resume cancelled',
166
+ resumeDone: (title, count) => `Resumed: ${title} (${count} messages)`,
155
167
  },
156
168
  }
157
169
 
@@ -590,6 +602,8 @@ function printSlashHelp(t, locale) {
590
602
  ' /cd <路径> 切换智能体工作目录',
591
603
  ' /image [路径] 查看待发送图片;/image <路径> 附加本地图片;/image clear 清除',
592
604
  ' /think 开关思考过程显示',
605
+ ' /new 开启新对话',
606
+ ' /resume 选择并恢复之前的对话',
593
607
  ' /clear 清空对话历史',
594
608
  ' /exit, /quit 退出',
595
609
  '',
@@ -604,6 +618,8 @@ function printSlashHelp(t, locale) {
604
618
  ' /cd <path> Change agent working directory',
605
619
  ' /image [path] List pending images; attach a file; /image clear to reset',
606
620
  ' /think Toggle thinking display',
621
+ ' /new Start a new conversation',
622
+ ' /resume Choose and resume a previous conversation',
607
623
  ' /clear Clear conversation history',
608
624
  ' /exit, /quit Quit',
609
625
  '',
@@ -621,12 +637,86 @@ async function handleSlashCommand(state, input, t) {
621
637
 
622
638
  if (cmd === '/exit' || cmd === '/quit') return 'exit'
623
639
  if (cmd === '/help') { printSlashHelp(t, state.locale); return 'ok' }
624
- if (cmd === '/clear') {
640
+ if (cmd === '/clear' || cmd === '/new') {
625
641
  state.messages = [state.messages[0]]
626
642
  // 开新会话 ID,旧会话保留在磁盘上
627
643
  state.sessionId = genSessionId()
628
644
  state.sessionCreatedAt = new Date().toISOString()
629
- printOk(t.cleared)
645
+ printOk(cmd === '/new' ? t.newConversation : t.cleared)
646
+ return 'ok'
647
+ }
648
+ if (cmd === '/resume') {
649
+ if (state.busy) {
650
+ printDim(t.busy)
651
+ return 'ok'
652
+ }
653
+ const sessions = (await listSessions())
654
+ .filter(session => session.sessionId !== state.sessionId && session.messages.length > 1)
655
+ if (sessions.length === 0) {
656
+ printDim(t.resumeEmpty)
657
+ return 'ok'
658
+ }
659
+
660
+ const zh = !String(state.locale || '').startsWith('en')
661
+ const cleanLabel = (value, fallback) => String(value || fallback).replace(/[\r\n\x00-\x1f\x7f]/g, ' ').trim()
662
+ const items = sessions.map((session) => {
663
+ const title = cleanLabel(session.title, zh ? '(未命名对话)' : '(untitled)')
664
+ const updated = new Date(session.updatedAt || session.createdAt || 0)
665
+ const date = Number.isNaN(updated.getTime()) ? '' : updated.toLocaleString(state.locale)
666
+ const count = session.messages.filter(message => message?.role !== 'system').length
667
+ return {
668
+ label: `${title} ${chalk.dim(`${date} · ${count} ${zh ? '条消息' : 'messages'}`)}`,
669
+ value: session,
670
+ }
671
+ })
672
+ const selectStrings = {
673
+ navHint: zh ? '↑↓ 切换,Enter 确认' : '↑↓ navigate, Enter confirm',
674
+ selectPrompt: (min, max) => zh ? `请选择(${min}-${max}):` : `Choose (${min}-${max}):`,
675
+ invalidChoice: (min, max) => zh ? `请输入 ${min}-${max} 之间的序号` : `Enter a number from ${min} to ${max}`,
676
+ }
677
+ const asker = {
678
+ ask(prompt) {
679
+ return new Promise(resolve => state.rl.question(`${prompt} `, answer => resolve(String(answer || '').trim())))
680
+ },
681
+ }
682
+
683
+ state.inWizard = true
684
+ try {
685
+ const selected = await selectFromList({
686
+ rl: state.rl,
687
+ asker,
688
+ title: t.resumeTitle,
689
+ items,
690
+ t: selectStrings,
691
+ })
692
+ if (!selected?.value) return 'ok'
693
+
694
+ const session = selected.value
695
+ const { promises: fsp } = await import('node:fs')
696
+ if (session.cwd) {
697
+ try {
698
+ const stat = await fsp.stat(session.cwd)
699
+ if (stat.isDirectory()) state.ctx.cwd = session.cwd
700
+ } catch { /* 已删除的旧目录不阻断恢复,沿用当前目录 */ }
701
+ }
702
+ state.sessionId = session.sessionId
703
+ state.sessionCreatedAt = session.createdAt || new Date().toISOString()
704
+ state.messages = session.messages.map(message => ({ ...message }))
705
+ const systemMessage = {
706
+ role: 'system',
707
+ content: buildSystemPrompt({ cwd: state.ctx.cwd, locale: state.locale, shellDesc: state.shellDesc }),
708
+ }
709
+ if (state.messages[0]?.role === 'system') state.messages[0] = systemMessage
710
+ else state.messages.unshift(systemMessage)
711
+ state.pendingImages = []
712
+ const count = state.messages.filter(message => message?.role !== 'system').length
713
+ printOk(t.resumeDone(cleanLabel(session.title, session.sessionId), count))
714
+ } catch (err) {
715
+ if (err?.cancelled) printDim(t.resumeCancelled)
716
+ else printError(err.message)
717
+ } finally {
718
+ state.inWizard = false
719
+ }
630
720
  return 'ok'
631
721
  }
632
722
  if (cmd === '/think') {
@@ -809,6 +899,7 @@ export async function runAiAgent(argv = []) {
809
899
  shellDesc,
810
900
  currentChild: null,
811
901
  abortController: null,
902
+ cancelRequested: false,
812
903
  busy: false,
813
904
  showThinking: true, // /think 切换:是否回显模型的思考过程
814
905
  pendingImages: [], // Alt+V / /image 附加的待发送图片 [{path, bytes}]
@@ -933,7 +1024,7 @@ export async function runAiAgent(argv = []) {
933
1024
  if (!state.busy) safeShowPrompt()
934
1025
  }
935
1026
 
936
- // ── 斜杠命令即时提示(支持 ↑↓ 切换 / Tab 补全 / Enter 提交) ──
1027
+ // ── 斜杠命令即时提示(支持 ↑↓ 切换 / Tab、Enter 补全) ──
937
1028
  // 在提示符下方浮现匹配到的命令,随输入过滤;不移动光标(用 DECSC/DECRC 保存-恢复)。
938
1029
  // slashHintRows 记录当前提示占了几行,下次刷新/提交时据此擦除,避免残影。
939
1030
  // slashHintBaseLine:本次 hint 显示时"输入行的基线值" — ↑↓ 时 readline 已经把
@@ -953,10 +1044,20 @@ export async function runAiAgent(argv = []) {
953
1044
  slashHintRows = 0
954
1045
  }
955
1046
  const drawSlashHint = () => {
956
- const body = renderSlashHintBody(slashHintMatches, slashHintIndex)
1047
+ const body = renderSlashHintBody(slashHintMatches, slashHintIndex, state.locale)
1048
+ const hadVisibleHint = slashHintRows > 0
957
1049
  eraseSlashHint()
958
1050
  if (!body) return
959
1051
  const rows = body.split('\n')
1052
+ if (!hadVisibleHint) {
1053
+ // CSI B(光标下移)到达终端底部后只会停在最后一行,不会自动滚屏,
1054
+ // 所以候选项会被裁掉。第一次显示提示时先用换行真实预留空间,
1055
+ // 再上移相同行数回到输入行;无论中途是否发生滚屏,都能回到原内容所在行。
1056
+ let reserve = ''
1057
+ for (let i = 0; i < rows.length; i++) reserve += '\n\x1b[2K'
1058
+ reserve += `\x1b[${rows.length}A`
1059
+ process.stdout.write(reserve)
1060
+ }
960
1061
  // 保存光标,逐行下移打印(每行先清行防止与旧内容叠字),最后恢复光标回输入行
961
1062
  let seq = '\x1b7'
962
1063
  for (const r of rows) seq += '\x1b[1B\x1b[2K\r' + r
@@ -986,10 +1087,8 @@ export async function runAiAgent(argv = []) {
986
1087
  rl.cursor = slashHintBaseLine.length
987
1088
  if (typeof rl._refreshLine === 'function') rl._refreshLine()
988
1089
  }
989
- // Tab / Enter:把 rl.line 替换为补全文本 + 清掉 hint 状态
990
- // Tab:补全文本末尾加空格,便于用户继续打参数(不回车)
991
- // Enter:不用此函数 — 用 state.slashHintSubmitLine 标志 + line handler 替换
992
- // (readline 自己处理 Enter,我们不能事后改 rl.line 触发提交)
1090
+ // Tab / Enter:把 rl.line 替换为补全文本 + 清掉 hint 状态。
1091
+ // Enter 会先被 readline 提交,因此由下方 line handler 在处理命令前调用本函数。
993
1092
  const applyHintCompletion = (newLine) => {
994
1093
  rl.line = newLine
995
1094
  rl.cursor = newLine.length
@@ -1001,6 +1100,47 @@ export async function runAiAgent(argv = []) {
1001
1100
  if (typeof rl._refreshLine === 'function') rl._refreshLine()
1002
1101
  }
1003
1102
 
1103
+ const neutralizeKeyForReadline = (key) => {
1104
+ key.name = 'escape'
1105
+ key.sequence = ''
1106
+ key.ctrl = false
1107
+ key.meta = false
1108
+ key.shift = false
1109
+ }
1110
+
1111
+ // readline 自己会优先处理 Enter/Ctrl+C。用 prependListener 在它之前完成补全
1112
+ // 或取消当前操作,再把本次按键变成 readline 会忽略的 Esc。
1113
+ const interceptInteractiveControlKeys = (_ch, key) => {
1114
+ if (!key) return
1115
+ if (key.ctrl && key.name === 'c') {
1116
+ if (state.inWizard) {
1117
+ neutralizeKeyForReadline(key)
1118
+ rl.emit('operationCancel')
1119
+ return
1120
+ }
1121
+ if (state.busy) {
1122
+ neutralizeKeyForReadline(key)
1123
+ state.cancelRequested = true
1124
+ try { state.abortController?.abort() } catch (_) {}
1125
+ try { state.currentChild?.kill('SIGTERM') } catch (_) {}
1126
+ return
1127
+ }
1128
+ if (slashHintRows > 0) {
1129
+ neutralizeKeyForReadline(key)
1130
+ applyHintCompletion('')
1131
+ return
1132
+ }
1133
+ return
1134
+ }
1135
+ if (key.name !== 'return' && key.name !== 'enter') return
1136
+ if (slashHintRows === 0 || slashHintMatches.length === 0 || state.busy || state.inWizard) return
1137
+ const selected = slashHintMatches[slashHintIndex]
1138
+ if (!selected) return
1139
+ applyHintCompletion(selected.cmd + ' ')
1140
+ neutralizeKeyForReadline(key)
1141
+ }
1142
+ rl.input.prependListener('keypress', interceptInteractiveControlKeys)
1143
+
1004
1144
  // Alt+V 粘贴剪贴板图片(node 把 ESC+v 解析为 meta+v;部分终端不发 Alt,可用 /image 兜底)
1005
1145
  const pasteFromClipboard = async () => {
1006
1146
  if (state.pasting) return
@@ -1028,7 +1168,7 @@ export async function runAiAgent(argv = []) {
1028
1168
  // 仅在 hint 已显示且有匹配项时拦截,readline 的 default 行为(历史浏览等)
1029
1169
  // 我们在 handler 里事后回滚 rl.line 来抵消
1030
1170
  const action = parseKeyForSlashHint(key)
1031
- const hintActive = action === 'prev' || action === 'next' || action === 'complete' || action === 'submit' || action === 'cancel'
1171
+ const hintActive = action === 'prev' || action === 'next' || action === 'complete' || action === 'cancel'
1032
1172
  ? (slashHintRows > 0 && slashHintMatches.length > 0 && !state.busy && !state.inWizard)
1033
1173
  : false
1034
1174
 
@@ -1050,29 +1190,6 @@ export async function runAiAgent(argv = []) {
1050
1190
  applyHintCompletion(newLine)
1051
1191
  return
1052
1192
  }
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
1193
  if (action === 'cancel') {
1077
1194
  // Esc:只清 hint,不动输入行
1078
1195
  eraseSlashHint()
@@ -1082,7 +1199,7 @@ export async function runAiAgent(argv = []) {
1082
1199
  }
1083
1200
  }
1084
1201
 
1085
- // submit(Enter)走默认 readline 提交,line 事件里统一擦除 hint;
1202
+ // Enter 走默认 readline 提交,line 事件里把选中项恢复到输入框;
1086
1203
  // 其他普通按键:重算匹配 + 重画 hint
1087
1204
  if (!(key.name === 'return' || key.name === 'enter')) {
1088
1205
  refreshSlashHint()
@@ -1130,21 +1247,25 @@ export async function runAiAgent(argv = []) {
1130
1247
  if (images.length > 0) printDim(t.imageSending(images.length))
1131
1248
 
1132
1249
  state.busy = true
1250
+ state.cancelRequested = false
1133
1251
  state.abortController = new AbortController()
1134
1252
  try {
1135
1253
  await runAgentTurn(state, input, t, images)
1136
1254
  // 每轮对话结束后持久化到磁盘,供 Web UI 读取
1137
1255
  await persistSession()
1138
1256
  } catch (err) {
1139
- printError(err.message)
1257
+ if (state.cancelRequested) printDim(t.operationCancelled)
1258
+ else printError(err.message)
1140
1259
  } finally {
1141
1260
  state.busy = false
1261
+ state.cancelRequested = false
1142
1262
  state.abortController = null
1143
1263
  safeShowPrompt()
1144
1264
  }
1145
1265
  })
1146
1266
 
1147
1267
  rl.on('close', () => {
1268
+ rl.input.removeListener('keypress', interceptInteractiveControlKeys)
1148
1269
  printWarn('\n' + t.bye)
1149
1270
  })
1150
1271
 
@@ -384,10 +384,44 @@ function createAsker(rl) {
384
384
  */
385
385
  function ask(prompt, defaultValue) {
386
386
  return new Promise((resolve, reject) => {
387
+ let settled = false
388
+ const controller = new AbortController()
387
389
  const display = defaultValue != null && defaultValue !== ''
388
390
  ? chalk.cyan(prompt) + chalk.dim(` (${defaultValue})`)
389
391
  : chalk.cyan(prompt)
390
- rl.question(display + ' ', (answer) => {
392
+
393
+ const cleanup = () => {
394
+ rl.removeListener?.('close', onClose)
395
+ rl.removeListener?.('operationCancel', onCancel)
396
+ rl.input?.removeListener?.('keypress', onKeypressCancel)
397
+ }
398
+ const cancel = () => {
399
+ if (settled) return
400
+ settled = true
401
+ controller.abort()
402
+ cleanup()
403
+ reject({ cancelled: true })
404
+ }
405
+ const onClose = () => cancel()
406
+ const onCancel = () => cancel()
407
+ const onKeypressCancel = (_str, key) => {
408
+ if (!key?.ctrl || key.name !== 'c') return
409
+ // 必须在 readline 内置 listener 前消费,否则它会直接关闭整个 REPL。
410
+ key.name = 'escape'
411
+ key.sequence = ''
412
+ key.ctrl = false
413
+ key.meta = false
414
+ key.shift = false
415
+ cancel()
416
+ }
417
+ rl.once('close', onClose)
418
+ rl.once('operationCancel', onCancel)
419
+ rl.input?.prependListener?.('keypress', onKeypressCancel)
420
+
421
+ rl.question(display + ' ', { signal: controller.signal }, (answer) => {
422
+ if (settled) return
423
+ settled = true
424
+ cleanup()
391
425
  const trimmed = (answer || '').trim()
392
426
  if (trimmed === '' && defaultValue != null && defaultValue !== '') {
393
427
  resolve(defaultValue)
@@ -395,10 +429,6 @@ function createAsker(rl) {
395
429
  resolve(trimmed)
396
430
  }
397
431
  })
398
- // Ctrl+C / Ctrl+D: 用户取消
399
- rl.once('close', () => {
400
- reject({ cancelled: true })
401
- })
402
432
  })
403
433
  }
404
434
  return { ask }
@@ -446,7 +476,7 @@ async function askYesNo(asker, prompt) {
446
476
  * - null:选中额外项
447
477
  * - 抛出 { cancelled: true }:用户取消整个 wizard(Ctrl+C / Esc / readline close)
448
478
  */
449
- async function selectFromList({ rl, asker, title, items, t, extraOptionLabel } = {}) {
479
+ export async function selectFromList({ rl, asker, title, items, t, extraOptionLabel } = {}) {
450
480
  const safeItems = Array.isArray(items) ? items : []
451
481
  const extraActive = !!extraOptionLabel
452
482
  const totalItems = safeItems.length + (extraActive ? 1 : 0)
@@ -504,10 +534,12 @@ async function selectFromList({ rl, asker, title, items, t, extraOptionLabel } =
504
534
  }
505
535
  }
506
536
 
537
+ const questionController = new AbortController()
507
538
  return new Promise((resolve, reject) => {
508
539
  const cleanup = () => {
509
540
  if (finished) return
510
541
  finished = true
542
+ questionController.abort()
511
543
  if (rl && rl.input) rl.input.removeListener('keypress', keyHandler)
512
544
  if (rl) rl.removeListener('close', onClose)
513
545
  }
@@ -548,6 +580,13 @@ async function selectFromList({ rl, asker, title, items, t, extraOptionLabel } =
548
580
 
549
581
  if (action === 'cancel') {
550
582
  // Esc / Ctrl+C — 让用户取消整个 wizard
583
+ if (key.ctrl && key.name === 'c') {
584
+ key.name = 'escape'
585
+ key.sequence = ''
586
+ key.ctrl = false
587
+ key.meta = false
588
+ key.shift = false
589
+ }
551
590
  cleanup()
552
591
  reject({ cancelled: true })
553
592
  return
@@ -557,7 +596,8 @@ async function selectFromList({ rl, asker, title, items, t, extraOptionLabel } =
557
596
  // 其他字符(字母/数字但 line buffer 非空)让 readline 自然处理
558
597
  }
559
598
 
560
- rl.input.on('keypress', keyHandler)
599
+ // 必须先于 readline 内置 listener,这样 Ctrl+C 才是取消向导而不是关闭 REPL。
600
+ rl.input.prependListener('keypress', keyHandler)
561
601
  rl.once('close', onClose)
562
602
 
563
603
  // 提示词:列出可选范围 + 导航提示
@@ -566,7 +606,7 @@ async function selectFromList({ rl, asker, title, items, t, extraOptionLabel } =
566
606
  const navHint = t.navHint // "↑↓ 切换,Enter 确认,数字直跳"
567
607
  const promptStr = `${chalk.cyan(t.selectPrompt(minIdx, maxIdx))}${chalk.dim(navHint ? ' ' + navHint : '')} `
568
608
 
569
- rl.question(promptStr, (answer) => {
609
+ rl.question(promptStr, { signal: questionController.signal }, (answer) => {
570
610
  cleanup()
571
611
  const trimmed = (answer || '').trim()
572
612
  let idx = Number.parseInt(trimmed, 10)
@@ -17,6 +17,7 @@
17
17
  // buildModelConfig / testModelConnection(注入 mock fetch)。
18
18
  import { test } from 'node:test'
19
19
  import assert from 'node:assert/strict'
20
+ import { EventEmitter } from 'node:events'
20
21
  import {
21
22
  getBuiltinModels,
22
23
  findProviderByUrl,
@@ -371,26 +372,36 @@ test('BUILTIN_MODELS: 每个 key 都能在 PROVIDERS 中找到', () => {
371
372
  * @param {object} [opts]
372
373
  * @param {number} [opts.closeOnQuestion=-1] - 在第 N 次 question 时触发 close 事件(模拟取消),-1=不触发
373
374
  */
374
- function createMockRl(answers, { closeOnQuestion = -1 } = {}) {
375
+ function createMockRl(answers, { closeOnQuestion = -1, cancelOnQuestion = -1, ctrlCOnQuestion = -1 } = {}) {
375
376
  const queue = [...answers]
376
- const closeHandlers = []
377
377
  let qNum = 0
378
- return {
379
- question(_prompt, cb) {
380
- qNum++
381
- if (qNum === closeOnQuestion) {
382
- // 先让 once('close') 注册,再在 nextTick 触发(模拟用户 Ctrl+D)
383
- process.nextTick(() => { for (const h of closeHandlers) h() })
384
- return
385
- }
386
- const answer = queue.shift() ?? ''
387
- process.nextTick(() => cb(answer))
388
- },
389
- once(event, cb) {
390
- if (event === 'close') closeHandlers.push(cb)
391
- },
392
- close() {},
378
+ const rl = new EventEmitter()
379
+ rl.input = new EventEmitter()
380
+ rl.closed = false
381
+ rl.question = (_prompt, options, callback) => {
382
+ const cb = typeof options === 'function' ? options : callback
383
+ qNum++
384
+ if (qNum === closeOnQuestion) {
385
+ process.nextTick(() => { rl.closed = true; rl.emit('close') })
386
+ return
387
+ }
388
+ if (qNum === cancelOnQuestion) {
389
+ process.nextTick(() => rl.emit('operationCancel'))
390
+ return
391
+ }
392
+ if (qNum === ctrlCOnQuestion) {
393
+ process.nextTick(() => {
394
+ const key = { name: 'c', sequence: '\x03', ctrl: true, meta: false, shift: false }
395
+ rl.input.emit('keypress', '\x03', key)
396
+ rl.lastCtrlCKey = key
397
+ })
398
+ return
399
+ }
400
+ const answer = queue.shift() ?? ''
401
+ process.nextTick(() => cb(answer))
393
402
  }
403
+ rl.close = () => { rl.closed = true; rl.emit('close') }
404
+ return rl
394
405
  }
395
406
 
396
407
  /** 静默 console.log/error/warn,避免交互式测试刷屏;返回 restore 函数 */
@@ -495,6 +506,39 @@ test('collectModelInput: 用户取消(触发 close)→ 返回 null', async () =>
495
506
  }
496
507
  })
497
508
 
509
+ test('collectModelInput: operationCancel 取消当前问题但不关闭 readline', async () => {
510
+ const restore = silenceConsole()
511
+ try {
512
+ const rl = createMockRl([], { cancelOnQuestion: 1 })
513
+ const result = await collectModelInput({
514
+ locale: 'zh-CN',
515
+ rl,
516
+ fetchFn: mockFetch({ status: 200, ok: true }),
517
+ })
518
+ assert.equal(result, null)
519
+ assert.equal(rl.closed, false, '取消向导后 REPL readline 应继续可用')
520
+ } finally {
521
+ restore()
522
+ }
523
+ })
524
+
525
+ test('collectModelInput: Ctrl+C 只取消当前问题并阻止 readline 关闭', async () => {
526
+ const restore = silenceConsole()
527
+ try {
528
+ const rl = createMockRl([], { ctrlCOnQuestion: 1 })
529
+ const result = await collectModelInput({
530
+ locale: 'zh-CN',
531
+ rl,
532
+ fetchFn: mockFetch({ status: 200, ok: true }),
533
+ })
534
+ assert.equal(result, null)
535
+ assert.equal(rl.closed, false)
536
+ assert.equal(rl.lastCtrlCKey.name, 'escape', 'Ctrl+C 应在 readline 处理前被中和')
537
+ } finally {
538
+ restore()
539
+ }
540
+ })
541
+
498
542
  test('collectModelInput: API 返回模型列表时优先使用 → 返回收集的字段', async () => {
499
543
  const restore = silenceConsole()
500
544
  try {
@@ -77,6 +77,30 @@ export async function readSession(sessionId) {
77
77
  return JSON.parse(await fsp.readFile(file, 'utf-8'));
78
78
  }
79
79
 
80
+ /**
81
+ * 列出可恢复的会话,按最近更新时间倒序排列。
82
+ * 单个损坏的 JSON 不应阻断整个列表,因此会静默跳过。
83
+ */
84
+ export async function listSessions(directory = SESSIONS_DIR) {
85
+ const files = await fsp.readdir(directory).catch(() => []);
86
+ const sessions = await Promise.all(
87
+ files.filter(file => file.endsWith('.json')).map(async (file) => {
88
+ try {
89
+ const raw = await fsp.readFile(path.join(directory, file), 'utf-8');
90
+ const data = JSON.parse(raw);
91
+ if (!data || typeof data !== 'object' || !Array.isArray(data.messages)) return null;
92
+ const sessionId = file.slice(0, -'.json'.length);
93
+ return { ...data, sessionId };
94
+ } catch {
95
+ return null;
96
+ }
97
+ }),
98
+ );
99
+ return sessions
100
+ .filter(Boolean)
101
+ .sort((a, b) => Date.parse(b.updatedAt || b.createdAt || 0) - Date.parse(a.updatedAt || a.createdAt || 0));
102
+ }
103
+
80
104
  /**
81
105
  * 保留策略:超过 MAX_SESSIONS 时删最旧的,保留 KEEP_SESSIONS 个
82
106
  */
@@ -0,0 +1,35 @@
1
+ // Copyright 2026 xz333221
2
+ // Licensed under the Apache License, Version 2.0
3
+
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import fsp from 'node:fs/promises'
7
+ import os from 'node:os'
8
+ import path from 'node:path'
9
+ import { listSessions } from './sessionStore.js'
10
+
11
+ test('listSessions: 按更新时间倒序列出有效会话并跳过损坏文件', async (t) => {
12
+ const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'zen-gitsync-sessions-'))
13
+ t.after(() => fsp.rm(directory, { recursive: true, force: true }))
14
+
15
+ await Promise.all([
16
+ fsp.writeFile(path.join(directory, 'older.json'), JSON.stringify({
17
+ sessionId: 'wrong-id',
18
+ title: '旧对话',
19
+ updatedAt: '2026-01-01T00:00:00.000Z',
20
+ messages: [{ role: 'user', content: 'old' }],
21
+ })),
22
+ fsp.writeFile(path.join(directory, 'newer.json'), JSON.stringify({
23
+ title: '新对话',
24
+ updatedAt: '2026-02-01T00:00:00.000Z',
25
+ messages: [{ role: 'user', content: 'new' }],
26
+ })),
27
+ fsp.writeFile(path.join(directory, 'broken.json'), '{not json'),
28
+ fsp.writeFile(path.join(directory, 'not-a-session.json'), JSON.stringify({ title: 'missing messages' })),
29
+ fsp.writeFile(path.join(directory, 'ignored.txt'), 'ignored'),
30
+ ])
31
+
32
+ const sessions = await listSessions(directory)
33
+ assert.deepEqual(sessions.map(session => session.sessionId), ['newer', 'older'])
34
+ assert.deepEqual(sessions.map(session => session.title), ['新对话', '旧对话'])
35
+ })