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.
- package/package.json +1 -1
- package/src/cli/ai/agent.js +157 -36
- package/src/cli/ai/modelSetup.js +48 -8
- package/src/cli/ai/modelSetup.test.js +61 -17
- package/src/cli/ai/sessionStore.js +24 -0
- package/src/cli/ai/sessionStore.test.js +35 -0
- package/src/cli/ai/termui.js +32 -28
- package/src/cli/ai/termui.test.js +22 -28
- package/src/ui/public/assets/AgentView-BPKmqqZl.js +4 -0
- package/src/ui/public/assets/{AppVersionBadge-CDoN_glL.js → AppVersionBadge-CFOXkBPL.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-gboCD3tv.js → BranchSelector-Wchf8YFj.js} +1 -1
- package/src/ui/public/assets/{CommitForm-CJgh5GcK.js → CommitForm-BG5mmgRC.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-BDZCLepZ.js → CommonDialog-JM-MxEwb.js} +1 -1
- package/src/ui/public/assets/{EditorView-Dyr7vMRf.js → EditorView-CMN-uriB.js} +0 -0
- package/src/ui/public/assets/{FlowExecutionViewer-CU9-dz9w.js → FlowExecutionViewer-CFbWKv24.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-c43AA7LF.js → FlowOrchestrationWorkspace-BDKwODIH.js} +1 -1
- package/src/ui/public/assets/{LogList-iCDptIPg.js → LogList-CSc-ZNzP.js} +1 -1
- package/src/ui/public/assets/{MindmapView-CQjUAlgQ.js → MindmapView-BqekPzIe.js} +1 -1
- package/src/ui/public/assets/{MonitorView-iYl_sijH.js → MonitorView-C1HenUy5.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-DkT5mET4.js → ProjectStartupButton-D-8X6yBa.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-CGsDr1KU.js → RemoteRepoCard-D5JvNYVx.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-DEZyzSlQ.js → SourceMapView-DqKXPsx0.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-B9e7SPKv.js → SvgIcon-BI4SErO0.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-BR7SDL9I.js → UserInputNode-BMlBt2DM.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-KwZ2PyW6.js +12 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-D38CJ5Mo.js → _plugin-vue_export-helper-CSWqbJLa.js} +1 -1
- package/src/ui/public/assets/{configStore-bI1LM_pM.js → configStore-DhoZfUJ9.js} +1 -1
- package/src/ui/public/assets/{index-vrSVIcKF.js → index-DDjvX04t.js} +6 -6
- package/src/ui/public/assets/vendor-C2ZpZPB4.css +1 -0
- package/src/ui/public/assets/{vendor-Dx-XcZ4p.js → vendor-Ifx2s0GZ.js} +38 -35
- package/src/ui/public/index.html +7 -7
- package/src/ui/public/assets/AgentView-BDvLuOtp.js +0 -4
- package/src/ui/public/assets/WorkbenchView-DS3ZcaUd.js +0 -12
- package/src/ui/public/assets/ai-chat-ui-86nZ2Zbh.js +0 -45
- package/src/ui/public/assets/ai-chat-ui-BwlJovCq.css +0 -1
- 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.
|
|
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",
|
package/src/cli/ai/agent.js
CHANGED
|
@@ -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 补全
|
|
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
|
-
//
|
|
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 === '
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
package/src/cli/ai/modelSetup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
}
|
|
392
|
-
|
|
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
|
+
})
|