From b02ef0b4551e445deeb7c4139448a319a0bb2e63 Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Fri, 15 May 2026 14:31:53 -0400 Subject: [PATCH] Add full-screen TUI with bottom composer --- README.md | 27 ++++++--- src/index.ts | 92 +++++++++++++---------------- src/tui.ts | 162 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 58 deletions(-) create mode 100644 src/tui.ts diff --git a/README.md b/README.md index dd4b3f7..5488f31 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,25 @@ Interactive responses stream into the terminal as they are generated, and the session keeps conversation history until you reset it. The current session is saved to `.harness/session.json` and reloaded the next time the app starts. -For multiline input, end a line with `\` and keep typing: +The interactive mode is a small full-screen TUI: the transcript scrolls above, +while the input composer stays pinned to the bottom of the terminal. Press +`Ctrl+J` to insert a newline while composing. ```text -you> summarize this:\ -...> line two\ -...> line three +┌ transcript +│ system> Loaded 3 conversation items... +│ you> ... +│ assistant> ... +└──────────────────────────────── +you> input stays here +``` + +For multiline input: + +```text +you> summarize this: + line two + line three ``` You can also keep using one-shot mode when scripting: @@ -175,6 +188,6 @@ npm run dev -- "/greet Ada" ## Intentional omissions -This first version does **not** include file writes, shell execution, approval -gates, or persistent sessions. Those are useful, but adding them after the core -shape is visible keeps the harness easier to reason about. +This version still does **not** include file writes, shell execution, or approval +gates. Those are useful, but adding them after the core shape is visible keeps +the harness easier to reason about. diff --git a/src/index.ts b/src/index.ts index f055f0b..af5b087 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; import { resolve } from "node:path"; import { Agent } from "./agent.js"; @@ -8,13 +7,14 @@ import { loadExtensions } from "./extensions.js"; import { ConsoleLogger, ToggleLogger } from "./logger.js"; import { OpenAIResponsesClient } from "./model/openai.js"; import { SessionStore } from "./session.js"; +import { Tui } from "./tui.js"; import { builtinTools } from "./tools/builtins.js"; import { ToolRegistry } from "./tools/registry.js"; const initialPrompt = process.argv.slice(2).join(" ").trim(); const cwd = process.cwd(); -const logger = new ToggleLogger(new ConsoleLogger(), process.env.LOGS !== "off"); +const logger = new ToggleLogger(new ConsoleLogger(), process.env.LOGS === "on"); const events = new EventBus(); const registry = new ToolRegistry(events); const commands = new CommandRegistry(events); @@ -141,15 +141,14 @@ function getAgent(): Agent { return currentAgent; } -async function handleCommand(prompt: string): Promise { - if (!prompt.startsWith("/")) return false; +async function handleCommand(prompt: string): Promise { + if (!prompt.startsWith("/")) return undefined; const [rawCommand, ...rest] = prompt.slice(1).split(" "); const command = commands.get(rawCommand); if (!command) { - console.error(`Unknown command: /${rawCommand}`); - return true; + return `Unknown command: /${rawCommand}`; } const args = rest.join(" ").trim(); @@ -158,35 +157,36 @@ async function handleCommand(prompt: string): Promise { await events.emit("command.started", { command: command.name, args }); const output = await command.execute(args, { cwd }); await events.emit("command.completed", { command: command.name }); - console.log(output); + return output; } catch (error) { const message = error instanceof Error ? error.message : "Command failed"; await events.emit("command.failed", { command: command.name, error: message }); throw error; } - return true; + return ""; } -async function handlePrompt(prompt: string, interactive = false): Promise { +async function handlePrompt(prompt: string, tui?: Tui): Promise { if (!prompt.trim()) return; - if (await handleCommand(prompt)) { - return; + const commandOutput = await handleCommand(prompt); + if (commandOutput !== undefined) { + return commandOutput; } - if (!interactive) { + if (!tui) { const answer = await getAgent().runTurn(prompt); loadedHistory = getAgent().getHistory(); await sessionStore.save(loadedHistory); console.log(answer); - return; + return answer; } let wroteAssistantText = false; let streamedText = ""; - - output.write("\x1b[36massistant>\x1b[0m "); + tui.beginAssistant(); + tui.setStatus("Thinking…"); let answer: string; @@ -195,14 +195,14 @@ async function handlePrompt(prompt: string, interactive = false): Promise onTextDelta(delta) { wroteAssistantText = true; streamedText += delta; - output.write(delta); + tui.appendAssistant(delta); } }); loadedHistory = getAgent().getHistory(); await sessionStore.save(loadedHistory); } catch (error) { if (!wroteAssistantText) { - output.write( + tui.replaceLastAssistant( error instanceof Error && error.message.includes("Missing credentials") ? "Missing OPENAI_API_KEY. Set it before sending model prompts." : error instanceof Error @@ -210,52 +210,42 @@ async function handlePrompt(prompt: string, interactive = false): Promise : "Request failed." ); } - output.write("\n\n"); + tui.setStatus(""); return; } if (!wroteAssistantText) { - output.write(answer); + tui.replaceLastAssistant(answer); } else if (streamedText !== answer && !answer.startsWith(streamedText)) { - output.write(answer.slice(streamedText.length)); + tui.appendAssistant(answer.slice(streamedText.length)); } - output.write("\n\n"); + tui.setStatus(""); + return answer; } if (initialPrompt) { - await handlePrompt(initialPrompt); + const answer = await handlePrompt(initialPrompt); + if (answer) console.log(answer); process.exit(0); } -const rl = createInterface({ input, output }); - -console.log("Simple Agent Harness"); -console.log( - `Loaded ${loadedHistory.length} conversation items. Type "/help" for commands or "/exit" to quit.` +const tui = new Tui(input, output); +tui.addEntry( + "system", + `Loaded ${loadedHistory.length} conversation items. Type /help for commands.` ); -console.log('End a line with "\\" to continue onto the next line.'); -output.write("\x1b[32myou>\x1b[0m "); +tui.start( + async (prompt) => { + if (prompt.trim() === "/exit") { + tui.stop(); + return; + } -let multilineBuffer: string[] = []; -for await (const prompt of rl) { - if (prompt.trim() === "/exit") { - break; - } - - const continues = prompt.endsWith("\\"); - const line = continues ? prompt.slice(0, -1) : prompt; - multilineBuffer.push(line); - - if (continues) { - output.write("\x1b[33m...>\x1b[0m "); - continue; - } - - const fullPrompt = multilineBuffer.join("\n"); - multilineBuffer = []; - await handlePrompt(fullPrompt, true); - output.write("\x1b[32myou>\x1b[0m "); -} - -rl.close(); + const result = await handlePrompt(prompt, tui); + if (result && prompt.startsWith("/")) { + tui.addEntry("system", result); + } + }, + () => tui.stop() +); diff --git a/src/tui.ts b/src/tui.ts new file mode 100644 index 0000000..4bd833d --- /dev/null +++ b/src/tui.ts @@ -0,0 +1,162 @@ +import { emitKeypressEvents } from "node:readline"; +import type { ReadStream, WriteStream } from "node:tty"; + +export type TranscriptEntry = { + role: "system" | "user" | "assistant"; + text: string; +}; + +export class Tui { + private readonly transcript: TranscriptEntry[] = []; + private input = ""; + private status = ""; + private closed = false; + private started = false; + + constructor( + private readonly stdin: ReadStream, + private readonly stdout: WriteStream + ) {} + + start(onSubmit: (value: string) => Promise, onExit: () => void): void { + emitKeypressEvents(this.stdin); + this.stdin.setRawMode(true); + this.stdin.resume(); + this.stdout.write("\x1b[?1049h\x1b[?25l"); + this.started = true; + + this.stdin.on("keypress", async (str, key) => { + if (this.closed) return; + + if (key.ctrl && key.name === "c") { + onExit(); + return; + } + + if (key.name === "return") { + const value = this.input.trim(); + if (!value) return; + this.input = ""; + this.addEntry("user", value); + this.render(); + await onSubmit(value); + return; + } + + if (key.name === "backspace") { + this.input = this.input.slice(0, -1); + } else if (key.ctrl && key.name === "j") { + this.input += "\n"; + } else if (str && !key.ctrl && !key.meta) { + this.input += str; + } + + this.render(); + }); + + this.render(); + } + + stop(): void { + if (this.closed) return; + this.closed = true; + this.stdin.setRawMode(false); + this.stdout.write("\x1b[?25h\x1b[?1049l"); + this.stdin.pause(); + } + + addEntry(role: TranscriptEntry["role"], text: string): void { + this.transcript.push({ role, text }); + if (this.started) this.render(); + } + + beginAssistant(): void { + this.transcript.push({ role: "assistant", text: "" }); + this.render(); + } + + appendAssistant(delta: string): void { + const last = this.transcript.at(-1); + if (!last || last.role !== "assistant") { + this.beginAssistant(); + } + this.transcript[this.transcript.length - 1]!.text += delta; + this.render(); + } + + replaceLastAssistant(text: string): void { + const last = this.transcript.at(-1); + if (!last || last.role !== "assistant") { + this.transcript.push({ role: "assistant", text }); + } else { + last.text = text; + } + this.render(); + } + + setStatus(status: string): void { + this.status = status; + this.render(); + } + + private render(): void { + if (!this.started) return; + const width = this.stdout.columns || 80; + const height = this.stdout.rows || 24; + const inputLines = this.wrap(this.input || "", Math.max(10, width - 8)); + const composerHeight = Math.max(3, inputLines.length + 2); + const transcriptHeight = Math.max(3, height - composerHeight - 2); + const renderedTranscript = this.transcript.flatMap((entry) => { + const label = + entry.role === "user" + ? "you" + : entry.role === "assistant" + ? "assistant" + : "system"; + const color = + entry.role === "user" + ? "\x1b[32m" + : entry.role === "assistant" + ? "\x1b[36m" + : "\x1b[33m"; + const lines = this.wrap(entry.text, Math.max(10, width - label.length - 4)); + return lines.map((line, index) => + index === 0 ? `${color}${label}>\x1b[0m ${line}` : `${" ".repeat(label.length + 2)}${line}` + ); + }); + const visibleTranscript = renderedTranscript.slice(-transcriptHeight); + + this.stdout.write("\x1b[H\x1b[2J"); + this.stdout.write("Simple Agent Harness\n"); + this.stdout.write(`${this.status || "Ctrl+C to exit · Ctrl+J for newline"}\n`); + + for (let i = 0; i < transcriptHeight; i += 1) { + this.stdout.write(`${visibleTranscript[i] ?? ""}\n`); + } + + this.stdout.write("─".repeat(width) + "\n"); + this.stdout.write("\x1b[32myou>\x1b[0m "); + + inputLines.forEach((line, index) => { + if (index > 0) { + this.stdout.write(`\n${" ".repeat(5)}`); + } + this.stdout.write(line); + }); + + this.stdout.write("\x1b[?25h"); + } + + private wrap(text: string, width: number): string[] { + if (!text) return [""]; + + return text.split("\n").flatMap((line) => { + if (line.length <= width) return [line]; + const parts: string[] = []; + for (let i = 0; i < line.length; i += width) { + parts.push(line.slice(i, i + width)); + } + return parts; + }); + } +}