From 90df8d307f242399aa48b3154de592dce13c2e2f Mon Sep 17 00:00:00 2001 From: Pushkar Kathayat Date: Sat, 14 Mar 2026 10:14:47 +0530 Subject: [PATCH] Rewrite CLI REPL with scroll region UI and live steering support Replace readline-based REPL with a custom raw-mode terminal UI using ANSI scroll regions. The input line is always visible at the bottom while streaming output scrolls above it. Layout: - Rows 1..(r-3): scroll region for streaming output - Row r-2: queue line showing pending steer message - Row r-1: separator - Row r: input line with fake block cursor Key changes: - Always-on raw mode with manual keystroke handling - Steer support: type during streaming, Enter sends agent.steer() - Queue line shows pending steer in yellow, echoes to scroll region when agent picks it up (appears after AI response, not inline) - Hidden real cursor with inverse-video fake cursor on input line - Proper vertical spacing between messages - Terminal resize handler redraws all fixed UI elements --- src/index.ts | 314 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 225 insertions(+), 89 deletions(-) diff --git a/src/index.ts b/src/index.ts index cd97c77..faa4880 100644 --- a/src/index.ts +++ b/src/index.ts @@ -472,20 +472,21 @@ async function main(): Promise { agent.subscribe((event) => handleEvent(event, hooksConfig, agentDir, sessionId, auditLogger)); - console.log(bold(`${manifest.name} v${manifest.version}`)); - console.log(dim(`Model: ${loaded.model.provider}:${loaded.model.id}`)); + let headerLines = 0; + console.log(bold(`${manifest.name} v${manifest.version}`)); headerLines++; + console.log(dim(`Model: ${loaded.model.provider}:${loaded.model.id}`)); headerLines++; const allToolNames = tools.map((t) => t.name); - console.log(dim(`Tools: ${allToolNames.join(", ")}`)); + console.log(dim(`Tools: ${allToolNames.join(", ")}`)); headerLines++; if (skills.length > 0) { - console.log(dim(`Skills: ${skills.map((s) => s.name).join(", ")}`)); + console.log(dim(`Skills: ${skills.map((s) => s.name).join(", ")}`)); headerLines++; } if (loaded.workflows.length > 0) { - console.log(dim(`Workflows: ${loaded.workflows.map((w) => w.name).join(", ")}`)); + console.log(dim(`Workflows: ${loaded.workflows.map((w) => w.name).join(", ")}`)); headerLines++; } if (loaded.subAgents.length > 0) { - console.log(dim(`Agents: ${loaded.subAgents.map((a) => a.name).join(", ")}`)); + console.log(dim(`Agents: ${loaded.subAgents.map((a) => a.name).join(", ")}`)); headerLines++; } - console.log(dim('Type /skills to list skills, /memory to view memory, /quit to exit\n')); + console.log(dim('Type /skills to list skills, /memory to view memory, /quit to exit\n')); headerLines += 2; // Single-shot mode if (prompt) { @@ -515,113 +516,248 @@ async function main(): Promise { return; } - // REPL mode - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }); + // Sandbox cleanup helper + const stopSandbox = async () => { + if (sandboxCtx) { + console.log(dim("Stopping sandbox...")); + await sandboxCtx.gitMachine.stop(); + } + }; - const ask = (): void => { - rl.question(green("→ "), async (input) => { - const trimmed = input.trim(); + // ── Unified REPL with fixed input line ─────────────────────── + // Always in raw mode. Scroll region keeps streaming output in + // the top area; input line is always visible at the bottom. + // No readline — we manage everything ourselves. - if (!trimmed) { - ask(); - return; + let inputBuffer = ""; + let isRunning = false; // true while agent.prompt() is in progress + let queueText = ""; // pending steer message shown on the queue line + + const rows = () => process.stdout.rows || 24; + const cols = () => process.stdout.columns || 80; + + // Layout (bottom 3 rows are reserved): + // rows 1..(r-3) — scroll region (streaming output) + // row r-2 — queue line (pending steer message) + // row r-1 — separator + // row r — input line + + const drawQueueLine = () => { + const r = rows(); + if (queueText) { + process.stdout.write(`\x1b7\x1b[${r - 2};1H\x1b[2K\x1b[33m⤷ ${queueText}\x1b[0m\x1b8`); + } else { + process.stdout.write(`\x1b7\x1b[${r - 2};1H\x1b[2K\x1b8`); + } + }; + + const drawSeparator = () => { + const r = rows(); + process.stdout.write(`\x1b7\x1b[${r - 1};1H\x1b[2K\x1b[2m${"─".repeat(cols())}\x1b[0m\x1b8`); + }; + + const drawInputLine = () => { + const r = rows(); + const prompt = isRunning ? `\x1b[2m⤷\x1b[0m ` : `\x1b[32m→\x1b[0m `; + // Fake block cursor (inverse video space) shows typing position + const cursor = `\x1b[7m \x1b[0m`; + // Save/restore keeps the real (hidden) cursor in the scroll region + process.stdout.write(`\x1b7\x1b[${r};1H\x1b[2K${prompt}${inputBuffer}${cursor}\x1b8`); + }; + + const initUI = (cursorRow: number) => { + if (!process.stdout.isTTY) return; + const r = rows(); + // Hide real cursor — we use a fake cursor on the input line + process.stdout.write(`\x1b[?25l`); + // Set scroll region: rows 1 to (r-3), leaving 3 rows for queue + separator + input + process.stdout.write(`\x1b[1;${r - 3}r`); + drawQueueLine(); + drawSeparator(); + drawInputLine(); + // Reposition cursor after header so new output doesn't overwrite it + // (setting scroll region resets cursor to row 1) + process.stdout.write(`\x1b[${cursorRow};1H`); + }; + + const cleanupUI = () => { + if (!process.stdout.isTTY) return; + // Show real cursor again + process.stdout.write(`\x1b[?25h`); + // Reset scroll region, clear bottom lines + const r = rows(); + process.stdout.write(`\x1b[${r - 2};1H\x1b[2K`); + process.stdout.write(`\x1b[${r - 1};1H\x1b[2K`); + process.stdout.write(`\x1b[${r};1H\x1b[2K`); + process.stdout.write(`\x1b[r`); + }; + + // Handle a command (when agent is idle) + const handleCommand = async (text: string) => { + if (text === "/quit" || text === "/exit") { + cleanupUI(); + if (process.stdin.isTTY) process.stdin.setRawMode(false); + console.log("Bye!"); + if (localSession) { + try { localSession.finalize(); } catch { /* best-effort */ } } + await stopSandbox(); + process.exit(0); + } - if (trimmed === "/quit" || trimmed === "/exit") { - rl.close(); - if (localSession) { - console.log(dim("Finalizing session...")); - localSession.finalize(); - } - await stopSandbox(); - process.exit(0); + if (text === "/memory") { + try { + const mem = await readFile(join(dir, "memory/MEMORY.md"), "utf-8"); + process.stdout.write(dim("--- memory ---\n")); + process.stdout.write((mem.trim() || "(empty)") + "\n"); + process.stdout.write(dim("--- end ---\n")); + } catch { + process.stdout.write(dim("(no memory file)\n")); } + drawInputLine(); + return; + } - if (trimmed === "/memory") { - try { - const mem = await readFile(join(dir, "memory/MEMORY.md"), "utf-8"); - console.log(dim("--- memory ---")); - console.log(mem.trim() || "(empty)"); - console.log(dim("--- end ---")); - } catch { - console.log(dim("(no memory file)")); + if (text === "/skills") { + if (skills.length === 0) { + process.stdout.write(dim("No skills installed.\n")); + } else { + for (const s of skills) { + process.stdout.write(` ${bold(s.name)} — ${dim(s.description)}\n`); } - ask(); - return; } + drawInputLine(); + return; + } - if (trimmed === "/skills") { - if (skills.length === 0) { - console.log(dim("No skills installed.")); - } else { - for (const s of skills) { - console.log(` ${bold(s.name)} — ${dim(s.description)}`); - } - } - ask(); + // Skill expansion + let promptText = text; + if (text.startsWith("/skill:")) { + const result = await expandSkillCommand(text, skills); + if (result) { + process.stdout.write(dim(`▶ loading skill: ${result.skillName}\n`)); + promptText = result.expanded; + } else { + const requested = text.match(/^\/skill:([a-z0-9-]*)/)?.[1] || "?"; + process.stdout.write(red(`Unknown skill: ${requested}\n`)); + drawInputLine(); return; } + } + + // Send prompt to agent + isRunning = true; + drawInputLine(); // switch prompt from → to ⤷ + process.stdout.write(`\n${green(`→ ${text}`)}\n`); // blank line + echo user input + + try { + await agent.prompt(promptText); + } catch (err: any) { + process.stdout.write(red(`Error: ${err.message}\n`)); + auditLogger?.logError(err.message).catch(() => {}); + if (hooksConfig?.hooks.on_error) { + runHooks(hooksConfig.hooks.on_error, agentDir, { + event: "on_error", + session_id: sessionId, + error: err.message, + }).catch(() => {}); + } + } + + isRunning = false; + queueText = ""; + drawQueueLine(); // clear steer message + drawInputLine(); // switch prompt back from ⤷ to → + }; - // Skill expansion: /skill:name [args] - let promptText = trimmed; - if (trimmed.startsWith("/skill:")) { - const result = await expandSkillCommand(trimmed, skills); - if (result) { - console.log(dim(`▶ loading skill: ${result.skillName}`)); - promptText = result.expanded; - } else { - const requested = trimmed.match(/^\/skill:([a-z0-9-]*)/)?.[1] || "?"; - console.error(red(`Unknown skill: ${requested}`)); - ask(); - return; + const onKeystroke = (key: Buffer) => { + const ch = key.toString("utf-8"); + const code = key[0]; + + // Ctrl+C + if (code === 3) { + if (isRunning) { + inputBuffer = ""; + drawInputLine(); + agent.abort(); + } else { + cleanupUI(); + if (process.stdin.isTTY) process.stdin.setRawMode(false); + console.log("\nBye!"); + if (localSession) { + try { localSession.finalize(); } catch { /* best-effort */ } } + stopSandbox().finally(() => process.exit(0)); } + return; + } - try { - await agent.prompt(promptText); - } catch (err: any) { - console.error(red(`Error: ${err.message}`)); - auditLogger?.logError(err.message).catch(() => {}); - // Fire on_error hooks - if (hooksConfig?.hooks.on_error) { - runHooks(hooksConfig.hooks.on_error, agentDir, { - event: "on_error", - session_id: sessionId, - error: err.message, - }).catch(() => {}); - } + // Enter + if (code === 13 || code === 10) { + const text = inputBuffer.trim(); + inputBuffer = ""; + drawInputLine(); + if (!text) return; + + if (isRunning) { + // Show on queue line until agent picks it up + queueText = text; + drawQueueLine(); + agent.steer({ + role: "user", + content: text, + timestamp: Date.now(), + }); + } else { + // Send as new prompt + handleCommand(text); } + return; + } - ask(); - }); - }; + // Backspace + if (code === 127 || code === 8) { + inputBuffer = inputBuffer.slice(0, -1); + drawInputLine(); + return; + } - // Sandbox cleanup helper - const stopSandbox = async () => { - if (sandboxCtx) { - console.log(dim("Stopping sandbox...")); - await sandboxCtx.gitMachine.stop(); + // Printable characters + if (code >= 32) { + inputBuffer += ch; + drawInputLine(); } }; - // Handle Ctrl+C during streaming - rl.on("SIGINT", () => { - if (agent.state.isStreaming) { - agent.abort(); - } else { - console.log("\nBye!"); - rl.close(); - if (localSession) { - try { localSession.finalize(); } catch { /* best-effort */ } + // Handle terminal resize — redraw all fixed lines + process.stdout.on("resize", () => { + const r = rows(); + process.stdout.write(`\x1b[1;${r - 3}r`); + drawQueueLine(); + drawSeparator(); + drawInputLine(); + }); + + // Echo steer messages in scroll region when agent picks them up + agent.subscribe((event) => { + if (event.type === "message_start") { + const msg = event.message as any; + if (msg?.role === "user" && queueText) { + // Agent picked up the steer — echo it in the scroll region + process.stdout.write(`\x1b[33m⤷ ${queueText}\x1b[0m\n`); + queueText = ""; + drawQueueLine(); } - stopSandbox().finally(() => process.exit(0)); } }); - ask(); + // Start the UI + initUI(headerLines + 1); + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on("data", onKeystroke); + } } main().catch((err) => {