Welcome to Chapter 5 of the pi-mono tutorial!
In the previous Standard Tools chapter, we gave our Agent "hands" to execute commands and write files. However, seeing the raw output of these tools scrolling endlessly in a terminal can be messy and hard to read.
In this chapter, we will build the Face of our application using the TUI (Terminal User Interface) Engine.
Standard command-line programs work like a receipt printer: they just append text to the bottom.
console.log("Hello") prints a line.console.log("World") prints another line below it.But what if you want a status bar that stays at the bottom, or a text editor where you can move the cursor around?
To do this, you might try clearing the screen and re-printing everything every time something changes. The Problem: If you do this 60 times a second, the screen will flash and flicker uncontrollably. It creates a terrible user experience.
The Solution: The TUI Engine. It acts like a web browser's rendering engine. It keeps a "Virtual Screen" in memory, compares it to what is currently on the real screen, and only updates the lines that have changed.
In web development, you have HTML elements (<div>, <span>). In our TUI, we have Components.
A Component is simply a class that knows how to turn itself into an array of strings (lines of text).
Instead of drawing directly to the screen, components "request" a render.
The TUI listens to the keyboard. It captures raw data (like ^[[A for "Up Arrow") and passes it to the component that currently has Focus.
Let's build a very simple interface that displays a title and a text box.
To create a UI element, we implement the Component interface. We just need a render method that returns an array of strings.
import { Component } from "@mariozechner/pi-tui";
class TitleComponent implements Component {
render(width: number): string[] {
// Return an array of strings.
// Each string is one horizontal line on the terminal.
return [
"ββββββββββββββββββββ",
"β MY AI AGENT β",
"ββββββββββββββββββββ"
];
}
}
Explanation: This component draws a box. The width argument tells us how wide the terminal currently is, so we can stretch our box if we wanted to.
Now we need to start the engine and add our component to it.
import { TUI, ProcessTerminal } from "@mariozechner/pi-tui";
// 1. Create the connection to the real OS terminal
const terminal = new ProcessTerminal();
// 2. Create the TUI engine
const tui = new TUI(terminal);
// 3. Add our component
tui.addChild(new TitleComponent());
// 4. Start the loop
tui.start();
Explanation:
ProcessTerminal wraps Node.js process.stdout and handles raw modes.tui.start() kicks off the event loop, hiding the cursor and listening for resize events.This is the magic part. How does the TUI avoid flickering?
When render() is called:
newLines[i] with previousLines[i].
Let's look at the implementation details in packages/tui/src/tui.ts.
The doRender method contains the "Diffing" logic.
// packages/tui/src/tui.ts
private doRender(): void {
// 1. Get new lines from components
let newLines = this.render(width);
// 2. Find start and end of changes
let firstChanged = -1;
let lastChanged = -1;
for (let i = 0; i < maxLines; i++) {
if (this.previousLines[i] !== newLines[i]) {
if (firstChanged === -1) firstChanged = i;
lastChanged = i;
}
}
// 3. If nothing changed, stop.
if (firstChanged === -1) return;
// 4. Only update the specific lines on screen
let buffer = "";
// ... logic to move cursor to firstChanged ...
for (let i = firstChanged; i <= lastChanged; i++) {
buffer += newLines[i]; // Add only changed lines
}
this.terminal.write(buffer);
}
Explanation: This optimization is crucial. If you have a 100-line screen and only the clock in the corner updates, firstChanged and lastChanged ensure we only transmit the bytes for that one line, making the UI feel instant even over slow SSH connections.
Text editing is surprisingly hard. You have to handle arrow keys, backspace, and line wrapping. The pi-mono project includes a robust Editor component in packages/tui/src/components/editor.ts.
It separates Logical Lines (the string in memory) from Visual Lines (how it wraps on screen).
// packages/tui/src/components/editor.ts
render(width: number): string[] {
// 1. Wrap text based on screen width
const layoutLines = this.layoutText(width);
// 2. Handle Scrolling
const visibleLines = layoutLines.slice(
this.scrollOffset,
this.scrollOffset + height
);
// 3. Draw the cursor
// The Editor manually inserts a specific "Reverse Color" ANSI code
// where the cursor should be.
// ...
return result;
}
Explanation: The Editor component handles the complexity of text manipulation. When you use it in your agent, you simply subscribe to onChange or onSubmit to get the text back.
Sometimes we need a popup (like an autocomplete menu) to appear over the text. The TUI supports this via an Overlay Stack.
SelectList) is active.
The TUI Engine provides the visual layer for pi-mono. It transforms our AI from a command-line script into a professional-looking application.
However, displaying all this information can fill up the screenβand the AI's context windowβvery quickly. How do we make sure the AI remembers important information without running out of memory?
In the next chapter, we will discuss Context Compaction.
Generated by Code IQ