mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-26 15:45:50 +00:00
update
This commit is contained in:
parent
a20acaba6b
commit
a5175eb7b0
1 changed files with 279 additions and 36 deletions
315
cli/src/repl.ts
315
cli/src/repl.ts
|
|
@ -31,6 +31,16 @@ import type { CliConfig, SSEEvent } from './types.js';
|
|||
import { AgentStep } from './types.js';
|
||||
|
||||
const PROMPT = chalk.bold.cyan('\u276F ');
|
||||
const INPUT_BG = chalk.bgHex('#2a2a3a');
|
||||
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
|
||||
const SLASH_COMMANDS = [
|
||||
{ name: '/new', description: 'Start a new project' },
|
||||
{ name: '/workspace', description: 'Show or set workspace path' },
|
||||
{ name: '/project', description: 'Show current project ID' },
|
||||
{ name: '/stop', description: 'Stop the current task' },
|
||||
{ name: '/quit', description: 'Exit the CLI' },
|
||||
];
|
||||
|
||||
function showPrompt(rl: readline.Interface): void {
|
||||
const cols = process.stdout.columns || 80;
|
||||
|
|
@ -38,6 +48,17 @@ function showPrompt(rl: readline.Interface): void {
|
|||
rl.prompt();
|
||||
}
|
||||
|
||||
/** Overwrite readline echo with styled input (background color strip). */
|
||||
function echoInput(text: string): void {
|
||||
const cols = process.stdout.columns || 80;
|
||||
const prefix = '\u276F ';
|
||||
const padding = Math.max(0, cols - prefix.length - text.length);
|
||||
process.stdout.write('\x1B[1A\x1B[2K');
|
||||
console.log(
|
||||
INPUT_BG(chalk.bold.cyan(prefix) + chalk.bold(text) + ' '.repeat(padding))
|
||||
);
|
||||
}
|
||||
|
||||
function shortenPath(p: string): string {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
if (home && p.startsWith(home)) return '~' + p.slice(home.length);
|
||||
|
|
@ -59,36 +80,40 @@ export async function startRepl(config: CliConfig): Promise<void> {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(
|
||||
chalk.bold.cyan(' ███████╗██╗ ██████╗ ███████╗███╗ ██╗████████╗')
|
||||
);
|
||||
console.log(
|
||||
chalk.bold.cyan(' ██╔════╝██║██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝')
|
||||
);
|
||||
console.log(
|
||||
chalk.bold.cyan(' █████╗ ██║██║ ███╗█████╗ ██╔██╗ ██║ ██║')
|
||||
);
|
||||
console.log(
|
||||
chalk.bold.cyan(' ██╔══╝ ██║██║ ██║██╔══╝ ██║╚██╗██║ ██║')
|
||||
);
|
||||
console.log(
|
||||
chalk.bold.cyan(' ███████╗██║╚██████╔╝███████╗██║ ╚████║ ██║')
|
||||
);
|
||||
console.log(
|
||||
chalk.bold.cyan(' ╚══════╝╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝')
|
||||
);
|
||||
console.log();
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` v0.1.0 | ${config.modelPlatform}/${config.modelType} | ${config.apiUrl}`
|
||||
)
|
||||
);
|
||||
console.log(chalk.dim(` workspace: ${workspace}`));
|
||||
console.log(
|
||||
chalk.dim(' /quit, /new, /project, /workspace <path>, Ctrl+C to stop')
|
||||
);
|
||||
console.log();
|
||||
function showBanner(): void {
|
||||
console.log();
|
||||
console.log(
|
||||
chalk.bold.cyan(' ███████╗██╗ ██████╗ ███████╗███╗ ██╗████████╗')
|
||||
);
|
||||
console.log(
|
||||
chalk.bold.cyan(' ██╔════╝██║██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝')
|
||||
);
|
||||
console.log(
|
||||
chalk.bold.cyan(' █████╗ ██║██║ ███╗█████╗ ██╔██╗ ██║ ██║')
|
||||
);
|
||||
console.log(
|
||||
chalk.bold.cyan(' ██╔══╝ ██║██║ ██║██╔══╝ ██║╚██╗██║ ██║')
|
||||
);
|
||||
console.log(
|
||||
chalk.bold.cyan(' ███████╗██║╚██████╔╝███████╗██║ ╚████║ ██║')
|
||||
);
|
||||
console.log(
|
||||
chalk.bold.cyan(' ╚══════╝╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝')
|
||||
);
|
||||
console.log();
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` v0.1.0 | ${config.modelPlatform}/${config.modelType} | ${config.apiUrl}`
|
||||
)
|
||||
);
|
||||
console.log(chalk.dim(` workspace: ${workspace}`));
|
||||
console.log(
|
||||
chalk.dim(' /quit, /new, /project, /workspace <path>, Ctrl+C to stop')
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
showBanner();
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
|
|
@ -96,7 +121,8 @@ export async function startRepl(config: CliConfig): Promise<void> {
|
|||
prompt: PROMPT,
|
||||
});
|
||||
|
||||
// Keypress handler for Ctrl+E (expand collapsed output)
|
||||
// Keypress handler for Ctrl+E and "/" command picker trigger
|
||||
let pickerActive = false;
|
||||
process.stdin.on('keypress', (_str: string, key: readline.Key) => {
|
||||
if (!isStreaming && key && key.ctrl && key.name === 'e') {
|
||||
const block = getLastCollapsedBlock();
|
||||
|
|
@ -107,6 +133,52 @@ export async function startRepl(config: CliConfig): Promise<void> {
|
|||
showPrompt(rl);
|
||||
}
|
||||
}
|
||||
|
||||
// "/" typed on empty line → immediately open command picker
|
||||
const currentLine = (rl as any).line as string | undefined;
|
||||
if (_str === '/' && currentLine === '/' && !pickerActive && !isStreaming) {
|
||||
pickerActive = true;
|
||||
|
||||
// Overwrite readline's echoed "❯ /" with styled version
|
||||
process.stdout.write('\r\x1B[2K');
|
||||
const cols = process.stdout.columns || 80;
|
||||
const pfx = '\u276F ';
|
||||
const pad = Math.max(0, cols - pfx.length - 1);
|
||||
console.log(
|
||||
INPUT_BG(chalk.bold.cyan(pfx) + chalk.bold('/') + ' '.repeat(pad))
|
||||
);
|
||||
|
||||
// Open picker (it handles stdin takeover internally)
|
||||
pickCommand('')
|
||||
.then(async (picked) => {
|
||||
pickerActive = false;
|
||||
// Clear readline's internal buffer so it doesn't have stale "/"
|
||||
(rl as any).line = '';
|
||||
(rl as any).cursor = 0;
|
||||
|
||||
if (picked) {
|
||||
// Overwrite the "❯ /" line with the picked command
|
||||
process.stdout.write('\x1B[1A\x1B[2K');
|
||||
const pad2 = Math.max(0, cols - pfx.length - picked.length);
|
||||
console.log(
|
||||
INPUT_BG(
|
||||
chalk.bold.cyan(pfx) + chalk.bold(picked) + ' '.repeat(pad2)
|
||||
)
|
||||
);
|
||||
await handleMessage(picked, true);
|
||||
} else {
|
||||
// Cancelled — erase the styled "/" line, show clean prompt
|
||||
process.stdout.write('\x1B[1A\x1B[2K');
|
||||
showPrompt(rl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
pickerActive = false;
|
||||
(rl as any).line = '';
|
||||
(rl as any).cursor = 0;
|
||||
showPrompt(rl);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Ctrl+C
|
||||
|
|
@ -125,13 +197,164 @@ export async function startRepl(config: CliConfig): Promise<void> {
|
|||
}
|
||||
});
|
||||
|
||||
async function handleMessage(input: string): Promise<void> {
|
||||
/**
|
||||
* Interactive command picker using raw stdin data events.
|
||||
* Bypasses readline/keypress entirely — parses ANSI escape sequences
|
||||
* from raw bytes for maximum reliability.
|
||||
*/
|
||||
function pickCommand(initialFilter: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let selected = 0;
|
||||
let filter = initialFilter;
|
||||
let lastDrawn = 0;
|
||||
let resolved = false;
|
||||
|
||||
function getFiltered() {
|
||||
return SLASH_COMMANDS.filter((c) => c.name.startsWith('/' + filter));
|
||||
}
|
||||
|
||||
function drawMenu(): void {
|
||||
if (lastDrawn > 0) {
|
||||
process.stdout.write(`\x1B[${lastDrawn}A\x1B[J`);
|
||||
}
|
||||
const filtered = getFiltered();
|
||||
if (filtered.length === 0) {
|
||||
lastDrawn = 0;
|
||||
} else {
|
||||
for (let i = 0; i < filtered.length; i++) {
|
||||
const cmd = filtered[i];
|
||||
if (i === selected) {
|
||||
console.log(
|
||||
` ${chalk.cyan('\u25B8')} ${chalk.cyan.bold(cmd.name.padEnd(13))}${cmd.description}`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
` ${chalk.dim(cmd.name.padEnd(13))}${chalk.dim(cmd.description)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
lastDrawn = filtered.length;
|
||||
}
|
||||
}
|
||||
|
||||
function updateInputLine(): void {
|
||||
const cols = process.stdout.columns || 80;
|
||||
const text = '/' + filter;
|
||||
const pfx = '\u276F ';
|
||||
const pad = Math.max(0, cols - pfx.length - text.length);
|
||||
// Move up past menu + input line, clear everything below
|
||||
process.stdout.write(`\x1B[${lastDrawn + 1}A\x1B[J`);
|
||||
console.log(
|
||||
INPUT_BG(chalk.bold.cyan(pfx) + chalk.bold(text) + ' '.repeat(pad))
|
||||
);
|
||||
lastDrawn = 0;
|
||||
drawMenu();
|
||||
}
|
||||
|
||||
function finish(result: string | null): void {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
process.stdin.removeListener('data', onData);
|
||||
if (lastDrawn > 0) {
|
||||
process.stdout.write(`\x1B[${lastDrawn}A\x1B[J`);
|
||||
}
|
||||
// Restore all saved listeners and resume readline
|
||||
for (const fn of savedDataListeners) {
|
||||
process.stdin.on('data', fn as (...args: any[]) => void);
|
||||
}
|
||||
for (const fn of savedKpListeners) {
|
||||
process.stdin.on('keypress', fn as (...args: any[]) => void);
|
||||
}
|
||||
rl.resume();
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
function onData(buf: Buffer): void {
|
||||
const s = buf.toString();
|
||||
const filtered = getFiltered();
|
||||
|
||||
if (s === '\x1B[A' || s === '\x1BOA') {
|
||||
// Arrow up
|
||||
if (filtered.length > 0) {
|
||||
selected = (selected - 1 + filtered.length) % filtered.length;
|
||||
drawMenu();
|
||||
}
|
||||
} else if (s === '\x1B[B' || s === '\x1BOB') {
|
||||
// Arrow down
|
||||
if (filtered.length > 0) {
|
||||
selected = (selected + 1) % filtered.length;
|
||||
drawMenu();
|
||||
}
|
||||
} else if (s === '\r' || s === '\n') {
|
||||
// Enter
|
||||
if (filtered.length > 0 && selected < filtered.length) {
|
||||
finish(filtered[selected].name);
|
||||
} else if (filter.length > 0) {
|
||||
// No match — return typed text as plain input
|
||||
finish('/' + filter);
|
||||
} else {
|
||||
finish(null);
|
||||
}
|
||||
} else if (s === '\x1B') {
|
||||
// Escape
|
||||
finish(null);
|
||||
} else if (s === '\x03') {
|
||||
// Ctrl+C
|
||||
finish(null);
|
||||
} else if (s === '\x7F' || s === '\b') {
|
||||
// Backspace
|
||||
if (filter.length > 0) {
|
||||
filter = filter.slice(0, -1);
|
||||
selected = 0;
|
||||
updateInputLine();
|
||||
} else {
|
||||
finish(null);
|
||||
}
|
||||
} else if (s.length === 1 && s >= ' ') {
|
||||
// Regular character — type to filter
|
||||
filter += s;
|
||||
selected = 0;
|
||||
updateInputLine();
|
||||
}
|
||||
}
|
||||
|
||||
// Save and remove ALL data + keypress listeners to prevent
|
||||
// emitKeypressEvents interference with our raw data handler
|
||||
const savedKpListeners = process.stdin.rawListeners('keypress').slice();
|
||||
const savedDataListeners = process.stdin.rawListeners('data').slice();
|
||||
process.stdin.removeAllListeners('keypress');
|
||||
process.stdin.removeAllListeners('data');
|
||||
|
||||
// Pause readline (releases stdin), then take raw control
|
||||
rl.pause();
|
||||
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
|
||||
// Register handler before resume to avoid missing events
|
||||
process.stdin.on('data', onData);
|
||||
process.stdin.resume();
|
||||
|
||||
// Draw initial menu
|
||||
drawMenu();
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMessage(
|
||||
input: string,
|
||||
fromPicker = false
|
||||
): Promise<void> {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
showPrompt(rl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Overwrite readline echo with styled input (bg color strip)
|
||||
if (!fromPicker) {
|
||||
echoInput(trimmed);
|
||||
}
|
||||
|
||||
// Slash commands
|
||||
if (trimmed === '/quit' || trimmed === '/exit') {
|
||||
console.log(chalk.dim('Goodbye!'));
|
||||
|
|
@ -150,6 +373,8 @@ export async function startRepl(config: CliConfig): Promise<void> {
|
|||
client.abort();
|
||||
stream = null;
|
||||
client.newSession();
|
||||
console.clear();
|
||||
showBanner();
|
||||
console.log(chalk.dim('Starting new project.'));
|
||||
showPrompt(rl);
|
||||
return;
|
||||
|
|
@ -186,10 +411,6 @@ export async function startRepl(config: CliConfig): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
// Overwrite readline echo with styled user input
|
||||
process.stdout.write('\x1B[1A\x1B[2K');
|
||||
console.log(chalk.bold.cyan('\u276F ') + chalk.bold(trimmed));
|
||||
|
||||
const timer = new TaskTimer();
|
||||
timer.start();
|
||||
clearBlocks();
|
||||
|
|
@ -197,6 +418,24 @@ export async function startRepl(config: CliConfig): Promise<void> {
|
|||
isStreaming = true;
|
||||
let hasConfirmed = false;
|
||||
|
||||
// Spinner while waiting for first response
|
||||
let spinnerIdx = 0;
|
||||
const spinner = setInterval(() => {
|
||||
const frame = chalk.cyan(
|
||||
SPINNER_FRAMES[spinnerIdx % SPINNER_FRAMES.length]
|
||||
);
|
||||
process.stdout.write(`\r\x1B[2K ${frame} ${chalk.dim('Thinking...')}`);
|
||||
spinnerIdx++;
|
||||
}, 80);
|
||||
let spinnerCleared = false;
|
||||
function clearSpinner(): void {
|
||||
if (!spinnerCleared) {
|
||||
spinnerCleared = true;
|
||||
clearInterval(spinner);
|
||||
process.stdout.write('\r\x1B[2K');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!stream) {
|
||||
// First message — open persistent SSE connection
|
||||
|
|
@ -212,6 +451,7 @@ export async function startRepl(config: CliConfig): Promise<void> {
|
|||
const { value: event, done } = await stream.next();
|
||||
if (done || !event) break;
|
||||
|
||||
clearSpinner();
|
||||
const rendered = renderEvent(event);
|
||||
|
||||
if (rendered.output) {
|
||||
|
|
@ -261,6 +501,7 @@ export async function startRepl(config: CliConfig): Promise<void> {
|
|||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
clearSpinner();
|
||||
if (err.name !== 'AbortError') {
|
||||
console.log(chalk.red(`Error: ${err.message}`));
|
||||
}
|
||||
|
|
@ -268,12 +509,14 @@ export async function startRepl(config: CliConfig): Promise<void> {
|
|||
stream = null;
|
||||
}
|
||||
|
||||
clearSpinner();
|
||||
isStreaming = false;
|
||||
console.log(chalk.dim(`\n\u2733 Completed in ${timer.format()}`));
|
||||
showPrompt(rl);
|
||||
}
|
||||
|
||||
rl.on('line', (input) => {
|
||||
if (pickerActive) return; // Picker is handling input
|
||||
handleMessage(input).catch((err) => {
|
||||
console.error(chalk.red(`Unexpected error: ${err.message}`));
|
||||
stream = null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue