For decades, the command line has been the backbone of developer tooling. We write scripts that ask for input, print lines of text, and dump tables of data. We call these CLI tools. They work. But they are fragile.

Stop Writing CLI Tools: Build Python TUIs Instead using Textual

The Forgotten Middle Ground

For decades, the command line has been the backbone of developer tooling. We write scripts that ask for input, print lines of text, and dump tables of data. We call these CLI tools. They work. But they are fragile.

One wrong character in a filename? The whole script crashes. Need to see real-time logs while filtering data? You are blind until the process finishes. Want to change a setting mid-run? Too bad. Press Ctrl+C and start over.

At the other end of the spectrum sits the GUI. Beautiful. Interactive. But heavy. Electron apps eat gigabytes of RAM. Web dashboards require servers, authentication, and JavaScript frameworks.

There is a forgotten middle ground. It is called the TUI — Textual User Interface.

A TUI lives inside your terminal. It looks like a CLI tool but acts like a GUI. You can click buttons. You can scroll through data. You can watch graphs update in real time. And you write it all in pure Python using a library called Textual.

This article is your practical guide. Stop fighting with argparse and input() prompts. Stop printing thousands of lines of print(f"..."). Start building tools that are a joy to use.

What is Wrong with Traditional CLI Tools?

Let us be honest about the pain points of standard command-line interfaces.

First, statelessness. Every time you run a CLI tool, it starts from zero. It does not remember your last choice. It does not know which file you edited yesterday. You must type everything again.

Second, blocking operations. When your CLI tool runs a network request or processes a large file, the screen freezes. You stare at a blinking cursor, unsure if it crashed or if it is working. You cannot cancel specific tasks. You cannot see progress beyond a single percentage number.

Third, information density. A terminal scrollback buffer is linear. If you generate a table with 200 rows and 15 columns, the user must scroll up and down blindly. There is no sorting. No filtering. No searching without using grep and re-running the entire script.

Fourth, error handling. One typo in a user prompt crashes everything. CLI tools are a series of roadblocks: “Enter your API key.” (user types wrong key). “Invalid. Enter again.” The flow is stop-and-go.

These problems exist because CLI tools simulate a conversation. You speak (type a command). The computer replies (prints text). Then you wait. Textual TUIs change the conversation into a cockpit. The user sees everything at once and interacts continuously.

Enter Textual: React for the Terminal

Textual is a Python framework for building TUIs. If you know React or Vue, you will feel at home. If you do not, do not worry—Textual is simpler.

The core idea is reactive widgets. You build a screen by composing widgets: buttons, input boxes, data tables, progress bars, markdown viewers. Each widget holds its own state. When the state changes, the widget redraws itself automatically. You never write print() or os.system('clear').

Textual runs on any operating system. It works over SSH. It supports mouse clicks, keyboard shortcuts, and true color (16 million colors). It handles resizing gracefully. If the user shrinks the terminal window, your layout adapts instantly.

Installation takes one second:

pip install textual

That is it. No extra dependencies. No complicated build steps. Textual uses your terminal’s native capabilities.

Your First TUI: Hello World with a Button

Let us break the tradition. Throw away the if __name__ == "__main__": script that prints “Hello World”. Build a TUI that lets a user click a button to change the message.

Here is the complete code:

from textual.app import App, ComposeResult
from textual.widgets import Button, Label
from textual.containers import Vertical

class HelloApp(App):
    def compose(self) -> ComposeResult:
        with Vertical():
            yield Label("Click the button below:", id="message")
            yield Button("Greet me!", id="greet")

    def on_button_pressed(self, event: Button.Pressed):
        if event.button.id == "greet":
            self.query_one("#message").update("Hello, TUI developer!")

if __name__ == "__main__":
    HelloApp().run()

Run this script. You see a clean screen. A label and a button. Click the button (with your mouse or by pressing Tab and Enter). The text changes instantly.

Notice what you did not write:

  • No while True loop for input handling.
  • No checking for KeyboardInterrupt.
  • No clearing the screen manually.
  • No redrawing logic.

Textual handles the event loop. You just describe what should happen when.

The Mental Shift: From Script to Application

To stop writing CLI tools, you must change how you think about your code.

A CLI script is a linear journey. Step 1, then Step 2, then Step 3. If Step 2 fails, the journey stops.

A Textual TUI is an event-driven environment. You define the starting screen. Then you wait. The user clicks, types, or presses keys. Your code responds to those events. Multiple things can happen out of order. The user might click Button A, then scroll through a table, then click Button B. Your app must be ready for any sequence.

This is more powerful. It is also more fun to write. You stop thinking about “what happens next?” and start thinking about “what can the user do right now?”

Consider a file processor. In a CLI tool, you would:

  1. Ask for directory path.
  2. Ask for file extension filter.
  3. Ask for output format.
  4. Process files.
  5. Print results.

If the user wants to process a different directory, they re-run the script. If they want to change the filter mid-process, too bad.

In a Textual TUI, you build a file browser widget, a filter input box that updates live as you type, and a processing button that runs in the background without freezing the UI. The user can change the filter while files are processing. They can cancel a long operation with one click.

Widgets: Your Building Blocks

Textual comes with a rich set of pre-built widgets. Learn these, and you can build 80% of your tools without writing custom UI code.

Button – The classic. Triggers actions. Can be primary, default, or success variants.

Input – A text field. Supports validation, placeholders, and password masking.

Label – Static or dynamic text. Supports Markdown formatting.

DataTable – The killer feature. Sortable columns, selectable rows, dynamic updates. Handles thousands of rows smoothly.

ListView – A scrollable list of items. Great for selections or menus.

Tree – Expandable tree view for hierarchical data (file systems, JSON structures, etc.).

ProgressBar – Shows completion percentage for long tasks.

Footer – Automatically shows key bindings (e.g., “Press Q to quit”).

Header – Shows title, time, and custom indicators.

MarkdownViewer – Renders Markdown files with syntax highlighting.

You compose these widgets using containers: Vertical, Horizontal, Grid, and ScrollView. Containers control layout. Textual uses CSS-like styling for positioning and sizing.

Real-Time Dashboards: Beyond Static Output

The most compelling reason to switch from CLI to TUI is real-time data.

Imagine you are building a tool that monitors server logs, API rate limits, or cryptocurrency prices. A CLI tool would print a line, sleep for a second, print another line. The screen scrolls endlessly. The user cannot read old data while new data arrives.

A Textual TUI updates specific widgets in place. The screen remains stable. Numbers change. Graphs move. Tables sort themselves.

Here is a minimal real-time dashboard that shows a counter incrementing every second:

from textual.app import App
from textual.widgets import Label
from textual.containers import Vertical
import asyncio

class CounterApp(App):
    def compose(self):
        with Vertical():
            yield Label("Live counter:", id="label")
            yield Label("0", id="counter")

    async def on_mount(self):
        counter = 0
        while True:
            counter += 1
            self.query_one("#counter").update(str(counter))
            await asyncio.sleep(1)

CounterApp().run()

The on_mount method runs when the app starts. The while True loop runs concurrently with the UI. The user can still click, type, or quit while the counter updates. No freezing. No blocking.

You can extend this pattern to anything: live Bitcoin prices, CPU usage, incoming webhook requests, tailed log files.

Background Workers: Keeping the UI Responsive

Real-time updates are great, but what about long-running tasks? If you process a 10 GB file, you cannot freeze the interface for 30 seconds.

Textual provides workers for exactly this. A worker runs a function in the background (using asyncio or threads). The UI stays responsive. You can update a progress bar, cancel the worker, or receive results when it finishes.

Example: Process files without freezing:

from textual import work
from textual.app import App
from textual.widgets import Button, ProgressBar, Label

class ProcessorApp(App):
    def compose(self):
        yield Button("Process 1000 files", id="start")
        yield ProgressBar(total=1000, id="progress")
        yield Label("", id="status")

    @work
    async def process_files(self):
        for i in range(1000):
            # Simulate work
            await asyncio.sleep(0.01)
            self.query_one("#progress").advance(1)
            self.query_one("#status").update(f"Processed {i+1} files")
        self.query_one("#status").update("Done!")

    def on_button_pressed(self, event):
        if event.button.id == "start":
            self.process_files()

The user clicks the button. The button does not freeze. The progress bar updates smoothly. The user could click another button or even quit the app (the worker is cancelled automatically).

In a CLI tool, the user would be staring at a blank screen for 10 seconds, wondering if the script crashed.

From Zero to TUI: A Practical Migration

Let us migrate a real CLI tool to Textual. Suppose you have a script that scans a directory for duplicate files by size and hash.

The CLI version (pseudo-code):

import os, hashlib
dir_path = input("Enter directory: ")
files = {}
for root, _, filenames in os.walk(dir_path):
    for name in filenames:
        path = os.path.join(root, name)
        size = os.path.getsize(path)
        # ... hash file, group duplicates
        print(f"{path} - {size} bytes")
print("Duplicates:", duplicates)

Problems:

  • The user must know the exact path upfront.
  • The output scrolls past; finding duplicates requires scrolling back.
  • No way to delete or move duplicate files interactively.

The TUI version using Textual:

from textual.app import App, ComposeResult
from textual.widgets import DirectoryTree, DataTable, Button, Label
from textual.containers import Horizontal, Vertical

class DuplicateFinder(App):
    def compose(self):
        with Horizontal():
            yield DirectoryTree(".")
            with Vertical():
                yield DataTable(id="results")
                yield Button("Scan for duplicates", id="scan")
                yield Label("Ready", id="status")

    def on_directory_tree_file_selected(self, event):
        self.selected_path = event.path
        self.query_one("#status").update(f"Selected: {event.path}")

    def on_button_pressed(self, event):
        if event.button.id == "scan":
            self.scan_for_duplicates()

    def scan_for_duplicates(self):
        # Actual scan logic here (use worker)
        # Populate DataTable with results
        table = self.query_one("#results")
        table.clear()
        table.add_column("Size", "Path")
        # ... add rows for duplicates

The user browses the directory tree with their mouse or keyboard. They click “Scan”. Results appear in a sortable table. They can click on a row to open the file location. They can check boxes to delete duplicates.

This is not a “better CLI tool.” It is a fundamentally different category of tool. It respects the user’s time and attention.

Styling: Make Your Tools Beautiful

CLI tools look like the 1970s. Green text on a black background (if you are lucky). Textual lets you style everything with CSS.

Create a style.css file next to your Python script:

Screen {
    background: $surface;
}

Button {
    background: $primary;
    color: $text;
    padding: 1;
}

Button:hover {
    background: $secondary;
}

DataTable {
    height: 1fr;
    border: solid $accent;
}

#status {
    text-style: italic;
    color: $warning;
}

Load it in your app:

class MyApp(App):
    CSS_PATH = "style.css"

Textual uses a subset of CSS. You get variables ($primary, $surface), pseudo-classes (:hover), and layout properties (width, height, margin). Dark and light themes are automatic based on your terminal.

Your TUI will look professional. It will feel native. Users will not realize they are still inside a terminal emulator.

Keyboard Ninjas: Bindings and Focus

Not everyone uses a mouse. Terminal users love their keyboards. Textual supports both.

You can add global key bindings:

class MyApp(App):
    BINDINGS = [
        ("ctrl+s", "save", "Save file"),
        ("ctrl+q", "quit", "Quit"),
        ("f1", "help", "Show help"),
    ]

    def action_save(self):
        # Save current data
        pass

The footer widget automatically displays these bindings. Users see “Ctrl+S Save | Ctrl+Q Quit | F1 Help” at the bottom of the screen.

You can also manage focus. Press Tab to move between widgets. Press Enter to “click” a focused button. Use focus() in Python to programmatically set focus.

This dual-mode interaction (mouse + keyboard) is impossible in a CLI tool and overkill in a full GUI. The TUI hits the sweet spot.

Debugging TUIs: The Unexpected Challenge

One problem with TUIs: they take over the entire terminal. If your code crashes, where do the error messages go?

Textual provides a debugging console. Run your app with:

textual run --dev my_app.py

A second terminal window opens. All print() statements, tracebacks, and log messages appear there. Your main terminal stays clean.

You can also use the built-in console:

from textual import log
log("This goes to the debug console")
log(widget, "was clicked at", timestamp)

This makes debugging TUIs easier than debugging CLI scripts. No more scrambling to see errors before the terminal closes.

Testing Your TUI Automatically

CLI tools are easy to test: feed input, capture output, assert. TUIs seem harder because they are interactive.

Textual includes a pilot system for automated testing. A pilot acts like a virtual user. It can click buttons, type text, and assert on widget states.

Example test:

from textual.pilot import Pilot

async def test_my_app():
    app = MyApp()
    async with app.run_test() as pilot:
        # Click the greet button
        await pilot.click("#greet")
        # Check that label updated
        label = app.query_one("#message")
        assert label.renderable == "Hello, TUI developer!"
        # Press Ctrl+Q to quit
        await pilot.press("ctrl+q")

You can run these tests in CI pipelines. No manual clicking required. This makes TUIs as testable as CLI tools.

Real-World Use Cases

Stop thinking about TUIs as toys. They are used in production by major companies.

Database explorers – Instead of psql or mysql CLI, imagine a table browser with sortable columns, SQL query editor with syntax highlighting, and result exports.

Cloud CLIsaws and gcloud commands are great for scripts but painful for exploration. A TUI could show all EC2 instances in a table, let you click to see details, and start/stop instances with a button press.

Data science – Pandas dataframes printed to the terminal are unreadable. A TUI with a scrollable, sortable table makes CSV inspection a pleasure.

DevOps dashboards – Watch Kubernetes pods, Docker containers, or system metrics in real time. Click a pod to view logs without switching to kubectl logs.

Setup wizards – Instead of 20 input() prompts, a TUI wizard with multiple screens, validation, and back buttons. Users can change previous answers without restarting.

Game development tools – Level editors, sprite viewers, and script debuggers can all run inside the terminal using Textual.

Performance: Will It Handle My Data?

A common fear: “My CLI tool processes 100,000 items. Will a TUI slow it down?”

Textual is surprisingly fast. The DataTable widget handles 1 million rows with virtual scrolling. It only renders what is visible. Updating a single cell is O(1).

Background workers prevent UI freezing. Your heavy processing runs in a separate thread. The UI updates only when you explicitly call update().

Textual uses a dirty flag system. If you change a widget’s state, it schedules a redraw for the next animation frame. It does not redraw 60 times per second unless something changes.

For context: people have built Textual apps that stream real-time stock tickers (thousands of updates per second) and parse multi-gigabyte log files. The bottleneck is your terminal emulator, not Textual.

Common Pitfalls and How to Avoid Them

Pitfall 1: Writing blocking code inside event handlers.

def on_button_pressed(self, event):
    time.sleep(10)  # UI freezes for 10 seconds!

Fix: Use @work decorator or asyncio.

Pitfall 2: Directly manipulating widgets from background threads.

def background_task(self):
    self.query_one("#label").update("Done")  # Crash!

Fix: Use self.call_from_thread() or the @work decorator which automatically schedules UI updates.

Pitfall 3: Creating too many widgets dynamically.

for i in range(10000):
    self.mount(Button(f"Button {i}"))  # Very slow

Fix: Use ListView or DataTable which are optimized for large data sets.

Pitfall 4: Forgetting to handle terminal resize.

Textual handles this automatically. Do not fight it. Use fr units (fractional height/width) instead of absolute pixel sizes.

The Future: From CLI to TUI by Default

We are at a turning point. Terminal emulators have supported mouse events, true color, and Unicode for years. Yet most Python tools still behave like they are running on a 1978 teletype.

The excuse used to be complexity. Building a TUI required learning curses, handling signal interrupts, and managing raw input modes. It was painful.

Textual removes that pain. You can write a TUI in 50 lines of Python that would have taken 500 lines with curses.

My prediction: Within three years, most new Python developer tools will launch with a TUI as the primary interface. The CLI will become a secondary mode for automation and scripting.

You can be ahead of this curve. Start today. Stop writing CLI tools.

Putting It All Together: A Complete Example

Here is a complete, useful TUI: a password generator with adjustable length and character sets.

from textual.app import App, ComposeResult
from textual.widgets import Button, Input, Label, Checkbox, Footer
from textual.containers import Horizontal, Vertical
import secrets
import string

class PasswordGenerator(App):
    BINDINGS = [("ctrl+g", "generate", "Generate password")]

    def compose(self):
        with Vertical():
            yield Label("Password Generator", id="title")
            with Horizontal():
                yield Label("Length:", id="length_label")
                yield Input(value="16", id="length", type="integer")
            yield Checkbox("Include uppercase", id="uppercase", value=True)
            yield Checkbox("Include digits", id="digits", value=True)
            yield Checkbox("Include symbols", id="symbols", value=True)
            yield Button("Generate", variant="primary", id="generate")
            yield Label("Your password:", id="result_label")
            yield Label("", id="password", markup=False)

    def action_generate(self):
        self.generate_password()

    def on_button_pressed(self):
        self.generate_password()

    def generate_password(self):
        length = int(self.query_one("#length").value)
        chars = string.ascii_lowercase
        if self.query_one("#uppercase").value:
            chars += string.ascii_uppercase
        if self.query_one("#digits").value:
            chars += string.digits
        if self.query_one("#symbols").value:
            chars += string.punctuation

        password = ''.join(secrets.choice(chars) for _ in range(length))
        self.query_one("#password").update(password)

if __name__ == "__main__":
    PasswordGenerator().run()

Run this. You get a proper tool. The user can change length, toggle checkboxes, click the button, or press Ctrl+G. The password appears instantly. No command line arguments. No input() prompts. No manual validation.

This is the difference between a script and a tool.

Conclusion: Stop Typing, Start Building

You have spent enough time writing Python scripts that ask for input and print lines of text. You have debugged enough argparse errors. You have scrolled past enough ugly tables.

The terminal is capable of so much more. Your users deserve better.

Textual gives you the power to build terminal applications that feel modern, responsive, and delightful. The learning curve is gentle. The documentation is excellent. The community is growing.

Your next project does not need to be a CLI tool. It can be a TUI. Build a dashboard. Build a database browser. Build a file manager. Build something that makes you think, “I wish I had built this years ago.”

Stop writing CLI tools. Start building TUIs.