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.
- A full-screen terminal layout with a scrollable output pane and a status bar.
- Token-by-token streaming from
anthropic/claude-sonnet-5.
- Keyboard shortcuts:
Ctrl+C to abort, Ctrl+P to pause/resume, Enter to submit follow-up input mid-run.
No 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/api/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() {
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");
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 });
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.ts
Or compile first:
npx tsc && OPENDUNES_API_KEY=sk-your-key node dist/agent-tui.js
Add 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.