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.
- package/README.md +9 -0
- package/package.json +1 -1
- package/src/cli/ai/agent.js +123 -15
- package/src/cli/ai/modelSetup.js +265 -25
- package/src/cli/ai/modelSetup.test.js +36 -7
- package/src/cli/ai/sessionStore.js +97 -97
- package/src/cli/ai/termui.js +173 -6
- package/src/cli/ai/termui.test.js +261 -1
- 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-Cwg6vOMz.js +0 -2
- package/src/ui/public/assets/BranchSelector-DgxIB9XY.js +0 -1
- package/src/ui/public/assets/CommitForm-CTa0pupJ.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-BOZPwTxr.js +0 -0
- package/src/ui/public/assets/FlowExecutionViewer-CFpiqZ2C.js +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CVYPdN7a.js +0 -21
- package/src/ui/public/assets/LogList-B4rrapVn.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-B-5MDx8k.js +0 -5
- package/src/ui/public/assets/SourceMapView-C3gZ5s1r.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-BE53JHac.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-Ds_2iFkX.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
|
@@ -409,8 +409,8 @@ function silenceConsole() {
|
|
|
409
409
|
test('collectModelInput: 预设服务商 + 内置模型 + 测试成功 → 返回收集的字段', async () => {
|
|
410
410
|
const restore = silenceConsole()
|
|
411
411
|
try {
|
|
412
|
-
// 答案:选 OpenAI(1) → 确认 baseURL(空=默认) → 选第一个模型(1) →
|
|
413
|
-
const rl = createMockRl(['1', '', '
|
|
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 → 确认(空=默认) →
|
|
433
|
-
const rl = createMockRl(['0', 'https://my.custom.api/v1', '', '
|
|
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) →
|
|
453
|
-
const rl = createMockRl(['3', '', '
|
|
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', '', '
|
|
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 };
|
package/src/cli/ai/termui.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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,
|