Transform your basic terminal interactions into a polished, developer-friendly CLI with real-time feedback and markdown formatting. This tutorial solves the pain of unstructured output and invisible AI processing by introducing a robust terminal UI (TUI) built with pi-tui and chalk. Perfect for building local AI agents, CLI tools, or any terminal application requiring clear state visibility and rich text rendering.
import { TUI, ProcessTerminal, Editor, matchesKey, Key, visibleWidth, truncateToWidth, Markdown, Loader,} from "@mariozechner/pi-tui";import type { Component } from "@mariozechner/pi-tui";import type { ModelMessage } from "ai";import chalk from "chalk";import type { MarkdownTheme } from "@mariozechner/pi-tui";
export const uiTheme = { borderColor: chalk.green, selectList: { selectedPrefix: (s: string) => chalk.cyan.bold(s), selectedText: chalk.cyan, description: chalk.gray, scrollInfo: chalk.gray, noMatch: chalk.red, },};
export const mdTheme: MarkdownTheme = { heading: (s) => chalk.bold.cyan(s), link: (s) => chalk.blue(s), linkUrl: (s) => chalk.dim(s), code: (s) => chalk.yellow(s), codeBlock: (s) => chalk.green(s), codeBlockBorder: (s) => chalk.dim(s), quote: (s) => chalk.italic(s), quoteBorder: (s) => chalk.dim(s), hr: (s) => chalk.dim(s), listBullet: (s) => chalk.cyan(s), bold: (s) => chalk.bold(s), italic: (s) => chalk.italic(s), strikethrough: (s) => chalk.strikethrough(s), underline: (s) => chalk.underline(s),};
export class AgentUI implements Component { private tui = new TUI(new ProcessTerminal()); private editor = new Editor(this.tui, uiTheme, { paddingX: 1 }); private thinkingLoader: Loader | null = null; private currentAction = ""; private mdCache = new Map<ModelMessage, Markdown>();
onInput?: (text: string) => Promise<void>;
constructor(private history: ModelMessage[]) { this.tui.addChild(this); this.tui.addChild(this.editor);
this.editor.onSubmit = (text) => { if (this.thinkingLoader) return; if (text.trim()) this.onInput?.(text); };
this.tui.addInputListener((d) => { if (matchesKey(d, Key.ctrl("c"))) this.exit(); return undefined; });
process.on("SIGINT", () => this.exit()).on("SIGTERM", () => this.exit()); }
start() { this.tui.start(); this.editor.focused = true; this.tui.setFocus(this.editor); }
exit() { this.tui.stop(); process.exit(0); }
appendSpacer(lines: string[]) { if (lines.length > 0) lines.push(""); }
colorMessage(m: ModelMessage) { return m.role === "user" ? chalk.green : chalk.white; }
renderAssistantMessage(m: ModelMessage, w: number) { let md = this.mdCache.get(m); if (!md) { md = new Markdown(this.getMessageText(m), 2, 0, mdTheme); this.mdCache.set(m, md); } return md.render(w); }
renderPlainMessage(m: ModelMessage) { const message = this.getMessageText(m); return message .split("\n") .map((l) => ` ${this.colorMessage(m)(l)}`); }
renderMessage(m: ModelMessage, w: number) { return m.role === "assistant" ? this.renderAssistantMessage(m, w) : this.renderPlainMessage(m); }
appendThinking(lines: string[], w: number) { if (!this.thinkingLoader) return; this.appendSpacer(lines); lines.push(...this.thinkingLoader.render(w)); if (this.currentAction) { lines.push(`\t${chalk.dim("↳")} ${chalk.gray(this.currentAction)}`); } }
fitWidth(lines: string[], w: number) { return lines.map((l) => (visibleWidth(l) > w ? truncateToWidth(l, w) : l)); }
getMessageText(m: ModelMessage): string { if (typeof m.content === "string") return m.content; return m.content.map((part) => ("text" in part ? part.text : "")).join(""); }
push(m: ModelMessage) { this.history.push(m); this.tui.requestRender(); this.editor.setText(""); }
setActionStatus(action: string) { this.currentAction = action; this.tui.requestRender(); }
clearActionStatus() { this.currentAction = ""; }
makeSpinner() { return new Loader(this.tui, chalk.cyan, chalk.dim, "Thinking..."); }
invalidate() { }
render(w: number) { const lines: string[] = []; for (const m of this.history) { this.appendSpacer(lines); lines.push(...this.renderMessage(m, w)); } this.appendThinking(lines, w); return this.fitWidth(lines, w); }
async think<T>(fn: () => Promise<T>) { this.thinkingLoader = this.makeSpinner(); this.clearActionStatus(); try { const reply = await fn(); this.thinkingLoader = null; return reply; } catch (e) { this.thinkingLoader = null; this.history.push({ role: "assistant", content: `🔴 Error: ${e instanceof Error ? e.message : String(e)}`, }); } finally { this.clearActionStatus(); } }}Now let’s add the Ui to our Agent Loop
First import our Agent UI and remove readline
import { generateText, isLoopFinished } from "ai";import type { ModelMessage } from "ai";import * as readline from "readline";import { PARENT_TOOLS, } from "./tools";import { api, MODEL, WORKDIR } from "./config";import { api, CONTEXT_LIMIT, MODEL, WORKDIR } from "./config";import { autoCompact, estimateTokens, microCompact } from "./compact";import { AgentUI } from "./ui";Now we’ll replace the readline implementation withour new TUI.
/** INTERFACE */
const rl = readline.createInterface({ input: process.stdin, output: process.stdout});const history: ModelMessage[] = [];
const prompt = (): void => { rl.question(" input >> ", async (query) => { history.push({ role: "user", content: query }); const reply = await agentLoop(history); history.push({ role: "assistant", content: reply }); if (reply) console.log(reply); console.log(); prompt(); });};
prompt();
const history: ModelMessage[] = [];const ui = new AgentUI(history);
ui.onInput = async (query) => { ui.push({ role: "user", content: query }); const reply = await ui.think(() => agentLoop(history, ui)); if (reply) { ui.push({ role: "assistant", content: reply }); }};
ui.start();An now we just pass the Ui intour Agent Loop.
/** AGENT LOOP */const agentLoop = async (messages: ModelMessage[]): Promise<string> => {const agentLoop = async (messages: ModelMessage[], ui: AgentUI): Promise<string> => { let stepsSinceTodo = 0; microCompact(messages); const estTokens = estimateTokens(messages); if (estTokens > CONTEXT_LIMIT) { /* compaction code here */ } const { text } = await generateText({ model: api.chat(MODEL), system: `...`, messages, tools: PARENT_TOOLS, stopWhen: isLoopFinished(), onStepFinish: ({ toolCalls }) => { onStepFinish: ({ toolCalls, toolResults }) => { const usedTodo = toolCalls.some((tc) => tc.toolName === "todo") ?? false; stepsSinceTodo = usedTodo ? 0 : stepsSinceTodo + 1; if (stepsSinceTodo >= 3) { messages.push({ role: "user", content: "<reminder>Updated your todos.</reminder>", }); }
const tc = toolCalls[0]; if (tc) { const args = JSON.stringify(tc.input).slice(0, 60); const out = toolResults[0]?.output; const result = out ? typeof out === "string" && out.startsWith("Error") ? `🔴 ${out.slice(0, 50)}` : "🟢" : ""; ui.setActionStatus(`${tc.toolName}: ${args} ${result}`); } }, return text;};Try it out! Our Agent now has a simple, but much improved terminal user interface.
