Skip to content

Build an AI Agent in Typescript / lesson 7 of 8

Build a TUI

Transform your basic terminal interactions into a polished, developer-friendly CLI with real-time feedback and markdown formatting.

Play

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.

ui.ts
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

index.ts
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.

index.ts
/** 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.

index.ts
/** 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.

tui


Share this post on:

Previous
Context Compaction
Next
Build a Skills Loader