Build a terminal UI that drives an agent loop with live streaming output, step indicators, and keyboard controls.
A terminal UI (TUI) agent wraps your agent loop in an interactive shell experience — streaming the model's output token by token, displaying step progress, and letting you steer or interrupt the run from the keyboard. This recipe builds one from scratch using Node.js and the OpenDunes streaming API.
anthropic/claude-sonnet-5.Ctrl+C to abort, Ctrl+P to pause/resume, Enter to submit follow-up input mid-run.npm install openaiNo TUI library is required — raw process.stdout write calls and ANSI escape codes handle the rendering.
import OpenAI from "openai";
import * as readline from "readline";
const client = new OpenAI({
baseURL: "https://opendunes.com/v1",
apiKey: process.env.OPENDUNES_API_KEY,
});
const messages: { role: string; content: string }[] = [];
let paused = false;
async function streamTurn(userInput: string): Promise<string> {
messages.push({ role: "user", content: userInput });
process.stdout.write("\n\x1b[36mAssistant:\x1b[0m ");
const stream = await client.chat.completions.create({
model: "anthropic/claude-sonnet-5",
messages,
stream: true,
max_tokens: 1024,
});
let fullContent = "";
for await (const chunk of stream) {
while (paused) {
await new Promise((r) => setTimeout(r, 200));
}
const delta = chunk.choices[0]?.delta?.content ?? "";
fullContent += delta;
process.stdout.write(delta);
if (chunk.choices[0]?.finish_reason === "stop") {
process.stdout.write("\n");
}
}
messages.push({ role: "assistant", content: fullContent });
return fullContent;
}async function main() {
// Enter raw mode for single-keypress handling
process.stdin.setRawMode(true);
readline.emitKeypressEvents(process.stdin);
process.stdin.on("keypress", (_, key) => {
if (key.ctrl && key.name === "c") {
process.stdout.write("\n\x1b[31mAborted.\x1b[0m\n");
process.exit(0);
}
if (key.ctrl && key.name === "p") {
paused = !paused;
const status = paused ? "\x1b[33mPaused\x1b[0m" : "\x1b[32mResumed\x1b[0m";
process.stdout.write(`\r${status} \n`);
}
});
printHeader();
await runConversationLoop();
}
function printHeader() {
process.stdout.write("\x1b[2J\x1b[H"); // clear screen
process.stdout.write("\x1b[1m OpenDunes Agent TUI \x1b[0m\n");
process.stdout.write("Ctrl+C: exit | Ctrl+P: pause/resume\n");
process.stdout.write("─".repeat(60) + "\n\n");
}
async function runConversationLoop() {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
// Disable raw mode temporarily for readline input
process.stdin.setRawMode(false);
const prompt = () =>
new Promise<string>((resolve) => {
process.stdout.write("\x1b[32mYou:\x1b[0m ");
rl.once("line", (line) => resolve(line.trim()));
});
while (true) {
process.stdin.setRawMode(false);
const input = await prompt();
if (!input) continue;
process.stdin.setRawMode(true);
await streamTurn(input);
printStatusBar(messages.length);
}
}
function printStatusBar(turnCount: number) {
const bar = `\x1b[90m[turns: ${turnCount} model: claude-sonnet-5 balance: check /dashboard]\x1b[0m`;
process.stdout.write(bar + "\n");
}
main().catch(console.error);OPENDUNES_API_KEY=sk-your-key node --loader ts-node/esm agent-tui.tsOr compile first:
npx tsc && OPENDUNES_API_KEY=sk-your-key node dist/agent-tui.jsAdd a step counter: extract a STEP: prefix from the model's output and display it in the status bar alongside the turn count.
Add a cost meter: read X-Balance-Available from the streaming response headers and update the status bar after each turn so you can see DA draining in real time.
Add history scroll: buffer the last N lines in an array and handle Ctrl+↑/Ctrl+↓ to scroll back through them using ANSI cursor controls.
Add multi-model switching: bind Ctrl+M to cycle through a list of model slugs — useful for comparing outputs interactively during development.