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.
- package/README.md +9 -0
- package/package.json +1 -1
- package/src/cli/ai/modelSetup.js +197 -14
- package/src/cli/ai/sessionStore.js +97 -97
- package/src/cli/ai/termui.js +92 -0
- package/src/cli/ai/termui.test.js +131 -0
- package/src/ui/public/assets/AgentView-d83DuyQq.js +4 -0
- package/src/ui/public/assets/AppVersionBadge-UNabzjNF.js +2 -0
- package/src/ui/public/assets/BranchSelector-DUbFSWiQ.js +1 -0
- package/src/ui/public/assets/CommitForm-HiRHE42z.js +6 -0
- package/src/ui/public/assets/EditorView-BvLP-NL1.js +0 -0
- package/src/ui/public/assets/FlowExecutionViewer-6mr8M454.js +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-Cw2ADY-p.js +21 -0
- package/src/ui/public/assets/LogList-DLg6RUCd.js +15 -0
- package/src/ui/public/assets/MindmapView-BKIsdNvz.js +2 -0
- package/src/ui/public/assets/MonitorView-BjjHr8a5.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-C-qI63k1.js +1 -0
- package/src/ui/public/assets/RemoteRepoCard-B0q3WipX.js +5 -0
- package/src/ui/public/assets/SourceMapView-BZgc-Ylv.js +3 -0
- package/src/ui/public/assets/UserInputNode-s3Dfg2Gm.js +1 -0
- package/src/ui/public/assets/WorkbenchView-BakrUWDH.js +28 -0
- package/src/ui/public/assets/element-plus-C0loJL0J.js +32 -0
- package/src/ui/public/assets/element-plus-CXfbWXaJ.css +1 -0
- package/src/ui/public/assets/{flow-mindmap-DnyDhwJv.js → flow-mindmap-CGn6wqsF.js} +1 -1
- package/src/ui/public/assets/index-Bumd4MSq.css +1 -0
- package/src/ui/public/assets/index-Cr5-U-r6.js +82 -0
- package/src/ui/public/assets/{monaco-Dje8Rg13.js → monaco-DPQ3OaiL.js} +2 -2
- package/src/ui/public/assets/socket-io-B9UuzwTN.js +1 -0
- package/src/ui/public/assets/{ts.worker-Dth06zuC.js → ts.worker-METxwbDZ.js} +1 -16
- package/src/ui/public/assets/{vendor-DVvq7qtS.css → vendor-C2ZpZPB4.css} +1 -1
- package/src/ui/public/assets/{vendor-exjCs1jx.js → vendor-pbWglt5U.js} +26 -67
- package/src/ui/public/assets/vue-flow-DRwHh4gM.js +39 -0
- package/src/ui/public/index.html +9 -15
- package/src/ui/server/index.js +4 -0
- package/src/ui/server/routes/workbench/agentChat.js +14 -3
- package/src/ui/server/routes/workbench/agentRoutes.js +218 -212
- package/src/ui/server/routes/workbench/agentSessionStore.js +173 -173
- package/src/ui/public/assets/AgentView-CioAzh-a.js +0 -4
- package/src/ui/public/assets/AppVersionBadge-CZ3UbiAu.js +0 -2
- package/src/ui/public/assets/BranchSelector-Kwcj7e0Z.js +0 -1
- package/src/ui/public/assets/CommitForm-DueeSB7T.js +0 -6
- package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +0 -1
- package/src/ui/public/assets/CommonDialog-Tlk0Fa7g.js +0 -1
- package/src/ui/public/assets/EditorView-B2gm2OW1.js +0 -0
- package/src/ui/public/assets/FlowExecutionViewer-CFpiqZ2C.js +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-BRUlRQwp.js +0 -21
- package/src/ui/public/assets/LogList-Do3C4BmA.js +0 -15
- package/src/ui/public/assets/MindmapView-C1pPUu8W.js +0 -2
- package/src/ui/public/assets/MonitorView-C2EyggDk.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-D7mgEsmf.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-D582mbhK.js +0 -5
- package/src/ui/public/assets/SourceMapView-gQRnN0Vj.js +0 -3
- package/src/ui/public/assets/SvgIcon-CDNsk4-Q.css +0 -1
- package/src/ui/public/assets/SvgIcon-Diox29ks.js +0 -1
- package/src/ui/public/assets/UserInputNode-CYxFuwuS.js +0 -1
- package/src/ui/public/assets/WorkbenchView-CbWSllXy.js +0 -28
- package/src/ui/public/assets/_plugin-vue_export-helper-B9cwfrdH.js +0 -15
- package/src/ui/public/assets/configStore-B8X980y1.js +0 -1
- package/src/ui/public/assets/element-plus-BId3Dnij.js +0 -25
- package/src/ui/public/assets/element-plus-D3Dn7nX0.css +0 -1
- package/src/ui/public/assets/index--onqtBRQ.css +0 -1
- package/src/ui/public/assets/index-DvrM8frM.js +0 -68
- package/src/ui/public/assets/socket-io-CGezBd--.js +0 -1
- package/src/ui/public/assets/vue-flow-Dj_X_Vr1.js +0 -39
- /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.
|
|
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",
|
package/src/cli/ai/modelSetup.js
CHANGED
|
@@ -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
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
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(
|
|
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(
|
|
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 };
|
package/src/cli/ai/termui.js
CHANGED
|
@@ -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,
|