op-packages/luci-app-openclaw/root/usr/share/openclaw/oc-menu-engine.js
github-actions[bot] c2374cbb98 🎨 Sync 2026-04-10 15:34:26
2026-04-10 15:34:26 +08:00

728 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ============================================================================
* OpenClaw 配置工具 — 交互式菜单引擎
* ============================================================================
*
* 功能特性:
* - 方向键 (↑↓) 导航菜单选项
* - 回车/空格 确认选择
* - Tab 在多列菜单中切换
* - ESC/q 返回上级菜单
* - 搜索过滤 (输入字符实时过滤)
* - 纯 Node.js 实现,零外部依赖
* - 完全兼容 xterm.js / Web PTY 环境
*
* 使用方式:
* const menu = require('./oc-menu-engine');
* const choice = await menu.select({
* title: '选择模型提供商',
* items: [
* { key: 'a', label: 'OpenAI', desc: 'GPT-5, GPT-4.1' },
* { key: 'b', label: 'Anthropic', desc: 'Claude' },
* ]
* });
*/
// ANSI 颜色代码
const C = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
italic: '\x1b[3m',
underline: '\x1b[4m',
// 前景色
black: '\x1b[30m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
// 亮色
brightRed: '\x1b[91m',
brightGreen: '\x1b[92m',
brightYellow: '\x1b[93m',
brightBlue: '\x1b[94m',
brightMagenta: '\x1b[95m',
brightCyan: '\x1b[96m',
// 背景色
bgBlack: '\x1b[40m',
bgRed: '\x1b[41m',
bgGreen: '\x1b[42m',
bgYellow: '\x1b[43m',
bgBlue: '\x1b[44m',
bgMagenta: '\x1b[45m',
bgCyan: '\x1b[46m',
bgWhite: '\x1b[47m',
// 光标控制
hide: '\x1b[?25l',
show: '\x1b[?25h',
save: '\x1b[s',
restore: '\x1b[u',
clearLine: '\x1b[2K',
clearDown: '\x1b[J',
// 移动
up: (n = 1) => `\x1b[${n}A`,
down: (n = 1) => `\x1b[${n}B`,
right: (n = 1) => `\x1b[${n}C`,
left: (n = 1) => `\x1b[${n}D`,
toCol: (n) => `\x1b[${n}G`,
toRow: (n) => `\x1b[${n}H`,
to: (row, col) => `\x1b[${row};${col}H`,
};
// ═══════════════════════════════════════════════════════════════════════════
// 终端输入处理
// ═══════════════════════════════════════════════════════════════════════════
/**
* 设置终端为原始模式 (逐字节读取)
*/
function setRawMode(enable) {
if (process.stdin.isTTY) {
process.stdin.setRawMode(enable);
}
}
/**
* 从 stdin 读取单个按键
* 支持 Bracketed Paste Mode (粘贴检测)
* @returns {Promise<{name: string, sequence: string, char: string, paste?: string}>}
*/
function readKey() {
return new Promise((resolve) => {
const onData = (buffer) => {
process.stdin.off('data', onData);
const seq = buffer.toString('utf8');
// 解析按键
let key = { sequence: seq, char: '', name: '' };
// 检测 Bracketed Paste (粘贴操作)
// 终端在粘贴时发送: ESC[200~ <粘贴内容> ESC[201~
if (seq.startsWith('\x1b[200~') && seq.endsWith('\x1b[201~')) {
// 提取粘贴的内容 (去掉首尾的转义序列)
const pasteContent = seq.slice(6, -6);
key.name = 'paste';
key.paste = pasteContent;
key.char = '';
resolve(key);
return;
}
if (seq === '\r' || seq === '\n') {
key.name = 'return';
} else if (seq === '\t') {
key.name = 'tab';
} else if (seq === '\x1b') {
key.name = 'escape';
} else if (seq === '\x7f' || seq === '\x08') {
key.name = 'backspace';
} else if (seq === '\x1b[A') {
key.name = 'up';
} else if (seq === '\x1b[B') {
key.name = 'down';
} else if (seq === '\x1b[C') {
key.name = 'right';
} else if (seq === '\x1b[D') {
key.name = 'left';
} else if (seq === '\x1b[1;2A' || seq === '\x1b[1;2B') {
// Shift+Up/Down - 可用于快速滚动
key.name = seq.endsWith('A') ? 'shift-up' : 'shift-down';
} else if (seq === '\x1b[5~') {
key.name = 'pageup';
} else if (seq === '\x1b[6~') {
key.name = 'pagedown';
} else if (seq === '\x1b[H') {
key.name = 'home';
} else if (seq === '\x1b[F') {
key.name = 'end';
} else if (seq.length === 1 && seq >= ' ' && seq <= '~') {
key.char = seq;
key.name = seq.toLowerCase();
} else if (seq.startsWith('\x1b[') && seq.endsWith('~')) {
// 功能键 F1-F12
const code = seq.slice(2, -1);
if (code >= '11' && code <= '24') {
key.name = `f${parseInt(code) - 10}`;
}
}
resolve(key);
};
process.stdin.once('data', onData);
});
}
// ═══════════════════════════════════════════════════════════════════════════
// 交互式选择菜单
// ═══════════════════════════════════════════════════════════════════════════
// 记录上一次渲染的菜单行数,用于正确清除
let lastRenderLines = 0;
/**
* 重置渲染计数器 (在打印非菜单内容前调用)
*/
function resetRenderCount() {
lastRenderLines = 0;
}
/**
* 渲染菜单选项
* @param {Array} items 菜单项数组
* @param {number} selected 当前选中索引
* @param {string} searchFilter 搜索过滤字符串
* @param {object} options 配置选项
* @returns {Array} 过滤后的菜单项
*/
function renderMenu(items, selected, searchFilter = '', options = {}) {
const {
title = '请选择',
header = '',
footer = '',
showSearch = true,
showHelp = true,
maxWidth = 80,
} = options;
// 过滤项目
let filteredItems = items;
if (searchFilter) {
const filter = searchFilter.toLowerCase();
filteredItems = items.filter(item =>
(item.label || '').toLowerCase().includes(filter) ||
(item.desc || '').toLowerCase().includes(filter) ||
(item.key || '').toLowerCase().includes(filter)
);
}
// 输出缓冲
const lines = [];
// 标题
if (title) {
lines.push(`${C.bold}${C.cyan} ${title}${C.reset}`);
lines.push('');
}
// 头部信息
if (header) {
lines.push(` ${header}`);
lines.push('');
}
// 搜索框
if (showSearch && searchFilter !== undefined) {
const searchPrompt = searchFilter
? `${C.dim}搜索: ${C.reset}${C.yellow}${searchFilter}${C.reset}${C.dim}${C.reset}`
: `${C.dim}搜索: ${C.reset}${C.dim}(输入字符过滤ESC 清空)${C.reset}`;
lines.push(` ${searchPrompt}`);
lines.push('');
}
// 菜单项
filteredItems.forEach((item, idx) => {
const isSelected = idx === selected;
const isDisabled = item.disabled;
// 选中态样式 - 使用背景色高亮整行
const cursor = isSelected ? `${C.cyan}${C.reset} ` : ' ';
const labelStyle = isSelected ? `${C.bold}${C.cyan}` : (isDisabled ? C.dim : '');
const descStyle = isSelected ? C.cyan : C.dim;
// 构建行
const keyLabel = item.key ? `${C.yellow}${item.key}${C.reset}` : '';
const mainLabel = `${labelStyle}${item.label || ''}${C.reset}`;
const descLabel = item.desc ? `${descStyle}${item.desc}${C.reset}` : '';
// 拼接
let line = cursor;
if (keyLabel) line += `${C.dim}[${C.reset}${keyLabel}${C.dim}]${C.reset} `;
line += mainLabel;
if (descLabel && !isDisabled) line += ` ${descLabel}`;
lines.push(line);
});
// 空状态
if (filteredItems.length === 0) {
lines.push(` ${C.dim}(没有匹配的项目)${C.reset}`);
}
// 底部帮助
if (showHelp) {
lines.push('');
lines.push(` ${C.dim}↑↓ 导航 回车 确认 ESC/q 返回${showSearch ? ' 输入 搜索' : ''}${C.reset}`);
}
// 自定义底部
if (footer) {
lines.push('');
lines.push(` ${footer}`);
}
// 计算需要的清除行数
const totalLines = lines.length + 1;
// 清除之前的内容: 移动到开头,清除到屏幕底部
let clearSeq = '\r';
if (lastRenderLines > 0) {
// 移动到上一次渲染的第一行
clearSeq += `\x1b[${lastRenderLines}A`;
// 清除从此行到屏幕底部
clearSeq += C.clearDown;
}
lastRenderLines = totalLines;
// 输出: 清除旧内容 + 新内容
process.stdout.write(clearSeq + lines.join('\n') + '\n');
return filteredItems;
}
/**
* 交互式单选菜单
* @param {object} options 配置选项
* @returns {Promise<object|null>} 选中的项目或 null (取消)
*/
async function select(options = {}) {
const {
items = [],
title = '请选择',
header = '',
footer = '',
defaultIndex = 0,
showSearch = true,
allowCancel = true,
exitKeys = ['escape', 'q'],
onSelect = null, // 选择回调 (可用于预览)
} = options;
if (items.length === 0) {
return null;
}
// 重置渲染行计数
lastRenderLines = 0;
// 初始化状态
let selected = Math.min(defaultIndex, items.length - 1);
let searchFilter = '';
let filteredItems = items;
let done = false;
let result = null;
// 保存终端状态
setRawMode(true);
process.stdout.write(C.hide);
process.stdout.write(C.save);
try {
// 初始渲染
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
while (!done) {
const key = await readKey();
// 处理按键
if (key.name === 'return') {
// 确认选择
if (filteredItems.length > 0 && !filteredItems[selected].disabled) {
result = filteredItems[selected];
done = true;
}
} else if (key.name === 'up') {
// 上移
if (selected > 0) {
selected--;
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
}
} else if (key.name === 'down') {
// 下移
if (selected < filteredItems.length - 1) {
selected++;
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
}
} else if (key.name === 'pageup') {
// 上翻页
selected = Math.max(0, selected - 10);
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
} else if (key.name === 'pagedown') {
// 下翻页
selected = Math.min(filteredItems.length - 1, selected + 10);
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
} else if (key.name === 'home') {
selected = 0;
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
} else if (key.name === 'end') {
selected = filteredItems.length - 1;
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
} else if (exitKeys.includes(key.name) && allowCancel) {
// 取消/退出
done = true;
} else if (key.name === 'backspace') {
// 删除搜索字符
if (searchFilter.length > 0) {
searchFilter = searchFilter.slice(0, -1);
selected = 0; // 重置选中
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
}
} else if (key.char && key.char.length === 1) {
// 输入字符 -> 搜索
if (showSearch) {
searchFilter += key.char;
selected = 0;
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
}
}
// 直接按键选择 (如果菜单项有 key 属性)
if (key.char && !showSearch) {
const matchByChar = items.find(item => (item.key || '').toLowerCase() === key.name);
if (matchByChar) {
result = matchByChar;
done = true;
}
}
}
} finally {
// 恢复终端状态
process.stdout.write(C.restore);
process.stdout.write(C.clearDown);
process.stdout.write(C.show);
setRawMode(false);
}
return result;
}
// ═══════════════════════════════════════════════════════════════════════════
// 其他交互组件
// ═══════════════════════════════════════════════════════════════════════════
/**
* 文本输入框
* 支持粘贴操作 (Bracketed Paste Mode)
* @param {object} options 配置选项
* @returns {Promise<string|null>} 输入的文本或 null (取消)
*/
async function input(options = {}) {
const {
prompt = '请输入',
defaultValue = '',
placeholder = '',
validate = null,
allowCancel = true,
hidden = false, // 密码输入模式
} = options;
let value = '';
let done = false;
let cancelled = false;
// 启用 Bracketed Paste Mode
process.stdout.write('\x1b[?2004h');
setRawMode(true);
process.stdout.write(C.hide);
try {
// 初始渲染
const renderInput = () => {
const displayValue = hidden ? '*'.repeat(value.length) : value;
const promptText = `${C.bold}${C.cyan}${prompt}:${C.reset} `;
const valueText = displayValue || `${C.dim}${placeholder}${C.reset}`;
process.stdout.write(`\r${C.clearLine}${promptText}${valueText}`);
// 光标定位
if (displayValue) {
process.stdout.write(C.left(displayValue.length - value.length));
}
};
renderInput();
while (!done) {
const key = await readKey();
if (key.name === 'return') {
// 确认
const finalValue = value || defaultValue;
if (validate) {
const error = validate(finalValue);
if (error) {
process.stdout.write(`\n${C.red}${error}${C.reset}\n`);
renderInput();
continue;
}
}
done = true;
} else if ((key.name === 'escape' || key.name === 'q') && allowCancel) {
done = true;
cancelled = true;
} else if (key.name === 'backspace') {
if (value.length > 0) {
value = value.slice(0, -1);
renderInput();
}
} else if (key.name === 'paste' && key.paste) {
// 处理粘贴操作 (Bracketed Paste Mode)
// 清理粘贴内容中的控制字符和换行符
const cleanPaste = key.paste
.replace(/[\r\n]+/g, ' ') // 换行符替换为空格
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // 移除 ANSI 转义序列
.replace(/[\x00-\x1f]/g, ''); // 移除控制字符
value += cleanPaste;
renderInput();
} else if (key.char && key.char.length === 1) {
value += key.char;
renderInput();
} else if (key.sequence && key.sequence.length > 1 && !key.name.startsWith('page') &&
!['up', 'down', 'left', 'right', 'home', 'end'].includes(key.name)) {
// 回退处理: 如果收到未知的多字符序列,可能是粘贴内容
// 尝试提取可打印字符
const extracted = key.sequence
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
.replace(/[\r\n]+/g, ' ')
.replace(/[\x00-\x1f]/g, '')
.replace(/[^\x20-\x7e]/g, '');
if (extracted.length > 0) {
value += extracted;
renderInput();
}
}
}
} finally {
// 禁用 Bracketed Paste Mode
process.stdout.write('\x1b[?2004l');
process.stdout.write(C.show);
setRawMode(false);
}
if (cancelled) return null;
// 显示最终值 (非隐藏模式)
if (!hidden) {
process.stdout.write(`\n`);
}
return value || defaultValue;
}
/**
* 确认对话框
* @param {object} options 配置选项
* @returns {Promise<boolean>} 用户选择
*/
async function confirm(options = {}) {
const {
prompt = '确认吗?',
defaultYes = true,
} = options;
let selected = defaultYes; // true = Yes, false = No
setRawMode(true);
process.stdout.write(C.hide);
const renderConfirm = () => {
const yesStyle = selected ? `${C.bold}${C.green}` : C.dim;
const noStyle = selected ? C.dim : `${C.bold}${C.red}`;
process.stdout.write(
`\r${C.clearLine}${C.bold}${C.cyan}${prompt}${C.reset} ` +
`${yesStyle}[Y] 是${C.reset} / ` +
`${noStyle}[N] 否${C.reset} ` +
`${C.dim}(←→ 切换, 回车 确认)${C.reset}`
);
};
renderConfirm();
try {
while (true) {
const key = await readKey();
if (key.name === 'return') {
process.stdout.write('\n');
return selected;
} else if (key.name === 'left' || key.name === 'y') {
selected = true;
renderConfirm();
} else if (key.name === 'right' || key.name === 'n') {
selected = false;
renderConfirm();
} else if (key.name === 'escape') {
process.stdout.write('\n');
return false;
}
}
} finally {
process.stdout.write(C.show);
setRawMode(false);
}
}
/**
* 多选菜单 (复选框)
* @param {object} options 配置选项
* @returns {Promise<Array>} 选中的项目数组
*/
async function multiselect(options = {}) {
const {
items = [],
title = '请选择 (空格 切换选中)',
defaultSelected = [],
} = options;
if (items.length === 0) return [];
const selectedSet = new Set(defaultSelected);
let cursor = 0;
let done = false;
let cancelled = false;
setRawMode(true);
process.stdout.write(C.hide);
const render = () => {
const lines = [`\r${C.bold}${C.cyan}${title}${C.reset}\n`];
items.forEach((item, idx) => {
const isCursor = idx === cursor;
const isChecked = selectedSet.has(idx);
const cursorStyle = isCursor ? `${C.bold}${C.cyan}` : ' ';
const checkChar = isChecked ? `${C.green}${C.reset}` : `${C.dim}${C.reset}`;
const labelStyle = isCursor ? C.bold : '';
lines.push(`\r${cursorStyle} ${checkChar} ${labelStyle}${item.label}${C.reset}`);
});
lines.push(`\r\n${C.dim}↑↓ 移动 空格 选中 回车 确认 ESC 取消${C.reset}`);
process.stdout.write(C.clearDown + lines.join('\n') + '\n');
};
render();
try {
while (!done) {
const key = await readKey();
if (key.name === 'return') {
done = true;
} else if (key.name === 'escape' || key.name === 'q') {
done = true;
cancelled = true;
} else if (key.name === 'up' && cursor > 0) {
cursor--;
render();
} else if (key.name === 'down' && cursor < items.length - 1) {
cursor++;
render();
} else if (key.name === ' ' || key.name === 'tab') {
if (selectedSet.has(cursor)) {
selectedSet.delete(cursor);
} else {
selectedSet.add(cursor);
}
render();
}
}
} finally {
process.stdout.write(C.show);
setRawMode(false);
}
if (cancelled) return [];
return Array.from(selectedSet).map(idx => items[idx]);
}
// ═══════════════════════════════════════════════════════════════════════════
// 进度指示器
// ═══════════════════════════════════════════════════════════════════════════
/**
* 加载动画 (Spinner)
*/
const spinners = {
dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
line: ['-', '\\', '|', '/'],
circle: ['◜', '◠', '◝', '◞', '◡', '◟'],
};
function spinner(options = {}) {
const {
text = '加载中...',
style = 'dots',
} = options;
const frames = spinners[style] || spinners.dots;
let frame = 0;
let interval = null;
let stopped = false;
return {
start() {
process.stdout.write(C.hide);
interval = setInterval(() => {
if (stopped) return;
const symbol = `${C.cyan}${frames[frame]}${C.reset}`;
process.stdout.write(`\r${C.clearLine}${symbol} ${text}`);
frame = (frame + 1) % frames.length;
}, 80);
},
update(newText) {
if (interval && !stopped) {
const symbol = `${C.cyan}${frames[frame]}${C.reset}`;
process.stdout.write(`\r${C.clearLine}${symbol} ${newText}`);
}
},
stop(finalText = '') {
stopped = true;
if (interval) {
clearInterval(interval);
interval = null;
}
process.stdout.write(`\r${C.clearLine}${finalText}\n`);
process.stdout.write(C.show);
},
succeed(text) {
this.stop(`${C.green}${C.reset} ${text}`);
},
fail(text) {
this.stop(`${C.red}${C.reset} ${text}`);
},
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 导出
// ═══════════════════════════════════════════════════════════════════════════
module.exports = {
// 颜色常量
C,
// 核心交互
select,
input,
confirm,
multiselect,
// 工具
spinner,
readKey,
setRawMode,
resetRenderCount,
// 底层渲染
renderMenu,
};