Crafting Interactive Terminal UIs with Laravel Prompts
Overview of Text-Based User Interfaces (TUIs)
Building applications for the terminal often feels like returning to the roots of computing, but modern tools have transformed the command line into a canvas for rich, interactive experiences. A Text-Based User Interface, or TUI, uses alphanumeric characters and
Developing these interfaces matters because it bridges the gap between simple scripts and complex web applications. It allows developers to create high-utility internal tools, dashboards, or even experimental games—like
Prerequisites and Toolkit
To follow this tutorial, you should possess a solid grasp of
Key Libraries & Tools
- Laravel Prompts: A first-party Laravel package developed byJess Archerthat provides beautiful, pre-styled UI components for CLI apps.
- Chewy: A helper package created by Joe Tannenbaumthat acts as "glue" between Laravel Prompts and custom TUIs, offering utilities for keypress listening and screen management.
- Terminal App: Any modern terminal emulator (iTerm2, Windows Terminal, etc.) that supports ANSI colors and alternate screen buffers.
The Fundamental Rule: Nothing for Free
When you move from the browser to the terminal, you leave behind the luxuries of the <input> tag handles focus, cursor positioning, and text deletion automatically. In the terminal, you get nothing for free. If you want a cursor to move when a user types, you must track the index of that cursor and render an inverted-color character at that specific position. If the user hits backspace, you must manually manipulate the string in your application state and re-render the entire view.
This manual nature requires a shift in mindset. You are no longer just sending data to a renderer; you are managing a loop of state, input, and output. Every interaction—from a simple keypress to a complex window resize—is a problem you must explicitly solve.
Building a Speaker Directory App
Let's walk through building a functional Speaker Directory. This app features a list of names on the left and a detailed bio on the right.
1. Defining Application State
Your state class must extend the base Prompt class. This is where you store your data, such as a collection of speakers and the index of the currently selected item.
class SpeakerDirectory extends Prompt
{
public Collection $speakers;
public int $selected = 0;
public function __construct()
{
$this->speakers = collect(json_decode(file_get_contents('speakers.json')));
}
public function value(): mixed
{
return null; // We are building an app, not returning a single value
}
}
2. Implementing Keypress Listeners
To make the list navigable, we use the KeyPressListener. This captures input and updates the state. Note how we use min and max to prevent the selection from going out of bounds.
// Inside your state class
(new KeyPressListener($this))
->on(Key::Down, fn() => $this->selected = min($this->selected + 1, $this->speakers->count() - 1))
->on(Key::Up, fn() => $this->selected = max($this->selected - 1, 0))
->listen();
3. The Renderer Logic
The renderer is an invocable class that transforms your state into a string. To show columns side-by-side, we use a "zip" technique. Since the terminal prints line-by-line, you must concatenate the first line of the left column with the first line of the right column, and so on.
class SpeakerRenderer extends Renderer
{
public function __invoke(SpeakerDirectory $state): string
{
$leftColumn = $state->speakers->map(fn($s, $i) =>
$i === $state->selected ? "\e[42m {$s->name} \e[0m" : " {$s->name} "
);
$rightColumn = explode("\n", wordwrap($state->speakers[$state->selected]->bio, 40));
// Zip columns together and return as string
return $this->zipColumns($leftColumn, $rightColumn);
}
}
Syntax Notes and Terminal Constraints
A critical technical hurdle in TUI development is the Line Wrap Trap. If your output string is wider than the terminal window, the terminal will automatically wrap the text to the next line. This breaks the Laravel Prompts rendering engine because it no longer knows exactly how many lines to erase before re-drawing the UI. Always use the terminal()->cols() helper to calculate available width and use wordwrap() to manually break your strings before the terminal does it for you.
Another advanced feature is the Alternate Screen Buffer. By sending a specific escape code, you can tell the terminal to open a fresh, empty canvas that doesn't mess with the user's scrollback history. When the app exits, the terminal restores the previous screen as if nothing happened. This makes your TUI feel like a standalone application rather than a messy script output.
Practical Examples and Tips
Beyond simple directories, you can extend these concepts to build
Best Practices:
- Buffer your measurements: Always subtract 1 or 2 characters from the terminal width to account for edge-case rendering bugs.
- Use Monospace Awareness: Use
mb_strwidth()instead ofstrlen()to accurately calculate how much space a string occupies, especially if using Unicode characters. - Contextual Hotkeys: Display a footer with available commands (e.g., "Press T to Tweet") to guide the user through the interface.
