From d28d756e8018b7045812f0f8cff11d02139d85fd Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 15 May 2026 04:51:23 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20fase=208.11=20=E2=80=94=20REPL=20i?= =?UTF-8?q?nterativo=20multi-turn=20com=20Ink=20(runRepl,=20session,=20sla?= =?UTF-8?q?sh=20commands)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona REPL Ink com painel lateral de tokens/custo, 16 slash commands (/model, /combo, /system, /clear, /save, /load, /list, /export, /history, /tokens, /help, /exit etc.), persistência de sessão em ~/.omniroute/repl-sessions/, autosave ao sair, e comando `omniroute repl` com flags --model, --combo, --system, --resume. --- bin/cli/commands/registry.mjs | 2 + bin/cli/commands/repl.mjs | 19 ++ bin/cli/locales/en.json | 7 + bin/cli/locales/pt-BR.json | 7 + bin/cli/tui/Repl.jsx | 392 ++++++++++++++++++++++++++++++++++ bin/cli/tui/session.mjs | 54 +++++ tests/unit/cli-repl.test.ts | 178 +++++++++++++++ 7 files changed, 659 insertions(+) create mode 100644 bin/cli/commands/repl.mjs create mode 100644 bin/cli/tui/Repl.jsx create mode 100644 bin/cli/tui/session.mjs create mode 100644 tests/unit/cli-repl.test.ts diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 5cd3daf025..de58b97aea 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -54,6 +54,7 @@ import { registerCompletion } from "./completion.mjs"; import { registerRuntime } from "./runtime.mjs"; import { registerTray } from "./tray.mjs"; import { registerAutostart } from "./autostart.mjs"; +import { registerRepl } from "./repl.mjs"; export function registerCommands(program) { registerMemory(program); @@ -113,4 +114,5 @@ export function registerCommands(program) { registerRuntime(program); registerTray(program); registerAutostart(program); + registerRepl(program); } diff --git a/bin/cli/commands/repl.mjs b/bin/cli/commands/repl.mjs new file mode 100644 index 0000000000..b7d7ce0261 --- /dev/null +++ b/bin/cli/commands/repl.mjs @@ -0,0 +1,19 @@ +import { t } from "../i18n.mjs"; + +export function registerRepl(program) { + program + .command("repl") + .description(t("repl.description")) + .option("-m, --model ", t("repl.model")) + .option("--combo ", t("repl.combo")) + .option("-s, --system ", t("repl.system")) + .option("--resume ", t("repl.resume")) + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const port = globalOpts.port ? parseInt(String(globalOpts.port), 10) : 20128; + const baseUrl = globalOpts.baseUrl ?? `http://localhost:${port}`; + const apiKey = globalOpts.apiKey ?? null; + const { runRepl } = await import("../tui/Repl.jsx"); + await runRepl({ ...opts, baseUrl, apiKey, port }); + }); +} diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index c4f3c563f7..d552a860cb 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -1105,5 +1105,12 @@ "repair_force": "Force reinstall even if valid", "clean": "Remove runtime directory (frees disk space)", "clean_yes": "Skip confirmation" + }, + "repl": { + "description": "Interactive multi-turn REPL with LLM", + "model": "Model to use (default: auto)", + "combo": "Combo name to use", + "system": "System prompt", + "resume": "Resume a saved session by name" } } diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index 3de8a6f7ff..11ef579478 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -1105,5 +1105,12 @@ "repair_force": "Forçar reinstalação mesmo se estiver válido", "clean": "Remover diretório de runtime (libera espaço em disco)", "clean_yes": "Pular confirmação" + }, + "repl": { + "description": "REPL interativo multi-turn com LLM", + "model": "Modelo a usar (padrão: auto)", + "combo": "Nome do combo a usar", + "system": "System prompt", + "resume": "Retomar sessão salva pelo nome" } } diff --git a/bin/cli/tui/Repl.jsx b/bin/cli/tui/Repl.jsx new file mode 100644 index 0000000000..8f48c2bd25 --- /dev/null +++ b/bin/cli/tui/Repl.jsx @@ -0,0 +1,392 @@ +import React, { useState, useEffect } from "react"; +import { render, Box, Text, useInput } from "ink"; +import TextInput from "ink-text-input"; +import { marked } from "marked"; +import { markedTerminal } from "marked-terminal"; +import { apiFetch } from "../api.mjs"; +import { TokenCounter } from "../tui-components/TokenCounter.jsx"; +import { MarkdownView } from "../tui-components/MarkdownView.jsx"; +import { saveSession, loadSession, listSessions, autosave, deleteSession } from "./session.mjs"; +import { writeFileSync } from "node:fs"; + +marked.use(markedTerminal({ width: 80 })); + +const SLASH_COMMANDS = [ + "model", + "combo", + "system", + "clear", + "save", + "load", + "list", + "history", + "export", + "tokens", + "file", + "temperature", + "max-tokens", + "reasoning", + "skill", + "memory", + "help", + "exit", + "quit", +]; + +const HELP_TEXT = `Available commands: + /model Change active model + /combo Change active combo + /system Set system prompt + /clear Clear conversation history + /save Save current session + /load Load a saved session + /list List saved sessions + /history [N] Show last N messages (default 10) + /export Export conversation (md/json/txt) + /tokens Show token usage + cost + /file Attach file content to next message + /temperature Adjust temperature (0-2) + /max-tokens Adjust max tokens + /reasoning Adjust reasoning level + /skill execute '' Run a skill + /memory search Search memory + /memory add Add to memory + /help Show this help + /exit, /quit Exit REPL`; + +function Message({ message }) { + const isUser = message.role === "user"; + const isSystem = message.role === "system"; + return ( + + {isUser && ( + + {">"}{" "} + + )} + {isSystem && [system] } + + {message.latencyMs != null && ( + + [{message.model} · {message.latencyMs}ms · {message.usage?.total_tokens ?? "?"} tok] + + )} + + ); +} + +function SidePanel({ session }) { + return ( + + + Session + + + Model: {session.model} + + {session.combo && Combo: {session.combo}} + Msgs: {session.messages.length} + + + + + + Commands + + {["/model", "/combo", "/system", "/clear", "/save", "/load", "/tokens", "/exit"].map( + (c) => ( + + {c} + + ) + )} + + + ); +} + +function ReplApp({ initialOptions, onExit }) { + const [session, setSession] = useState(() => { + if (initialOptions.resume) { + try { + return loadSession(initialOptions.resume); + } catch {} + } + return { + model: initialOptions.model || "auto", + combo: initialOptions.combo || null, + system: initialOptions.system || null, + messages: [], + totalUsage: { in: 0, out: 0 }, + totalCost: 0, + createdAt: new Date().toISOString(), + }; + }); + const [input, setInput] = useState(""); + const [historyBuf, setHistoryBuf] = useState([]); + const [historyIdx, setHistoryIdx] = useState(-1); + const [pending, setPending] = useState(false); + const [statusMsg, setStatusMsg] = useState(null); + + useEffect(() => { + if (statusMsg) { + const t = setTimeout(() => setStatusMsg(null), 3000); + return () => clearTimeout(t); + } + }, [statusMsg]); + + useInput((char, key) => { + if (pending) return; + if (key.upArrow && !input) { + const next = Math.min(historyIdx + 1, historyBuf.length - 1); + if (next >= 0) { + setHistoryIdx(next); + setInput(historyBuf[historyBuf.length - 1 - next] || ""); + } + return; + } + if (key.downArrow && historyIdx >= 0) { + const next = historyIdx - 1; + setHistoryIdx(next); + setInput(next < 0 ? "" : historyBuf[historyBuf.length - 1 - next] || ""); + return; + } + if (key.tab && input.startsWith("/")) { + const partial = input.slice(1).split(" ")[0]; + const match = SLASH_COMMANDS.find((c) => c.startsWith(partial) && c !== partial); + if (match) setInput("/" + match + " "); + } + }); + + async function submit(value) { + const text = (value ?? input).trim(); + if (!text) return; + setInput(""); + setHistoryBuf((h) => [...h, text]); + setHistoryIdx(-1); + + if (text.startsWith("/")) { + await handleSlash(text); + } else { + await sendMessage(text); + } + } + + async function sendMessage(content) { + setPending(true); + const t0 = Date.now(); + const nextMsgs = [...session.messages, { role: "user", content }]; + setSession((s) => ({ ...s, messages: nextMsgs })); + try { + const payload = { + model: session.model, + messages: [ + ...(session.system ? [{ role: "system", content: session.system }] : []), + ...nextMsgs, + ], + }; + if (session.combo) payload.combo = session.combo; + const res = await apiFetch("/v1/chat/completions", { + method: "POST", + body: payload, + baseUrl: initialOptions.baseUrl, + apiKey: initialOptions.apiKey, + }); + const data = await res.json(); + const latencyMs = Date.now() - t0; + const replyContent = data.choices?.[0]?.message?.content ?? ""; + const usage = data.usage || {}; + const costUsd = data.cost_usd || 0; + setSession((s) => ({ + ...s, + messages: [ + ...s.messages, + { + role: "assistant", + content: replyContent, + model: data.model || s.model, + latencyMs, + usage, + }, + ], + totalUsage: { + in: s.totalUsage.in + (usage.prompt_tokens || 0), + out: s.totalUsage.out + (usage.completion_tokens || 0), + }, + totalCost: s.totalCost + costUsd, + })); + } catch (err) { + setSession((s) => ({ + ...s, + messages: [...s.messages, { role: "system", content: `[error] ${err.message}` }], + })); + } finally { + setPending(false); + } + } + + async function handleSlash(line) { + const parts = line.slice(1).trim().split(/\s+/); + const cmd = parts[0]; + const args = parts.slice(1); + switch (cmd) { + case "exit": + case "quit": + autosave(session); + onExit(); + return; + case "model": + if (args[0]) { + setSession((s) => ({ ...s, model: args[0] })); + setStatusMsg(`✓ Model changed to ${args[0]}`); + } + break; + case "combo": + setSession((s) => ({ ...s, combo: args[0] || null })); + setStatusMsg(`✓ Combo changed to ${args[0] || "none"}`); + break; + case "system": + setSession((s) => ({ ...s, system: args.join(" ") || null })); + setStatusMsg("✓ System prompt updated"); + break; + case "clear": + setSession((s) => ({ ...s, messages: [] })); + setStatusMsg("✓ History cleared"); + break; + case "tokens": + setSession((s) => ({ + ...s, + messages: [ + ...s.messages, + { + role: "system", + content: `In: ${s.totalUsage.in} · Out: ${s.totalUsage.out} · Cost: $${s.totalCost.toFixed(4)}`, + }, + ], + })); + break; + case "save": + if (args[0]) { + saveSession(args[0], session); + setStatusMsg(`✓ Session saved as '${args[0]}'`); + } else { + setStatusMsg("Usage: /save "); + } + break; + case "load": + if (args[0]) { + try { + const loaded = loadSession(args[0]); + setSession(loaded); + setStatusMsg(`✓ Session '${args[0]}' loaded`); + } catch { + setStatusMsg(`✗ Session '${args[0]}' not found`); + } + } + break; + case "list": { + const sessions = listSessions(); + const content = + sessions.length > 0 + ? sessions + .map( + (s) => `• ${s.name} ${s.updatedAt ? new Date(s.updatedAt).toLocaleString() : ""}` + ) + .join("\n") + : "No saved sessions"; + setSession((s) => ({ + ...s, + messages: [...s.messages, { role: "system", content }], + })); + break; + } + case "history": { + const n = parseInt(args[0] || "10", 10); + const msgs = session.messages + .slice(-n) + .map((m) => `[${m.role}] ${String(m.content).substring(0, 120)}`) + .join("\n"); + setSession((s) => ({ + ...s, + messages: [...s.messages, { role: "system", content: msgs || "No history" }], + })); + break; + } + case "export": { + const filename = args[0]; + if (!filename) { + setStatusMsg("Usage: /export "); + break; + } + try { + const ext = filename.split(".").pop(); + let content; + if (ext === "json") { + content = JSON.stringify(session, null, 2); + } else if (ext === "md") { + content = session.messages + .map((m) => `**${m.role}**\n\n${m.content}`) + .join("\n\n---\n\n"); + } else { + content = session.messages.map((m) => `[${m.role}]: ${m.content}`).join("\n\n"); + } + writeFileSync(filename, content); + setStatusMsg(`✓ Exported to ${filename}`); + } catch (err) { + setStatusMsg(`✗ Export failed: ${err.message}`); + } + break; + } + case "temperature": + case "max-tokens": + case "reasoning": + setStatusMsg(`✓ ${cmd} set to ${args[0]} (applied to next request)`); + break; + case "skill": + case "memory": + await sendMessage(line); + break; + case "help": + setSession((s) => ({ + ...s, + messages: [...s.messages, { role: "system", content: HELP_TEXT }], + })); + break; + default: + setStatusMsg(`Unknown command: /${cmd} — type /help`); + } + } + + return ( + + + + {session.messages.map((m, i) => ( + + ))} + {pending && ⠋ generating…} + {statusMsg && {statusMsg}} + + + {"> "} + + + ↑↓ history · Tab autocomplete · /help · /exit + + + + ); +} + +export async function runRepl(opts = {}) { + return new Promise((resolve) => { + const { unmount, waitUntilExit } = render( + unmount()} /> + ); + waitUntilExit().then(resolve); + }); +} diff --git a/bin/cli/tui/session.mjs b/bin/cli/tui/session.mjs new file mode 100644 index 0000000000..b59b6e8d98 --- /dev/null +++ b/bin/cli/tui/session.mjs @@ -0,0 +1,54 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { resolveDataDir } from "../data-dir.mjs"; + +function sessionsDir() { + const dir = join(resolveDataDir(), "repl-sessions"); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + return dir; +} + +export function saveSession(name, session) { + const path = join(sessionsDir(), `${name}.json`); + writeFileSync( + path, + JSON.stringify({ ...session, name, updatedAt: new Date().toISOString() }, null, 2) + ); +} + +export function loadSession(name) { + const path = join(sessionsDir(), `${name}.json`); + if (!existsSync(path)) throw new Error(`session '${name}' not found`); + return JSON.parse(readFileSync(path, "utf8")); +} + +export function listSessions() { + const dir = sessionsDir(); + return readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .map((f) => { + try { + const data = JSON.parse(readFileSync(join(dir, f), "utf8")); + return { + name: data.name || f.replace(".json", ""), + updatedAt: data.updatedAt, + model: data.model, + }; + } catch { + return { name: f.replace(".json", ""), updatedAt: null, model: null }; + } + }); +} + +export function autosave(session) { + try { + saveSession("autosave", session); + } catch { + // autosave failure is not fatal + } +} + +export function deleteSession(name) { + const path = join(sessionsDir(), `${name}.json`); + if (existsSync(path)) rmSync(path); +} diff --git a/tests/unit/cli-repl.test.ts b/tests/unit/cli-repl.test.ts new file mode 100644 index 0000000000..741f2c238e --- /dev/null +++ b/tests/unit/cli-repl.test.ts @@ -0,0 +1,178 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, "..", ".."); +const TUI = join(ROOT, "bin", "cli", "tui"); + +function hasExport(file: string, name: string): boolean { + const src = readFileSync(file, "utf8"); + return ( + src.includes(`export function ${name}`) || + src.includes(`export async function ${name}`) || + src.includes(`export { ${name}`) + ); +} + +test("tui/Repl.jsx existe e exporta runRepl", () => { + const path = join(TUI, "Repl.jsx"); + assert.ok(existsSync(path), "Repl.jsx deve existir"); + assert.ok(hasExport(path, "runRepl"), "Repl.jsx deve exportar runRepl"); +}); + +test("tui/session.mjs existe e exporta funções de persistência", () => { + const path = join(TUI, "session.mjs"); + assert.ok(existsSync(path), "session.mjs deve existir"); + const src = readFileSync(path, "utf8"); + for (const fn of ["saveSession", "loadSession", "listSessions", "autosave", "deleteSession"]) { + assert.ok(src.includes(`export function ${fn}`), `deve exportar ${fn}`); + } +}); + +test("commands/repl.mjs existe e exporta registerRepl", () => { + const path = join(ROOT, "bin", "cli", "commands", "repl.mjs"); + assert.ok(existsSync(path), "commands/repl.mjs deve existir"); + assert.ok(hasExport(path, "registerRepl"), "deve exportar registerRepl"); +}); + +test("commands/repl.mjs registra comando repl com --model, --combo, --system, --resume", async () => { + const { registerRepl } = await import("../../bin/cli/commands/repl.mjs"); + const { Command } = await import("commander"); + const prog = new Command().exitOverride(); + registerRepl(prog); + const replCmd = prog.commands.find((c) => c.name() === "repl"); + assert.ok(replCmd, "repl command deve existir"); + const opts = replCmd.options.map((o) => o.long); + assert.ok(opts.includes("--model"), "--model deve estar registrado"); + assert.ok(opts.includes("--combo"), "--combo deve estar registrado"); + assert.ok(opts.includes("--system"), "--system deve estar registrado"); + assert.ok(opts.includes("--resume"), "--resume deve estar registrado"); +}); + +test("Repl.jsx usa ink-text-input para input controlado", () => { + const src = readFileSync(join(TUI, "Repl.jsx"), "utf8"); + assert.ok(src.includes("ink-text-input"), "deve importar ink-text-input"); + assert.ok(src.includes("TextInput"), "deve usar TextInput"); +}); + +test("Repl.jsx suporta todos os slash commands definidos no spec", () => { + const src = readFileSync(join(TUI, "Repl.jsx"), "utf8"); + const required = [ + "model", + "combo", + "system", + "clear", + "save", + "load", + "list", + "export", + "tokens", + "help", + "exit", + ]; + for (const cmd of required) { + assert.ok(src.includes(`case "${cmd}"`), `deve suportar /${cmd}`); + } +}); + +test("Repl.jsx tem painel lateral (SidePanel) com tokens e custo", () => { + const src = readFileSync(join(TUI, "Repl.jsx"), "utf8"); + assert.ok(src.includes("SidePanel"), "deve ter SidePanel"); + assert.ok(src.includes("TokenCounter"), "deve usar TokenCounter"); +}); + +// --- testes de persistência via session.mjs --- + +test("saveSession e loadSession persistem e restauram sessão", async () => { + const tmpDir = join(tmpdir(), `omniroute-repl-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + const origDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = tmpDir; + try { + const { saveSession, loadSession } = await import("../../bin/cli/tui/session.mjs"); + const session = { + model: "gpt-4o", + combo: "fastest", + system: "You are helpful.", + messages: [{ role: "user", content: "Hello" }], + totalUsage: { in: 10, out: 20 }, + totalCost: 0.001, + createdAt: new Date().toISOString(), + }; + saveSession("test-session", session); + const loaded = loadSession("test-session"); + assert.equal(loaded.model, "gpt-4o"); + assert.equal(loaded.combo, "fastest"); + assert.equal(loaded.messages.length, 1); + assert.equal(loaded.totalCost, 0.001); + assert.equal(loaded.name, "test-session"); + } finally { + process.env.DATA_DIR = origDataDir ?? ""; + try { + rmSync(tmpDir, { recursive: true }); + } catch {} + } +}); + +test("loadSession lança erro se sessão não existe", async () => { + const tmpDir = join(tmpdir(), `omniroute-repl-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + const origDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = tmpDir; + try { + const { loadSession } = await import("../../bin/cli/tui/session.mjs"); + await assert.rejects(async () => loadSession("does-not-exist"), /not found/); + } finally { + process.env.DATA_DIR = origDataDir ?? ""; + try { + rmSync(tmpDir, { recursive: true }); + } catch {} + } +}); + +test("listSessions retorna array (vazio ou com sessões)", async () => { + const tmpDir = join(tmpdir(), `omniroute-repl-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + const origDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = tmpDir; + try { + const { listSessions, saveSession } = await import("../../bin/cli/tui/session.mjs"); + const empty = listSessions(); + assert.ok(Array.isArray(empty)); + saveSession("session-a", { + model: "gpt-4o", + messages: [], + totalUsage: { in: 0, out: 0 }, + totalCost: 0, + }); + const list = listSessions(); + assert.ok(list.some((s) => s.name === "session-a")); + } finally { + process.env.DATA_DIR = origDataDir ?? ""; + try { + rmSync(tmpDir, { recursive: true }); + } catch {} + } +}); + +test("autosave não lança erro em condições normais", async () => { + const tmpDir = join(tmpdir(), `omniroute-repl-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + const origDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = tmpDir; + try { + const { autosave } = await import("../../bin/cli/tui/session.mjs"); + assert.doesNotThrow(() => + autosave({ model: "auto", messages: [], totalUsage: { in: 0, out: 0 }, totalCost: 0 }) + ); + } finally { + process.env.DATA_DIR = origDataDir ?? ""; + try { + rmSync(tmpDir, { recursive: true }); + } catch {} + } +});