@ww_nero/mini-cli 1.0.62 → 1.0.67

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/src/config.js CHANGED
@@ -18,8 +18,13 @@ const DEFAULT_ALLOWED_COMMANDS = [
18
18
  'false', 'pip', 'python', 'ps', 'lsof', 'git', 'pandoc'
19
19
  ];
20
20
 
21
- const DEFAULT_MAX_TOOL_TOKENS = 65536;
21
+ const DEFAULT_TOOL_RESPONSE_MAX_TOKENS = 65536;
22
22
  const DEFAULT_COMPACT_TOKEN_THRESHOLD = 65536;
23
+ const DEFAULT_MCP_TOOL_TIMEOUT_MS = 10 * 60 * 1000;
24
+ const DEFAULT_OUTPUT_MAX_LENGTH = 12000;
25
+ const DEFAULT_EXECUTION_TIMEOUT = 300000;
26
+ const DEFAULT_SERVICE_BOOT_WINDOW = 5000;
27
+ const DEFAULT_LARGE_FILE_LINE_THRESHOLD = 2000;
23
28
  const COMPACT_SUMMARY_PROMPT = `请对以下对话进行总结,用于上下文压缩。请按以下格式输出:
24
29
 
25
30
  ## 问题背景
@@ -86,10 +91,14 @@ const DEFAULT_SETTINGS = {
86
91
  mcpServers: {},
87
92
  tools: {},
88
93
  commands: [],
89
- maxToolTokens: DEFAULT_MAX_TOOL_TOKENS,
94
+ maxToolTokens: DEFAULT_TOOL_RESPONSE_MAX_TOKENS,
90
95
  compactTokenThreshold: DEFAULT_COMPACT_TOKEN_THRESHOLD,
91
96
  allowedCommands: [...DEFAULT_ALLOWED_COMMANDS],
92
- worktrees: 2
97
+ mcpToolTimeout: DEFAULT_MCP_TOOL_TIMEOUT_MS,
98
+ outputMaxLength: DEFAULT_OUTPUT_MAX_LENGTH,
99
+ executionTimeout: DEFAULT_EXECUTION_TIMEOUT,
100
+ serviceBootWindow: DEFAULT_SERVICE_BOOT_WINDOW,
101
+ largeFileLineThreshold: DEFAULT_LARGE_FILE_LINE_THRESHOLD
93
102
  };
94
103
 
95
104
  const DEFAULT_MINI_CONTENT = '# 在此填写全局系统指令。\n';
@@ -187,7 +196,11 @@ const normalizePositiveInteger = (value, fallback) => {
187
196
 
188
197
  const normalizeField = (value) => (typeof value === 'string' ? value.trim() : '');
189
198
  const createEndpointSignature = (entry = {}) => {
190
- const pick = (key) => normalizeField(entry[key]).toLowerCase();
199
+ const nameVal = normalizeField(entry.alias || entry.name || entry.model);
200
+ const pick = (key) => {
201
+ if (key === 'name') return nameVal.toLowerCase();
202
+ return normalizeField(entry[key]).toLowerCase();
203
+ };
191
204
  return ['name', 'model', 'baseUrl', 'key'].map(pick).join('::');
192
205
  };
193
206
 
@@ -336,7 +349,7 @@ const loadSettings = ({ defaultTools = [] } = {}) => {
336
349
  commandPath: detectCommandPath(),
337
350
  maxToolTokens: normalizePositiveInteger(
338
351
  parsed.maxToolTokens,
339
- DEFAULT_MAX_TOOL_TOKENS
352
+ DEFAULT_TOOL_RESPONSE_MAX_TOKENS
340
353
  ),
341
354
  allowedCommands: (() => {
342
355
  const list = ensureArrayOfStrings(parsed.allowedCommands);
@@ -346,9 +359,25 @@ const loadSettings = ({ defaultTools = [] } = {}) => {
346
359
  parsed.compactTokenThreshold,
347
360
  DEFAULT_COMPACT_TOKEN_THRESHOLD
348
361
  ),
349
- worktrees: normalizePositiveInteger(
350
- parsed.worktrees,
351
- DEFAULT_SETTINGS.worktrees
362
+ mcpToolTimeout: normalizePositiveInteger(
363
+ parsed.mcpToolTimeout,
364
+ DEFAULT_MCP_TOOL_TIMEOUT_MS
365
+ ),
366
+ outputMaxLength: normalizePositiveInteger(
367
+ parsed.outputMaxLength,
368
+ DEFAULT_OUTPUT_MAX_LENGTH
369
+ ),
370
+ executionTimeout: normalizePositiveInteger(
371
+ parsed.executionTimeout,
372
+ DEFAULT_EXECUTION_TIMEOUT
373
+ ),
374
+ serviceBootWindow: normalizePositiveInteger(
375
+ parsed.serviceBootWindow,
376
+ DEFAULT_SERVICE_BOOT_WINDOW
377
+ ),
378
+ largeFileLineThreshold: normalizePositiveInteger(
379
+ parsed.largeFileLineThreshold,
380
+ DEFAULT_LARGE_FILE_LINE_THRESHOLD
352
381
  )
353
382
  };
354
383
 
@@ -370,7 +399,7 @@ module.exports = {
370
399
  ensureConfigFiles,
371
400
  getConfigPath,
372
401
  DEFAULT_ALLOWED_COMMANDS,
373
- DEFAULT_MAX_TOOL_TOKENS,
402
+ DEFAULT_TOOL_RESPONSE_MAX_TOKENS,
374
403
  DEFAULT_COMPACT_TOKEN_THRESHOLD,
375
404
  COMPACT_SUMMARY_PROMPT
376
405
  };
package/src/tools/bash.js CHANGED
@@ -2,10 +2,6 @@ const { spawn } = require('child_process');
2
2
  const { resolveWorkspacePath } = require('../utils/helpers');
3
3
  const { DEFAULT_ALLOWED_COMMANDS } = require('../config');
4
4
 
5
- const OUTPUT_MAX_LENGTH = 12000;
6
- const EXECUTION_TIMEOUT = 300000;
7
- const SERVICE_RETURN_DELAY = 5000;
8
-
9
5
  // Git 只读命令白名单
10
6
  const GIT_READONLY_COMMANDS = ['show', 'diff', 'log', 'status', 'branch', 'tag', 'ls-files', 'ls-tree', 'rev-parse', 'reflog', 'blame', 'shortlog', 'describe', 'config --get', 'config --list', 'remote', 'ls-remote', 'fetch --dry-run', 'grep'];
11
7
 
@@ -86,6 +82,10 @@ const executeCommand = async ({ command, workingDirectory = '.', isService = fal
86
82
  const allowedCommands = Array.isArray(context.allowedCommands) && context.allowedCommands.length > 0
87
83
  ? context.allowedCommands
88
84
  : DEFAULT_ALLOWED_COMMANDS;
85
+
86
+ const outputMaxLength = context.outputMaxLength || 12000;
87
+ const executionTimeout = context.executionTimeout || 300000;
88
+ const serviceBootWindow = context.serviceBootWindow || 5000;
89
89
  const commands = splitShellCommands(normalizedCommand);
90
90
  if (commands.length === 0) {
91
91
  return '未找到有效的命令';
@@ -129,15 +129,15 @@ const executeCommand = async ({ command, workingDirectory = '.', isService = fal
129
129
 
130
130
  child.stdout.on('data', (data) => {
131
131
  stdout += data.toString();
132
- if (stdout.length > OUTPUT_MAX_LENGTH) {
133
- stdout = stdout.slice(0, OUTPUT_MAX_LENGTH);
132
+ if (stdout.length > outputMaxLength) {
133
+ stdout = stdout.slice(0, outputMaxLength);
134
134
  }
135
135
  });
136
136
 
137
137
  child.stderr.on('data', (data) => {
138
138
  stderr += data.toString();
139
- if (stderr.length > OUTPUT_MAX_LENGTH) {
140
- stderr = stderr.slice(0, OUTPUT_MAX_LENGTH);
139
+ if (stderr.length > outputMaxLength) {
140
+ stderr = stderr.slice(0, outputMaxLength);
141
141
  }
142
142
  });
143
143
 
@@ -169,8 +169,8 @@ const executeCommand = async ({ command, workingDirectory = '.', isService = fal
169
169
  const stdOutput = stdout.trim();
170
170
  const errOutput = stderr.trim();
171
171
  const captured = stdOutput || errOutput ? `\n当前输出:\n${stdOutput}${errOutput ? `\n错误:\n${errOutput}` : ''}` : '\n暂无输出';
172
- resolve(`已等待 ${SERVICE_RETURN_DELAY / 1000}s,命令已在后台持续运行(PID: ${child.pid})。${captured}`);
173
- }, SERVICE_RETURN_DELAY);
172
+ resolve(`已等待 ${serviceBootWindow / 1000}s,命令已在后台持续运行(PID: ${child.pid})。${captured}`);
173
+ }, serviceBootWindow);
174
174
 
175
175
  const serviceErrorHandler = (error) => {
176
176
  clearTimeout(timer);
@@ -190,8 +190,8 @@ const executeCommand = async ({ command, workingDirectory = '.', isService = fal
190
190
  if (settled) return;
191
191
  child.kill('SIGTERM');
192
192
  cleanup();
193
- resolve('命令执行超时 (超过 300s)');
194
- }, EXECUTION_TIMEOUT);
193
+ resolve(`命令执行超时 (超过 ${executionTimeout / 1000}s)`);
194
+ }, executionTimeout);
195
195
  }
196
196
  });
197
197
  };
@@ -15,7 +15,15 @@ const createToolRuntime = async (workspaceRoot, options = {}) => {
15
15
  const enabledMcps = Array.isArray(settings.mcps) ? settings.mcps : [];
16
16
  const allowedCommands = settings.allowedCommands;
17
17
 
18
- const context = { workspaceRoot, allowedCommands, ...options };
18
+ const context = {
19
+ workspaceRoot,
20
+ allowedCommands,
21
+ outputMaxLength: settings.outputMaxLength,
22
+ executionTimeout: settings.executionTimeout,
23
+ serviceBootWindow: settings.serviceBootWindow,
24
+ largeFileLineThreshold: settings.largeFileLineThreshold,
25
+ ...options
26
+ };
19
27
 
20
28
  const tools = [];
21
29
  const handlers = {};
@@ -35,7 +43,9 @@ const createToolRuntime = async (workspaceRoot, options = {}) => {
35
43
 
36
44
  TOOL_MODULES.forEach(registerTool);
37
45
 
38
- const mcpManager = await createMcpManager(workspaceRoot, enabledMcps);
46
+ const mcpManager = await createMcpManager(workspaceRoot, enabledMcps, {
47
+ mcpToolTimeout: settings.mcpToolTimeout
48
+ });
39
49
  let mcpConfigPath = null;
40
50
  const mcpToolNames = new Set(); // 记录所有 MCP 工具名称
41
51
  const enabledMcpNames = []; // 记录启用的 MCP 服务器名称
package/src/tools/mcp.js CHANGED
@@ -305,9 +305,10 @@ const formatMcpContent = (content) => {
305
305
  };
306
306
 
307
307
  class McpManager {
308
- constructor(workspaceRoot, allowedMcpNames = null) {
308
+ constructor(workspaceRoot, allowedMcpNames = null, options = {}) {
309
309
  this.workspaceRoot = workspaceRoot;
310
310
  this.allowedMcpNames = Array.isArray(allowedMcpNames) ? allowedMcpNames : null;
311
+ this.mcpToolTimeout = options.mcpToolTimeout || 600000;
311
312
  this.clients = [];
312
313
  }
313
314
 
@@ -407,7 +408,7 @@ class McpManager {
407
408
  },
408
409
  undefined,
409
410
  {
410
- timeout: 600000 // 10 分钟超时
411
+ timeout: this.mcpToolTimeout
411
412
  }
412
413
  );
413
414
  const isError = Boolean(result && result.isError);
@@ -460,11 +461,11 @@ class McpManager {
460
461
  }
461
462
  }
462
463
 
463
- const createMcpManager = async (workspaceRoot, allowedMcpNames = null) => {
464
+ const createMcpManager = async (workspaceRoot, allowedMcpNames = null, options = {}) => {
464
465
  if (Array.isArray(allowedMcpNames) && allowedMcpNames.length === 0) {
465
466
  return null;
466
467
  }
467
- const manager = new McpManager(workspaceRoot, allowedMcpNames);
468
+ const manager = new McpManager(workspaceRoot, allowedMcpNames, options);
468
469
  await manager.initialize();
469
470
  const tools = manager.getTools();
470
471
  if (!tools || tools.length === 0) {
@@ -1,89 +1,119 @@
1
- const chalk = require('chalk');
2
-
3
- const SLASH_COMMANDS = [
4
- { key: 'model', value: '/model', description: '查看或切换模型' },
5
- { key: 'clear', value: '/clear', description: '清空上下文' },
6
- { key: 'resume', value: '/resume', description: '查看或恢复历史会话' },
7
- { key: 'exit', value: '/exit', description: '退出对话' }
8
- ];
9
-
10
- const getSlashCommandListText = () => SLASH_COMMANDS.map((entry) => entry.value).join('、');
11
-
12
- const createCommandHandler = ({ modelManager, resetMessages, closeSession, handleResume }) => {
13
- const handleClearCommand = () => {
14
- resetMessages();
15
- console.log(chalk.green('已清空上下文。'));
16
- };
17
-
18
- const handleModelCommand = (rawArgs = '') => {
19
- const args = rawArgs.trim();
20
- if (!args || ['list', 'ls'].includes(args.toLowerCase())) {
21
- modelManager.listModels();
22
- return;
23
- }
24
-
25
- const numeric = Number.parseInt(args, 10);
26
- if (!Number.isNaN(numeric) && String(numeric) === args) {
27
- const index = numeric - 1;
28
- if (!modelManager.setActiveModel(index, { updateDefault: true, announce: true })) {
29
- console.error(chalk.red(`序号 ${args} 不在 1-${modelManager.getTotalModels()} 范围内,已保持 ${modelManager.getDefaultModelDescription()}。`));
30
- modelManager.resetToDefault();
31
- }
32
- return;
33
- }
34
-
35
- modelManager.applyModelByName(args, { source: 'command', announceSuccess: true, showError: true });
36
- };
37
-
38
- const handleResumeCommand = (rawArgs = '') => {
39
- if (typeof handleResume === 'function') {
40
- handleResume(rawArgs);
41
- } else {
42
- console.log(chalk.yellow('当前构建未启用历史记录功能。'));
43
- }
44
- };
45
-
46
- const handleSlashCommand = (raw = '') => {
47
- if (!raw.startsWith('/')) {
48
- return { handled: false, shouldExit: false };
49
- }
50
- const trimmed = raw.slice(1).trim();
51
- const availableCommandsText = getSlashCommandListText() || '/model、/clear、/exit';
52
- if (!trimmed) {
53
- console.log(chalk.yellow(`请输入完整命令,例如 ${availableCommandsText}。`));
54
- return { handled: true, shouldExit: false };
55
- }
56
- const [command] = trimmed.split(' ');
57
- const args = trimmed.slice(command.length).trim();
58
- switch ((command || '').toLowerCase()) {
59
- case 'model':
60
- handleModelCommand(args);
61
- return { handled: true, shouldExit: false };
62
- case 'clear':
63
- handleClearCommand();
64
- return { handled: true, shouldExit: false };
65
- case 'resume':
66
- handleResumeCommand(args);
67
- return { handled: true, shouldExit: false };
68
- case 'exit':
69
- closeSession();
70
- return { handled: true, shouldExit: true };
71
- default:
72
- console.log(chalk.yellow(`未识别的命令 /${command},可用命令:${availableCommandsText}。`));
73
- return { handled: true, shouldExit: false };
74
- }
75
- };
76
-
77
- return {
78
- handleSlashCommand,
79
- handleClearCommand,
80
- handleModelCommand,
81
- handleResumeCommand
82
- };
83
- };
84
-
85
- module.exports = {
86
- SLASH_COMMANDS,
87
- getSlashCommandListText,
88
- createCommandHandler
89
- };
1
+ const chalk = require('chalk');
2
+ const { selectFromList } = require('./menu');
3
+
4
+ const SLASH_COMMANDS = [
5
+ { key: 'model', value: '/model', description: '查看或切换模型' },
6
+ { key: 'clear', value: '/clear', description: '清空上下文' },
7
+ { key: 'resume', value: '/resume', description: '查看或恢复历史会话' },
8
+ { key: 'exit', value: '/exit', description: '退出对话' }
9
+ ];
10
+
11
+ const getSlashCommandListText = () => SLASH_COMMANDS.map((entry) => entry.value).join('、');
12
+
13
+ const createCommandHandler = ({ modelManager, resetMessages, closeSession, handleResume, pauseSession, resumeSession }) => {
14
+ const handleClearCommand = () => {
15
+ resetMessages();
16
+ console.log(chalk.green('已清空上下文。'));
17
+ };
18
+
19
+ const handleModelCommand = async (rawArgs = '') => {
20
+ const args = rawArgs.trim();
21
+ if (!args || ['list', 'ls'].includes(args.toLowerCase())) {
22
+ const endpoints = modelManager.getModels();
23
+ const activeIndex = modelManager.getActiveIndex();
24
+ const defaultIndex = modelManager.getDefaultIndex();
25
+
26
+ if (pauseSession) pauseSession();
27
+ const selectedIndex = await selectFromList(endpoints, {
28
+ title: chalk.gray('请选择要切换的模型:'),
29
+ renderItem: (endpoint, idx, isSelected) => {
30
+ const markers = [];
31
+ if (idx === activeIndex) {
32
+ markers.push(chalk.green('当前'));
33
+ }
34
+ if (idx === defaultIndex) {
35
+ markers.push(chalk.cyan('默认'));
36
+ }
37
+ const markerText = markers.length > 0 ? ` [${markers.join('/')}]` : '';
38
+ const description = modelManager.describeEndpoint(endpoint);
39
+ const text = `${idx + 1}. ${description}${markerText}`;
40
+ return isSelected ? chalk.cyan(`> ${text}`) : ` ${text}`;
41
+ }
42
+ });
43
+ if (resumeSession) resumeSession();
44
+
45
+ if (selectedIndex !== null) {
46
+ if (selectedIndex !== activeIndex) {
47
+ modelManager.setActiveModel(selectedIndex, { updateDefault: true, announce: true });
48
+ } else {
49
+ console.log(chalk.gray('已保持当前模型。'));
50
+ }
51
+ }
52
+ return;
53
+ }
54
+
55
+ const numeric = Number.parseInt(args, 10);
56
+ if (!Number.isNaN(numeric) && String(numeric) === args) {
57
+ const index = numeric - 1;
58
+ if (!modelManager.setActiveModel(index, { updateDefault: true, announce: true })) {
59
+ console.error(chalk.red(`序号 ${args} 不在 1-${modelManager.getTotalModels()} 范围内,已保持 ${modelManager.getDefaultModelDescription()}。`));
60
+ modelManager.resetToDefault();
61
+ }
62
+ return;
63
+ }
64
+
65
+ modelManager.applyModelByName(args, { source: 'command', announceSuccess: true, showError: true });
66
+ };
67
+
68
+ const handleResumeCommand = async (rawArgs = '') => {
69
+ if (typeof handleResume === 'function') {
70
+ await handleResume(rawArgs);
71
+ } else {
72
+ console.log(chalk.yellow('当前构建未启用历史记录功能。'));
73
+ }
74
+ };
75
+
76
+ const handleSlashCommand = async (raw = '') => {
77
+ if (!raw.startsWith('/')) {
78
+ return { handled: false, shouldExit: false };
79
+ }
80
+ const trimmed = raw.slice(1).trim();
81
+ const availableCommandsText = getSlashCommandListText() || '/model、/clear、/exit';
82
+ if (!trimmed) {
83
+ console.log(chalk.yellow(`请输入完整命令,例如 ${availableCommandsText}。`));
84
+ return { handled: true, shouldExit: false };
85
+ }
86
+ const [command] = trimmed.split(' ');
87
+ const args = trimmed.slice(command.length).trim();
88
+ switch ((command || '').toLowerCase()) {
89
+ case 'model':
90
+ await handleModelCommand(args);
91
+ return { handled: true, shouldExit: false };
92
+ case 'clear':
93
+ handleClearCommand();
94
+ return { handled: true, shouldExit: false };
95
+ case 'resume':
96
+ await handleResumeCommand(args);
97
+ return { handled: true, shouldExit: false };
98
+ case 'exit':
99
+ closeSession();
100
+ return { handled: true, shouldExit: true };
101
+ default:
102
+ console.log(chalk.yellow(`未识别的命令 /${command},可用命令:${availableCommandsText}。`));
103
+ return { handled: true, shouldExit: false };
104
+ }
105
+ };
106
+
107
+ return {
108
+ handleSlashCommand,
109
+ handleClearCommand,
110
+ handleModelCommand,
111
+ handleResumeCommand
112
+ };
113
+ };
114
+
115
+ module.exports = {
116
+ SLASH_COMMANDS,
117
+ getSlashCommandListText,
118
+ createCommandHandler
119
+ };
@@ -0,0 +1,156 @@
1
+ const readline = require('readline');
2
+ const chalk = require('chalk');
3
+
4
+ // 简单的 ANSI 码去除正则
5
+ const stripAnsi = (str) => str.replace(
6
+ /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
7
+ ''
8
+ );
9
+
10
+ // 计算字符串在终端显示的实际行数(考虑中文宽度和终端宽度)
11
+ const measureLines = (str) => {
12
+ const termWidth = process.stdout.columns || 80;
13
+ const clean = stripAnsi(str);
14
+ let width = 0;
15
+ for (let i = 0; i < clean.length; i++) {
16
+ const code = clean.charCodeAt(i);
17
+ // 简单的宽字符判断:ASCII 范围外视为 2 个宽度
18
+ width += (code > 255) ? 2 : 1;
19
+ }
20
+ return Math.ceil(width / termWidth) || 1;
21
+ };
22
+
23
+ /**
24
+ * 交互式列表选择
25
+ * @param {Array} items - 待选择的项数组
26
+ * @param {Object} options - 配置项
27
+ * @param {Function} options.renderItem - 渲染每项的回调 (item, index, isSelected) => string
28
+ * @param {number} options.pageSize - 每页显示的条数
29
+ * @param {string} options.title - 标题
30
+ * @returns {Promise<number|null>} - 返回选中的索引,取消则返回 null
31
+ */
32
+ const selectFromList = (items, options = {}) => {
33
+ return new Promise((resolve) => {
34
+ const {
35
+ renderItem = (item, idx, isSelected) => (isSelected ? chalk.cyan(`> ${item}`) : ` ${item}`),
36
+ pageSize = 10,
37
+ title = '',
38
+ exitOnInput = false
39
+ } = options;
40
+
41
+ if (!items || items.length === 0) {
42
+ resolve(null);
43
+ return;
44
+ }
45
+
46
+ let selectedIndex = 0;
47
+ let scrollOffset = 0;
48
+ const total = items.length;
49
+ let linesRendered = 0;
50
+
51
+ // 隐藏光标
52
+ process.stdout.write('\x1B[?25l');
53
+
54
+ if (title) {
55
+ console.log(title);
56
+ }
57
+
58
+ const render = () => {
59
+ // 清除之前渲染的行
60
+ if (linesRendered > 0) {
61
+ readline.moveCursor(process.stdout, 0, -(linesRendered - 1));
62
+ readline.cursorTo(process.stdout, 0);
63
+ readline.clearScreenDown(process.stdout);
64
+ }
65
+
66
+ const windowSize = Math.min(pageSize, total);
67
+
68
+ // 调整滚动偏移
69
+ if (selectedIndex < scrollOffset) {
70
+ scrollOffset = selectedIndex;
71
+ } else if (selectedIndex >= scrollOffset + windowSize) {
72
+ scrollOffset = selectedIndex - windowSize + 1;
73
+ }
74
+
75
+ const visibleItems = items.slice(scrollOffset, scrollOffset + windowSize);
76
+
77
+ linesRendered = 0;
78
+ visibleItems.forEach((item, index) => {
79
+ const actualIndex = scrollOffset + index;
80
+ const isSelected = actualIndex === selectedIndex;
81
+ const line = renderItem(item, actualIndex, isSelected);
82
+
83
+ // 计算该行实际占用的物理行数
84
+ const lineCount = measureLines(line);
85
+ process.stdout.write(line + '\n');
86
+ linesRendered += lineCount;
87
+ });
88
+
89
+ // 底部提示
90
+ const footerText = chalk.gray(' (↑/↓ 选择, Enter 确认, Esc 取消)');
91
+ const footerLines = measureLines(footerText);
92
+ process.stdout.write(footerText);
93
+ linesRendered += footerLines;
94
+ };
95
+
96
+ render();
97
+
98
+ const cleanup = () => {
99
+ process.stdout.write('\x1B[?25h'); // 恢复光标
100
+
101
+ // 清除菜单显示
102
+ if (linesRendered > 0) {
103
+ readline.moveCursor(process.stdout, 0, -(linesRendered - 1));
104
+ readline.cursorTo(process.stdout, 0);
105
+ readline.clearScreenDown(process.stdout);
106
+ }
107
+
108
+ // 清除 title 行(如果有)
109
+ if (title) {
110
+ // 计算 title 占用的行数
111
+ const titleLines = measureLines(title);
112
+ // 上移 titleLines 行
113
+ readline.moveCursor(process.stdout, 0, -titleLines);
114
+ readline.cursorTo(process.stdout, 0);
115
+ readline.clearScreenDown(process.stdout);
116
+ }
117
+
118
+ process.stdin.removeListener('keypress', handleKeypress);
119
+ if (process.stdin.isTTY) {
120
+ process.stdin.setRawMode(false);
121
+ // 不要 pause stdin,因为外层 readline 需要它
122
+ // process.stdin.pause();
123
+ }
124
+ };
125
+
126
+ const handleKeypress = (str, key) => {
127
+ if (!key) return;
128
+
129
+ if (key.name === 'up') {
130
+ selectedIndex = (selectedIndex - 1 + total) % total;
131
+ render();
132
+ } else if (key.name === 'down') {
133
+ selectedIndex = (selectedIndex + 1) % total;
134
+ render();
135
+ } else if (key.name === 'return') {
136
+ cleanup();
137
+ resolve(exitOnInput ? { action: 'select', index: selectedIndex } : selectedIndex);
138
+ } else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
139
+ cleanup();
140
+ resolve(exitOnInput ? { action: 'cancel' } : null);
141
+ } else if (exitOnInput) {
142
+ cleanup();
143
+ const inputVal = key.sequence || str || key.name;
144
+ resolve({ action: 'input', input: inputVal });
145
+ }
146
+ };
147
+
148
+ if (process.stdin.isTTY) {
149
+ process.stdin.setRawMode(true);
150
+ process.stdin.resume();
151
+ }
152
+ process.stdin.on('keypress', handleKeypress);
153
+ });
154
+ };
155
+
156
+ module.exports = { selectFromList };
@@ -117,7 +117,11 @@ const createModelManager = (endpoints, { configPath } = {}) => {
117
117
  resetToDefault,
118
118
  setActiveModel,
119
119
  applyModelByName,
120
- listModels
120
+ listModels,
121
+ getModels: () => endpoints,
122
+ getActiveIndex: () => activeEndpointIndex,
123
+ getDefaultIndex: () => defaultEndpointIndex,
124
+ describeEndpoint: (endpoint) => describeModel(endpoint)
121
125
  };
122
126
  };
123
127