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
|
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.
|
||||||
|
|||||||
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 { 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) => {
|
||||||
|
if (prompt.trim() === "/exit") {
|
||||||
|
tui.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let multilineBuffer: string[] = [];
|
const result = await handlePrompt(prompt, tui);
|
||||||
for await (const prompt of rl) {
|
if (result && prompt.startsWith("/")) {
|
||||||
if (prompt.trim() === "/exit") {
|
tui.addEntry("system", result);
|
||||||
break;
|
}
|
||||||
}
|
},
|
||||||
|
() => tui.stop()
|
||||||
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();
|
|
||||||
|
|||||||
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