#!/usr/bin/env node 'use strict'; const fs = require('fs'); const path = require('path'); const os = require('os'); const readline = require('readline'); const crypto = require('crypto'); const WORKSPACE = process.cwd(); const WORKSPACE_NAME = path.basename(WORKSPACE) || WORKSPACE; const USERNAME = (os.userInfo().username || 'user'); const PROMPT = `${USERNAME}@${WORKSPACE_NAME} `; const SHOW_REASONING = process.env.EA_REASONING !== '0'; const COLOR_ENABLED = !process.env.NO_COLOR; const GREY = COLOR_ENABLED ? '\x1b[90m' : ''; const BLUE = COLOR_ENABLED ? '\x1b[94m' : ''; const RESET = COLOR_ENABLED ? '\x1b[0m' : ''; // ANSI 转义序列 - 用于精确光标控制 const ANSI = { HIDE_CURSOR: '\x1b[?25l', SHOW_CURSOR: '\x1b[?25h', CLEAR_LINE: '\x1b[K', // 清除从光标到行尾 CLEAR_ENTIRE_LINE: '\x1b[2K', // 清除整行 MOVE_UP: (n) => `\x1b[${n}A`, // 光标上移n行 MOVE_DOWN: (n) => `\x1b[${n}B`, // 光标下移n行 MOVE_LEFT: (n) => `\x1b[${n}D`, // 光标左移n列 SAVE_CURSOR: '\x1b[s', // 保存光标位置(部分终端) RESTORE_CURSOR: '\u001b[u', // 恢复光标位置 ALT_SCREEN: '\x1b[?1049h', // 切换到备用屏幕 MAIN_SCREEN: '\x1b[?1049l', // 返回主屏幕 }; const SPINNER_FRAMES = ['⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']; const SPINNER_INTERVAL_MS = 80; const SPINNER_INDENT = ' '; const SYSTEM_PROMPT = loadSystemPrompt(); void SYSTEM_PROMPT; const COMMAND_MENU_ITEMS = [ { cmd: '/new', desc: '创建新对话' }, { cmd: '/resume', desc: '加载旧对话' }, { cmd: '/allow', desc: '切换运行模式(只读/无限制)' }, { cmd: '/model', desc: '切换模型和思考模式' }, { cmd: '/status', desc: '查看当前对话状态' }, { cmd: '/compact', desc: '压缩当前对话' }, { cmd: '/config', desc: '查看当前配置' }, { cmd: '/help', desc: '显示指令列表' }, { cmd: '/exit', desc: '退出程序' }, ]; const COMMAND_MENU_SIZE = 6; const state = { conversationId: crypto.randomUUID(), allow: 'full_access', model: 'kimi', thinking: 'thinking', tokenUsage: 0, baseUrl: 'https://api.example.com/v1', apiKey: 'sk-xxxxxxw2y', }; console.log(''); console.log('eagent demo'); console.log(`model: kimi | allow: full_access | cwd: ${WORKSPACE_NAME}`); console.log(''); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true, }); readline.emitKeypressEvents(process.stdin, rl); if (process.stdin.isTTY) process.stdin.setRawMode(true); rl.setPrompt(PROMPT); rl.prompt(); const menuState = { active: false, selected: 0, offset: 0, startRow: null, // 记录菜单开始的行号(相对位置) }; process.stdin.on('keypress', async (str, key) => { // 如果菜单处于激活状态,优先处理菜单导航 if (menuState.active) { await handleMenuKeypress(str, key); return; } // 检测 / 键来打开菜单 if (str === '/' && (rl.line === '' || rl.line === '/')) { openCommandMenu(); return; } }); async function handleMenuKeypress(str, key) { if (!key) return; switch (key.name) { case 'up': menuState.selected = Math.max(0, menuState.selected - 1); renderCommandMenu(); break; case 'down': menuState.selected = Math.min( COMMAND_MENU_ITEMS.length - 1, menuState.selected + 1 ); renderCommandMenu(); break; case 'return': case 'enter': const chosen = COMMAND_MENU_ITEMS[menuState.selected]?.cmd; await closeCommandMenu(); if (chosen) { await handleCommand(chosen); process.stdout.write('\n'); } rl.prompt(); break; case 'escape': await closeCommandMenu(); process.stdout.write('\n'); rl.prompt(); break; case 'c': if (key.ctrl) { await closeCommandMenu(); rl.close(); } break; } } rl.on('line', async (line) => { const input = line.trim(); if (!input) { rl.prompt(); return; } if (input.startsWith('/')) { await handleCommand(input); rl.prompt(); return; } console.log(`用户:${line}`); console.log(''); await simulateAssistantReply(input); rl.prompt(); }); rl.on('close', () => { process.stdout.write('\n'); process.exit(0); }); function loadSystemPrompt() { const p = path.join(__dirname, '..', 'prompts', 'system.txt'); try { return fs.readFileSync(p, 'utf8'); } catch (_) { return ''; } } function truncateText(text, maxLen) { if (text.length <= maxLen) return text; return text.slice(0, maxLen) + '...'; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function simulateAssistantReply(userInput) { const spinner = startSpinner(''); await sleep(600); spinner.setLabel(' 思考中...'); await sleep(1200); stopSpinner(spinner, SHOW_REASONING ? '○ 思考完成' : '○'); if (SHOW_REASONING) { const reasoning = `好的,用户让我根据输入「${userInput}」做出回应,并给出明确的下一步。`; const snippet = truncateText(reasoning, 50); await streamText('', `${GREY}${snippet}${RESET}`, 12); } const reply = `这是模拟的 Eagent 回复内容。后续会替换为真实模型流式输出。`; await streamText('Eagent:', reply, 18); state.tokenUsage += userInput.length + reply.length; } function startSpinner(initialLabel) { let i = 0; let label = initialLabel; const timer = setInterval(() => { const frame = SPINNER_FRAMES[i % SPINNER_FRAMES.length]; process.stdout.write(`\r${SPINNER_INDENT}${frame}${label}`); i += 1; }, SPINNER_INTERVAL_MS); return { timer, setLabel(nextLabel) { label = nextLabel || ''; } }; } function stopSpinner(spinner, finalText) { clearInterval(spinner.timer); const clear = '\r' + ' '.repeat(80) + '\r'; process.stdout.write(clear + SPINNER_INDENT + finalText + '\n'); } async function streamText(prefix, text, delayMs) { if (prefix) process.stdout.write(prefix); for (const ch of text) { process.stdout.write(ch); await sleep(delayMs); } process.stdout.write('\n'); } async function handleCommand(input) { const [cmd, ...rest] = input.split(/\s+/); const arg = rest.join(' ').trim(); if (cmd === '/exit') { rl.close(); return; } if (cmd === '/help') { printHelp(); return; } switch (cmd) { case '/new': state.conversationId = crypto.randomUUID(); state.tokenUsage = 0; console.log(`已创建新对话: ${state.conversationId}`); return; case '/resume': if (arg) { state.conversationId = arg; console.log(`已加载对话: ${state.conversationId}`); } else { console.log('Updated Conversation'); console.log('2分钟前 帮我看看...'); console.log('14小时前 查看所有...'); console.log('13天前 ...'); console.log('提示:使用 /resume 直接加载。'); } return; case '/allow': state.allow = state.allow === 'full_access' ? 'read_only' : 'full_access'; console.log(`运行模式已切换为: ${state.allow}`); return; case '/model': { if (!arg) { state.thinking = state.thinking === 'thinking' ? 'fast' : 'thinking'; console.log(`思考模式已切换为: ${state.thinking}`); return; } const parts = arg.split(/\s+/).filter(Boolean); state.model = parts[0] || state.model; if (parts[1]) state.thinking = parts[1]; console.log(`模型已切换为: ${state.model} | 思考模式: ${state.thinking}`); return; } case '/status': console.log(`model: ${state.model} | 思考: ${state.thinking}`); console.log(`workspace: ${WORKSPACE}`); console.log(`allow: ${state.allow}`); console.log(`conversation: ${state.conversationId}`); console.log(`token usage: ${state.tokenUsage}`); return; case '/compact': { const before = state.tokenUsage; const after = Math.max(0, Math.floor(before * 0.6)); state.tokenUsage = after; console.log(`压缩完成:${before} -> ${after}`); return; } case '/config': console.log(`base_url: ${state.baseUrl}`); console.log(`modelname: ${state.model}`); console.log(`apikey: ${state.apiKey}`); return; default: console.log(`未知指令: ${cmd},使用 /help 查看指令列表。`); } } function printHelp() { console.log('/new 创建新对话'); console.log('/resume 加载旧对话'); console.log('/allow 切换运行模式(只读/无限制)'); console.log('/model 切换模型和思考模式'); console.log('/status 查看当前对话状态'); console.log('/compact 压缩当前对话'); console.log('/config 查看当前配置'); console.log('/exit 退出程序'); console.log('/help 显示指令列表'); } /** * 打开命令菜单 * * 关键修复点: * 1. 在输入 / 后,光标位于提示行末尾 * 2. 我们不写换行符,而是直接在下一行开始渲染菜单 * 3. 使用 ANSI 序列精确控制光标位置 */ function openCommandMenu() { if (menuState.active) return; menuState.active = true; menuState.selected = 0; menuState.offset = 0; // 清空当前输入行(保留提示符) rl.line = ''; rl.cursor = 0; // 隐藏光标,暂停 readline rl.pause(); process.stdout.write(ANSI.HIDE_CURSOR); // 移动到行尾(确保我们在输入行的末尾) const currentLine = PROMPT + '/'; process.stdout.write(ANSI.CLEAR_LINE); process.stdout.write('\r' + currentLine); // 关键:移到下一行开始渲染菜单 process.stdout.write('\n'); renderCommandMenu(); } /** * 关闭命令菜单 * * 关键修复点: * 1. 向上移动到菜单开始的位置 * 2. 清除所有菜单行 * 3. 将光标移回输入行(在 / 后面) * 4. 恢复 readline 状态 */ async function closeCommandMenu() { if (!menuState.active) return; // 清除菜单(从当前位置向上清除) clearCommandMenu(); // 向上移动到输入行(菜单有 COMMAND_MENU_SIZE 行) process.stdout.write(ANSI.MOVE_UP(COMMAND_MENU_SIZE)); // 清除输入行并重新显示提示符 process.stdout.write('\r' + ANSI.CLEAR_LINE); process.stdout.write(PROMPT); // 显示光标 process.stdout.write(ANSI.SHOW_CURSOR); menuState.active = false; rl.resume(); } /** * 渲染命令菜单 * * 关键修复点: * 1. 菜单固定显示 6 行 * 2. 每次重新渲染时,先回到菜单起始位置 * 3. 使用 ANSI 序列清除并重写每一行 * 4. 保持光标位置稳定 */ function renderCommandMenu() { const total = COMMAND_MENU_ITEMS.length; // 计算滚动偏移 if (menuState.selected < menuState.offset) { menuState.offset = menuState.selected; } if (menuState.selected >= menuState.offset + COMMAND_MENU_SIZE) { menuState.offset = menuState.selected - COMMAND_MENU_SIZE + 1; } // 保存当前光标位置(用于后续恢复) // 实际上我们不需要保存,因为我们控制整个渲染过程 // 移到菜单开始位置(向上移动菜单行数) // 注意:此时光标在最后一行的末尾 process.stdout.write(ANSI.MOVE_UP(COMMAND_MENU_SIZE)); // 渲染每一行 const end = Math.min(total, menuState.offset + COMMAND_MENU_SIZE); let lineIndex = 0; for (let i = menuState.offset; i < menuState.offset + COMMAND_MENU_SIZE; i++) { // 移到行首并清除该行 process.stdout.write('\r' + ANSI.CLEAR_LINE); if (i < end) { const item = COMMAND_MENU_ITEMS[i]; const isSelected = i === menuState.selected; const cmdText = item.cmd.padEnd(12, ' '); const left = isSelected ? `${BLUE}▸ ${cmdText}${RESET}` : ` ${cmdText}`; const right = `${GREY}${item.desc}${RESET}`; process.stdout.write(`${left} ${right}`); } else { // 空白行(当项目少于 COMMAND_MENU_SIZE 时) process.stdout.write(''); } // 如果不是最后一行,移到下一行 lineIndex++; if (lineIndex < COMMAND_MENU_SIZE) { process.stdout.write('\n'); } } // 将光标移回菜单第一行(这样下次渲染时从正确的位置开始) process.stdout.write(ANSI.MOVE_UP(COMMAND_MENU_SIZE - 1)); } /** * 清除命令菜单 * * 从当前位置向上清除所有菜单行 */ function clearCommandMenu() { // 移到菜单底部(向下移动菜单行数-1,因为我们当前在第一行) process.stdout.write(ANSI.MOVE_DOWN(COMMAND_MENU_SIZE - 1)); // 从底部开始向上清除每一行 for (let i = 0; i < COMMAND_MENU_SIZE; i++) { process.stdout.write('\r' + ANSI.CLEAR_LINE); if (i < COMMAND_MENU_SIZE - 1) { process.stdout.write(ANSI.MOVE_UP(1)); } } }