Add full-screen TUI with bottom composer

This commit is contained in:
Adolfo Reyna
2026-05-15 14:31:53 -04:00
parent 2e806c97a0
commit b02ef0b455
3 changed files with 223 additions and 58 deletions

View File

@@ -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 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. 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 ```text
you> summarize this:\ ┌ transcript
...> line two\ │ system> Loaded 3 conversation items...
...> line three │ 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: You can also keep using one-shot mode when scripting:
@@ -175,6 +188,6 @@ npm run dev -- "/greet Ada"
## Intentional omissions ## Intentional omissions
This first version does **not** include file writes, shell execution, approval This version still does **not** include file writes, shell execution, or approval
gates, or persistent sessions. Those are useful, but adding them after the core gates. Those are useful, but adding them after the core shape is visible keeps
shape is visible keeps the harness easier to reason about. the harness easier to reason about.

View File

@@ -1,4 +1,3 @@
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process"; import { stdin as input, stdout as output } from "node:process";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { Agent } from "./agent.js"; import { Agent } from "./agent.js";
@@ -8,13 +7,14 @@ import { loadExtensions } from "./extensions.js";
import { ConsoleLogger, ToggleLogger } from "./logger.js"; import { ConsoleLogger, ToggleLogger } from "./logger.js";
import { OpenAIResponsesClient } from "./model/openai.js"; import { OpenAIResponsesClient } from "./model/openai.js";
import { SessionStore } from "./session.js"; import { SessionStore } from "./session.js";
import { Tui } from "./tui.js";
import { builtinTools } from "./tools/builtins.js"; import { builtinTools } from "./tools/builtins.js";
import { ToolRegistry } from "./tools/registry.js"; import { ToolRegistry } from "./tools/registry.js";
const initialPrompt = process.argv.slice(2).join(" ").trim(); const initialPrompt = process.argv.slice(2).join(" ").trim();
const cwd = process.cwd(); 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 events = new EventBus();
const registry = new ToolRegistry(events); const registry = new ToolRegistry(events);
const commands = new CommandRegistry(events); const commands = new CommandRegistry(events);
@@ -141,15 +141,14 @@ function getAgent(): Agent {
return currentAgent; return currentAgent;
} }
async function handleCommand(prompt: string): Promise<boolean> { async function handleCommand(prompt: string): Promise<string | undefined> {
if (!prompt.startsWith("/")) return false; if (!prompt.startsWith("/")) return undefined;
const [rawCommand, ...rest] = prompt.slice(1).split(" "); const [rawCommand, ...rest] = prompt.slice(1).split(" ");
const command = commands.get(rawCommand); const command = commands.get(rawCommand);
if (!command) { if (!command) {
console.error(`Unknown command: /${rawCommand}`); return `Unknown command: /${rawCommand}`;
return true;
} }
const args = rest.join(" ").trim(); const args = rest.join(" ").trim();
@@ -158,35 +157,36 @@ async function handleCommand(prompt: string): Promise<boolean> {
await events.emit("command.started", { command: command.name, args }); await events.emit("command.started", { command: command.name, args });
const output = await command.execute(args, { cwd }); const output = await command.execute(args, { cwd });
await events.emit("command.completed", { command: command.name }); await events.emit("command.completed", { command: command.name });
console.log(output); return output;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Command failed"; const message = error instanceof Error ? error.message : "Command failed";
await events.emit("command.failed", { command: command.name, error: message }); await events.emit("command.failed", { command: command.name, error: message });
throw error; throw error;
} }
return true; return "";
} }
async function handlePrompt(prompt: string, interactive = false): Promise<void> { async function handlePrompt(prompt: string, tui?: Tui): Promise<string | undefined> {
if (!prompt.trim()) return; if (!prompt.trim()) return;
if (await handleCommand(prompt)) { const commandOutput = await handleCommand(prompt);
return; if (commandOutput !== undefined) {
return commandOutput;
} }
if (!interactive) { if (!tui) {
const answer = await getAgent().runTurn(prompt); const answer = await getAgent().runTurn(prompt);
loadedHistory = getAgent().getHistory(); loadedHistory = getAgent().getHistory();
await sessionStore.save(loadedHistory); await sessionStore.save(loadedHistory);
console.log(answer); console.log(answer);
return; return answer;
} }
let wroteAssistantText = false; let wroteAssistantText = false;
let streamedText = ""; let streamedText = "";
tui.beginAssistant();
output.write("\x1b[36massistant>\x1b[0m "); tui.setStatus("Thinking…");
let answer: string; let answer: string;
@@ -195,14 +195,14 @@ async function handlePrompt(prompt: string, interactive = false): Promise<void>
onTextDelta(delta) { onTextDelta(delta) {
wroteAssistantText = true; wroteAssistantText = true;
streamedText += delta; streamedText += delta;
output.write(delta); tui.appendAssistant(delta);
} }
}); });
loadedHistory = getAgent().getHistory(); loadedHistory = getAgent().getHistory();
await sessionStore.save(loadedHistory); await sessionStore.save(loadedHistory);
} catch (error) { } catch (error) {
if (!wroteAssistantText) { if (!wroteAssistantText) {
output.write( tui.replaceLastAssistant(
error instanceof Error && error.message.includes("Missing credentials") error instanceof Error && error.message.includes("Missing credentials")
? "Missing OPENAI_API_KEY. Set it before sending model prompts." ? "Missing OPENAI_API_KEY. Set it before sending model prompts."
: error instanceof Error : error instanceof Error
@@ -210,52 +210,42 @@ async function handlePrompt(prompt: string, interactive = false): Promise<void>
: "Request failed." : "Request failed."
); );
} }
output.write("\n\n"); tui.setStatus("");
return; return;
} }
if (!wroteAssistantText) { if (!wroteAssistantText) {
output.write(answer); tui.replaceLastAssistant(answer);
} else if (streamedText !== answer && !answer.startsWith(streamedText)) { } 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) { if (initialPrompt) {
await handlePrompt(initialPrompt); const answer = await handlePrompt(initialPrompt);
if (answer) console.log(answer);
process.exit(0); process.exit(0);
} }
const rl = createInterface({ input, output }); const tui = new Tui(input, output);
tui.addEntry(
console.log("Simple Agent Harness"); "system",
console.log( `Loaded ${loadedHistory.length} conversation items. Type /help for commands.`
`Loaded ${loadedHistory.length} conversation items. Type "/help" for commands or "/exit" to quit.`
); );
console.log('End a line with "\\" to continue onto the next line.'); tui.start(
output.write("\x1b[32myou>\x1b[0m "); async (prompt) => {
let multilineBuffer: string[] = [];
for await (const prompt of rl) {
if (prompt.trim() === "/exit") { if (prompt.trim() === "/exit") {
break; tui.stop();
return;
} }
const continues = prompt.endsWith("\\"); const result = await handlePrompt(prompt, tui);
const line = continues ? prompt.slice(0, -1) : prompt; if (result && prompt.startsWith("/")) {
multilineBuffer.push(line); tui.addEntry("system", result);
if (continues) {
output.write("\x1b[33m...>\x1b[0m ");
continue;
} }
},
const fullPrompt = multilineBuffer.join("\n"); () => tui.stop()
multilineBuffer = []; );
await handlePrompt(fullPrompt, true);
output.write("\x1b[32myou>\x1b[0m ");
}
rl.close();

162
src/tui.ts Normal file
View File

@@ -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<void>, 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;
});
}
}