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
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.

View File

@@ -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<boolean> {
if (!prompt.startsWith("/")) return false;
async function handleCommand(prompt: string): Promise<string | undefined> {
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<boolean> {
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<void> {
async function handlePrompt(prompt: string, tui?: Tui): Promise<string | undefined> {
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<void>
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<void>
: "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()
);

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;
});
}
}