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

to construct visual elements like tables, progress bars, and navigation menus. Unlike a standard Command Line Interface (CLI) that simply outputs text and terminates, a TUI creates an immersive environment where the user can interact with the state of the application in real-time.

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

—that live entirely within the developer's native habitat: the terminal. By mastering TUIs, you gain total control over the terminal buffer, enabling you to manipulate pixels (characters) to suit your specific workflow needs.

Prerequisites and Toolkit

To follow this tutorial, you should possess a solid grasp of

and the
Laravel
ecosystem. Familiarity with
Object-Oriented Programming
is essential, as we will be extending base classes to manage application state and rendering logic.

Key Libraries & Tools

  • Laravel Prompts
    : A first-party Laravel package developed by
    Jess Archer
    that provides beautiful, pre-styled UI components for CLI apps.
  • Chewy: A helper package created by
    Joe Tannenbaum
    that 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

. In a web browser, an <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

boards, system monitors, or even photo booths that use ASCII art to represent camera feeds.

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 of strlen() 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.
5 min read