zen-gitsync 2.16.9 → 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 (67) hide show
  1. package/README.md +9 -0
  2. package/package.json +1 -1
  3. package/src/cli/ai/agent.js +123 -15
  4. package/src/cli/ai/modelSetup.js +265 -25
  5. package/src/cli/ai/modelSetup.test.js +36 -7
  6. package/src/cli/ai/sessionStore.js +97 -97
  7. package/src/cli/ai/termui.js +173 -6
  8. package/src/cli/ai/termui.test.js +261 -1
  9. package/src/ui/public/assets/AgentView-d83DuyQq.js +4 -0
  10. package/src/ui/public/assets/AppVersionBadge-UNabzjNF.js +2 -0
  11. package/src/ui/public/assets/BranchSelector-DUbFSWiQ.js +1 -0
  12. package/src/ui/public/assets/CommitForm-HiRHE42z.js +6 -0
  13. package/src/ui/public/assets/EditorView-BvLP-NL1.js +0 -0
  14. package/src/ui/public/assets/FlowExecutionViewer-6mr8M454.js +1 -0
  15. package/src/ui/public/assets/FlowOrchestrationWorkspace-Cw2ADY-p.js +21 -0
  16. package/src/ui/public/assets/LogList-DLg6RUCd.js +15 -0
  17. package/src/ui/public/assets/MindmapView-BKIsdNvz.js +2 -0
  18. package/src/ui/public/assets/MonitorView-BjjHr8a5.js +1 -0
  19. package/src/ui/public/assets/ProjectStartupButton-C-qI63k1.js +1 -0
  20. package/src/ui/public/assets/RemoteRepoCard-B0q3WipX.js +5 -0
  21. package/src/ui/public/assets/SourceMapView-BZgc-Ylv.js +3 -0
  22. package/src/ui/public/assets/UserInputNode-s3Dfg2Gm.js +1 -0
  23. package/src/ui/public/assets/WorkbenchView-BakrUWDH.js +28 -0
  24. package/src/ui/public/assets/element-plus-C0loJL0J.js +32 -0
  25. package/src/ui/public/assets/element-plus-CXfbWXaJ.css +1 -0
  26. package/src/ui/public/assets/{flow-mindmap-DnyDhwJv.js → flow-mindmap-CGn6wqsF.js} +1 -1
  27. package/src/ui/public/assets/index-Bumd4MSq.css +1 -0
  28. package/src/ui/public/assets/index-Cr5-U-r6.js +82 -0
  29. package/src/ui/public/assets/{monaco-Dje8Rg13.js → monaco-DPQ3OaiL.js} +2 -2
  30. package/src/ui/public/assets/socket-io-B9UuzwTN.js +1 -0
  31. package/src/ui/public/assets/{ts.worker-Dth06zuC.js → ts.worker-METxwbDZ.js} +1 -16
  32. package/src/ui/public/assets/{vendor-DVvq7qtS.css → vendor-C2ZpZPB4.css} +1 -1
  33. package/src/ui/public/assets/{vendor-exjCs1jx.js → vendor-pbWglt5U.js} +26 -67
  34. package/src/ui/public/assets/vue-flow-DRwHh4gM.js +39 -0
  35. package/src/ui/public/index.html +9 -15
  36. package/src/ui/server/index.js +4 -0
  37. package/src/ui/server/routes/workbench/agentChat.js +14 -3
  38. package/src/ui/server/routes/workbench/agentRoutes.js +218 -212
  39. package/src/ui/server/routes/workbench/agentSessionStore.js +173 -173
  40. package/src/ui/public/assets/AgentView-CioAzh-a.js +0 -4
  41. package/src/ui/public/assets/AppVersionBadge-Cwg6vOMz.js +0 -2
  42. package/src/ui/public/assets/BranchSelector-DgxIB9XY.js +0 -1
  43. package/src/ui/public/assets/CommitForm-CTa0pupJ.js +0 -6
  44. package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +0 -1
  45. package/src/ui/public/assets/CommonDialog-Tlk0Fa7g.js +0 -1
  46. package/src/ui/public/assets/EditorView-BOZPwTxr.js +0 -0
  47. package/src/ui/public/assets/FlowExecutionViewer-CFpiqZ2C.js +0 -1
  48. package/src/ui/public/assets/FlowOrchestrationWorkspace-CVYPdN7a.js +0 -21
  49. package/src/ui/public/assets/LogList-B4rrapVn.js +0 -15
  50. package/src/ui/public/assets/MindmapView-C1pPUu8W.js +0 -2
  51. package/src/ui/public/assets/MonitorView-C2EyggDk.js +0 -1
  52. package/src/ui/public/assets/ProjectStartupButton-D7mgEsmf.js +0 -1
  53. package/src/ui/public/assets/RemoteRepoCard-B-5MDx8k.js +0 -5
  54. package/src/ui/public/assets/SourceMapView-C3gZ5s1r.js +0 -3
  55. package/src/ui/public/assets/SvgIcon-CDNsk4-Q.css +0 -1
  56. package/src/ui/public/assets/SvgIcon-Diox29ks.js +0 -1
  57. package/src/ui/public/assets/UserInputNode-CYxFuwuS.js +0 -1
  58. package/src/ui/public/assets/WorkbenchView-BE53JHac.js +0 -28
  59. package/src/ui/public/assets/_plugin-vue_export-helper-B9cwfrdH.js +0 -15
  60. package/src/ui/public/assets/configStore-B8X980y1.js +0 -1
  61. package/src/ui/public/assets/element-plus-BId3Dnij.js +0 -25
  62. package/src/ui/public/assets/element-plus-D3Dn7nX0.css +0 -1
  63. package/src/ui/public/assets/index--onqtBRQ.css +0 -1
  64. package/src/ui/public/assets/index-Ds_2iFkX.js +0 -68
  65. package/src/ui/public/assets/socket-io-CGezBd--.js +0 -1
  66. package/src/ui/public/assets/vue-flow-Dj_X_Vr1.js +0 -39
  67. /package/src/ui/public/assets/{dagre-BCXBo6I9.js → dagre-CePNlbK4.js} +0 -0
@@ -409,8 +409,8 @@ function silenceConsole() {
409
409
  test('collectModelInput: 预设服务商 + 内置模型 + 测试成功 → 返回收集的字段', async () => {
410
410
  const restore = silenceConsole()
411
411
  try {
412
- // 答案:选 OpenAI(1) → 确认 baseURL(空=默认) → 选第一个模型(1) → apiKey → displayName(空=默认)
413
- const rl = createMockRl(['1', '', '1', 'sk-test', ''])
412
+ // 答案:选 OpenAI(1) → 确认 baseURL(空=默认) → apiKey → 选第一个模型(1) → displayName(空=默认)
413
+ const rl = createMockRl(['1', '', 'sk-test', '1', ''])
414
414
  const result = await collectModelInput({
415
415
  locale: 'zh-CN',
416
416
  rl,
@@ -429,8 +429,8 @@ test('collectModelInput: 预设服务商 + 内置模型 + 测试成功 → 返
429
429
  test('collectModelInput: 自定义服务商 + 手动输入模型名 → 返回收集的字段', async () => {
430
430
  const restore = silenceConsole()
431
431
  try {
432
- // 选自定义(0) → 输入 baseURL → 确认(空=默认) → 手动输入模型名 → apiKey(空) → displayName(空=默认)
433
- const rl = createMockRl(['0', 'https://my.custom.api/v1', '', 'my-model', '', ''])
432
+ // 选自定义(0) → 输入 baseURL → 确认(空=默认) → apiKey(空) → 手动输入模型名 → displayName(空=默认)
433
+ const rl = createMockRl(['0', 'https://my.custom.api/v1', '', '', 'my-model', ''])
434
434
  const result = await collectModelInput({
435
435
  locale: 'zh-CN',
436
436
  rl,
@@ -449,8 +449,8 @@ test('collectModelInput: 自定义服务商 + 手动输入模型名 → 返回
449
449
  test('collectModelInput: 测试失败 + 用户选择不保存 → 返回 null', async () => {
450
450
  const restore = silenceConsole()
451
451
  try {
452
- // 选 DeepSeek(3) → 确认 → 选第一个模型(1) → apiKey → displayName → 测试401失败 → 不保存(n)
453
- const rl = createMockRl(['3', '', '1', 'sk-bad', '', 'n'])
452
+ // 选 DeepSeek(3) → 确认 → apiKey → 选第一个模型(1) → displayName → 测试401失败 → 不保存(n)
453
+ const rl = createMockRl(['3', '', 'sk-bad', '1', '', 'n'])
454
454
  const result = await collectModelInput({
455
455
  locale: 'zh-CN',
456
456
  rl,
@@ -465,7 +465,7 @@ test('collectModelInput: 测试失败 + 用户选择不保存 → 返回 null',
465
465
  test('collectModelInput: 测试失败 + 用户选择继续 → 返回收集的字段', async () => {
466
466
  const restore = silenceConsole()
467
467
  try {
468
- const rl = createMockRl(['3', '', '1', 'sk-bad', '', 'y'])
468
+ const rl = createMockRl(['3', '', 'sk-bad', '1', '', 'y'])
469
469
  const result = await collectModelInput({
470
470
  locale: 'zh-CN',
471
471
  rl,
@@ -494,3 +494,32 @@ test('collectModelInput: 用户取消(触发 close)→ 返回 null', async () =>
494
494
  restore()
495
495
  }
496
496
  })
497
+
498
+ test('collectModelInput: API 返回模型列表时优先使用 → 返回收集的字段', async () => {
499
+ const restore = silenceConsole()
500
+ try {
501
+ // mock fetch: /models 返回模型列表, /chat/completions 返回 200
502
+ const smartFetch = async (url, opts) => {
503
+ if (url.endsWith('/models')) {
504
+ return {
505
+ ok: true,
506
+ status: 200,
507
+ json: async () => ({ data: [{ id: 'api-model-1' }, { id: 'api-model-2' }] }),
508
+ }
509
+ }
510
+ return { ok: true, status: 200, json: async () => ({}) }
511
+ }
512
+ // 选 OpenAI(1) → 确认 baseURL → apiKey → 选第一个 API 返回的模型(1) → displayName
513
+ const rl = createMockRl(['1', '', 'sk-test', '1', ''])
514
+ const result = await collectModelInput({
515
+ locale: 'zh-CN',
516
+ rl,
517
+ fetchFn: smartFetch,
518
+ })
519
+ assert.ok(result)
520
+ assert.equal(result.model, 'api-model-1', '应使用 API 返回的模型而非内置列表')
521
+ assert.equal(result.apiKey, 'sk-test')
522
+ } finally {
523
+ restore()
524
+ }
525
+ })
@@ -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 };
@@ -140,16 +140,149 @@ export function filterSlashCommands(input, locale) {
140
140
 
141
141
  /**
142
142
  * 生成即时提示面板的 ANSI 字符串(不含定位,由调用方负责保存/恢复光标)。
143
- * 每行:两空格缩进 + 青色命令名(左对齐补齐)+ 灰色说明。
144
- * 空数组返回空串。
143
+ * 每行:两空格缩进 + 蓝色命令名(左对齐补齐 12 字符宽)+ 灰色说明。
144
+ * 所有行的"视觉起点"一致(均为 2 空格缩进);选中行用整行反白区分(从缩进处
145
+ * 开始),不另加 ❯ 之类的偏移标记 —— 避免反白块起点与其他行不齐。
146
+ * - 越界 selectedIndex(<0 / >=matches.length) 自动回退为 -1(即不高亮)
147
+ * - 空数组返回空串
145
148
  */
146
- export function renderSlashHintBody(matches) {
149
+ export function renderSlashHintBody(matches, selectedIndex = -1) {
147
150
  if (!Array.isArray(matches) || matches.length === 0) return ''
151
+ const sel = (Number.isInteger(selectedIndex) && selectedIndex >= 0 && selectedIndex < matches.length)
152
+ ? selectedIndex
153
+ : -1
154
+ // 行宽固定 = 2 空格缩进 + 12 字符命令 + 说明;选中行反白整行(含缩进),
155
+ // 反白起点与未选中行严格对齐,视觉上选中行不会"偏左"。
156
+ const rowWidth = (m) => 2 + Math.max(12, String(m.cmd).length) + 1 + String(m.desc).length
148
157
  return matches
149
- .map((m) => ' ' + chalk.cyan(String(m.cmd).padEnd(12)) + chalk.dim(m.desc))
158
+ .map((m, i) => {
159
+ // 用 padEnd 凑满 rowWidth,这样反白背景能延伸到行尾,左右不留缝
160
+ const raw = ' ' + String(m.cmd).padEnd(12) + ' ' + m.desc
161
+ // 所有行命令名统一用青色(不带额外前缀),选中行靠反白区分
162
+ const line = chalk.cyan(String(m.cmd).padEnd(12)) + ' ' + chalk.dim(m.desc)
163
+ if (i !== sel) return ' ' + line
164
+ // 选中行:把命令名+说明之外的两空格缩进也纳入反白,这样视觉起点严格对齐
165
+ return chalk.inverse(raw.padEnd(rowWidth(m)))
166
+ })
150
167
  .join('\n')
151
168
  }
152
169
 
170
+ /**
171
+ * 解析 readline keypress 事件,识别"斜杠命令提示"专用快捷键。
172
+ * 返回动作字符串(消费方据此改 selectedIndex 或补全输入);
173
+ * 不识别的键返回 null(由 readline 正常处理)。
174
+ *
175
+ * - ↑ / Shift+Tab → 'prev'
176
+ * - ↓ → 'next'
177
+ * - Tab → 'complete'(补全选中命令到输入行)
178
+ * - Enter → 'submit'(由 readline 默认提交,本函数仅标记是补全时机)
179
+ * - Esc → 'cancel'
180
+ *
181
+ * 注意:readline 默认会把方向键当历史浏览。消费方需要在我们这个 handler
182
+ * 里"事后回滚":把 rl.line 恢复成 hint 显示时的基线,再 _refreshLine()。
183
+ * (详见 agent.js 的 keypress 监听与说明)
184
+ */
185
+ export function parseKeyForSlashHint(key) {
186
+ if (!key) return null
187
+ const name = key.name
188
+ if (name === 'tab') return key.shift ? 'prev' : 'complete'
189
+ if (name === 'up') return 'prev'
190
+ if (name === 'down') return 'next'
191
+ if (name === 'return' || name === 'enter') return 'submit'
192
+ if (name === 'escape') return 'cancel'
193
+ return null
194
+ }
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
+
153
286
  /** 盒式帮助面板 */
154
287
  export function printHelpPanel(title, lines) {
155
288
  const body = [chalk.bold(title), ...lines].join('\n')
@@ -376,6 +509,28 @@ export function createAssistantWriter({
376
509
  }
377
510
  }
378
511
 
512
+ // ──────────────────────────────────────────────────────
513
+ // 耗时格式化
514
+ // ──────────────────────────────────────────────────────
515
+
516
+ /**
517
+ * 格式化耗时(毫秒 → 人类可读)。
518
+ * < 1s → "123ms"
519
+ * < 60s → "1.2s"
520
+ * ≥ 60s → "2m30s"
521
+ * @param {number} ms
522
+ * @returns {string}
523
+ */
524
+ export function formatDuration(ms) {
525
+ if (!Number.isFinite(ms) || ms < 0) return ''
526
+ if (ms < 1000) return `${Math.round(ms)}ms`
527
+ const s = ms / 1000
528
+ if (s < 60) return `${s.toFixed(1)}s`
529
+ const m = Math.floor(s / 60)
530
+ const rem = Math.round(s % 60)
531
+ return `${m}m${rem}s`
532
+ }
533
+
379
534
  // ──────────────────────────────────────────────
380
535
  // 工具调用块(Claude Code 风格)
381
536
  // ──────────────────────────────────────────────
@@ -426,8 +581,12 @@ export function printToolHeader(name, summary, write = (s) => process.stdout.wri
426
581
  * run_command 的结果里首行是 `$ <command>` 回显——这条信息已经出现在上方的
427
582
  * `▶ run_command $ <summary>` 工具头里,这里再印一次就是重复。所以这里把首
428
583
  * 行 `$ ...` 剥掉,同时复用它来识别退出码(退出码仍驱动错误着色)。
584
+ *
585
+ * @param {string} result - 工具输出文本
586
+ * @param {(s: string) => void} [write] - 输出函数,默认写 stdout
587
+ * @param {number} [durationMs] - 执行耗时(毫秒),有值时在结果末尾追加 ⏱ 计时行
429
588
  */
430
- export function printToolResult(result, write = (s) => process.stdout.write(s)) {
589
+ export function printToolResult(result, write = (s) => process.stdout.write(s), durationMs) {
431
590
  const text = truncateDisplay(result)
432
591
  // 先把 "$ <command>\n" 这一行回显从展示里剥掉(退出码仍在第二行里识别)
433
592
  const visible = text.replace(/^\$[^\n]*\n/, '')
@@ -440,7 +599,11 @@ export function printToolResult(result, write = (s) => process.stdout.write(s))
440
599
  const colorize = isError ? chalk.hex('#f0a020') : chalk.hex('#94a3b8')
441
600
  const lines = visible.split('\n')
442
601
  const rendered = lines.map((l) => chalk.dim(' │ ') + colorize(l)).join('\n')
443
- write(rendered + '\n')
602
+ // 有耗时时在结果末尾追加 ⏱ 计时行(与结果块同缩进)
603
+ const timingLine = (durationMs != null && Number.isFinite(durationMs))
604
+ ? '\n' + chalk.dim(' │ ⏱ ') + chalk.hex('#7a87a0')(formatDuration(durationMs))
605
+ : ''
606
+ write(rendered + timingLine + '\n')
444
607
  }
445
608
 
446
609
  // ──────────────────────────────────────────────
@@ -456,11 +619,15 @@ export default {
456
619
  termWidth,
457
620
  stripAnsi,
458
621
  truncateDisplay,
622
+ formatDuration,
459
623
  printBanner,
460
624
  printHelpPanel,
461
625
  SLASH_COMMANDS,
462
626
  filterSlashCommands,
463
627
  renderSlashHintBody,
628
+ parseKeyForSlashHint,
629
+ renderSelectableListBody,
630
+ parseKeyForSelectableList,
464
631
  drawInputTop,
465
632
  drawInputBottom,
466
633
  inputBottomBorder,