Add full-screen TUI with bottom composer
This commit is contained in:
27
README.md
27
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.
|
||||
|
||||
92
src/index.ts
92
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<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
162
src/tui.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user