Input & UX

Choosing Between a CLI, a Prompt Flow and a TUI

Decide how a Python tool should interact with people: plain commands, guided prompts or a full-screen TUI — with criteria, a layered design and code for each.

Updated

When a Python tool starts growing, someone always asks for it to be "more interactive". Sometimes they mean prompts that walk a new user through setup; sometimes a full-screen dashboard with arrow-key navigation; sometimes just nicer output. Each of those is a different interface style with different costs, and picking the wrong one produces tools that are either tedious for daily use or impossible to automate. This guide compares the three styles a Python CLI can offer — plain commands, prompt flows and full-screen TUIs — gives concrete criteria for choosing, and shows how to build them as layers over one core so you never have to choose only one. It belongs to the building terminal UIs with Textual topic.

Prerequisites

  • A CLI built with Typer or Click, with its logic in core functions separate from the command layer, as described in how to structure a large Python CLI project.
  • A specific task in mind. The right interface depends on the task, not the tool.

The three styles

Plain commands take everything as arguments and options, do their work, and print results. They are scriptable, repeatable, composable with pipes, documented by --help, and fast for people who know what they want. They are the foundation; every other style is optional.

Prompt flows ask questions one at a time — "Project name?", "Which template?" — and are good for tasks done rarely and involving several choices: first-time setup, generating a config file, a release checklist. Done well, every prompt corresponds to a flag, so the same command can run unattended.

Full-screen TUIs take over the terminal with an interactive, continuously updated view: lists to scroll, panels that update, keys that act on the selected item. They excel at exploring and monitoring — browsing hundreds of items, watching a queue, triaging alerts — and are useless for automation.

The recipe: choosing by task

Which interface does this task need? A decision for interface style: repeated or automated tasks get commands, occasional multi-step setup gets a prompt flow, browsing or monitoring large state gets a TUI. Which interface does this task need? How is this task done? Repeatedly, or by scripts Command flags + JSON Rarely, with many choices Prompt flow with flags too Exploring or watching data TUI on top of commands Most tools need the first, some benefit from the second, few need the third.

Ask how the task is actually done:

  • Repeatedly, or by scripts and CI → a command. If people do it daily, every extra keystroke in a prompt or TUI is friction; if a machine does it, anything interactive is a bug. Machine-readable output (--json) belongs here too, as covered in emitting JSON output for scripting.
  • Rarely, with several decisions a newcomer will not know → a prompt flow, on top of a command that accepts the same answers as flags.
  • Exploring or watching data whose shape the user does not know in advance → a TUI, on top of commands that expose the same data.

A few signals point the same way. If you find yourself writing documentation that says "run list, find the ID, then run show ID, then rollback ID", a TUI or at least an interactive picker may help. If support questions are mostly "what do I put in the config?", a prompt flow for init will help more than a better README. If people wrap your command in shell loops, it is already doing its job as a command — do not make it interactive.

The recipe: layering, so you never choose only one

Three interfaces, one core A command line tool offering plain commands, an interactive prompt flow and a full-screen TUI, all built on the same core functions. Three interfaces, one core mytool one package deploy --env prod command init (prompts) guided setup browse (TUI) explore deploys mytool.core shared logic Every TUI action has a command Prompts are skipped with flags Core has no UI imports Layering is what keeps a TUI from becoming a second, divergent implementation.

The best tools offer the styles as layers over one core, so each task gets the right interface and nothing is duplicated. The command is the base; prompts fill in only what flags did not provide; a TUI calls the same core functions and can hand results back to commands.

# src/mytool/cli.py
from __future__ import annotations

import sys
from typing import Annotated

import typer

from mytool.core import TEMPLATES, create_project

app = typer.Typer()


def interactive() -> bool:
    return sys.stdin.isatty() and sys.stdout.isatty()


@app.callback()
def main() -> None:
    """Project tool with commands, guided prompts and an interactive picker."""


@app.command()
def init(
    name: Annotated[str | None, typer.Option(help="Project name.")] = None,
    template: Annotated[str | None, typer.Option(help=f"One of: {', '.join(TEMPLATES)}.")] = None,
    no_input: Annotated[bool, typer.Option("--no-input", help="Never prompt; fail if values are missing.")] = False,
) -> None:
    """Create a project. Prompts for anything not given as an option."""
    can_ask = interactive() and not no_input
    if name is None:
        if not can_ask:
            raise typer.BadParameter("required when not running interactively", param_hint="--name")
        name = typer.prompt("Project name")
    if template is None:
        if not can_ask:
            template = "basic"                                   # a documented default
        else:
            template = typer.prompt("Template", default="basic",
                                    type=typer.Choice(list(TEMPLATES)), show_choices=True)
    path = create_project(name, template)
    typer.echo(f"created {path}", err=True)


@app.command()
def pick() -> None:
    """Choose a project interactively and print its name (for use in $(...))."""
    if not interactive():
        typer.echo("error: pick needs a terminal; use 'mytool list' in scripts", err=True)
        raise typer.Exit(2)
    from mytool.tui import ProjectPicker                         # Textual imported only here
    chosen = ProjectPicker().run()
    if chosen is None:
        raise typer.Exit(1)
    typer.echo(chosen)
# src/mytool/core.py
from pathlib import Path

TEMPLATES = {"basic": "A minimal CLI", "service": "A CLI with an HTTP client", "data": "A data pipeline CLI"}


def create_project(name: str, template: str) -> Path:
    if template not in TEMPLATES:
        raise ValueError(f"unknown template {template!r}")
    path = Path(name)
    path.mkdir(exist_ok=False)
    (path / "TEMPLATE").write_text(template + "\n", encoding="utf-8")
    return path

The design principles embedded here:

  • Every prompt has a flag. mytool init --name web --template service runs with no questions, so documentation, scripts and CI all use the same command a newcomer uses interactively.
  • Prompts only fill gaps. A value given as a flag is never asked for again.
  • No terminal, no prompts. When stdin or stdout is not a terminal, or --no-input is set, the command either uses a documented default or fails immediately with the flag to use — it never hangs waiting for input. The same rule is covered from the security side in prompting for passwords securely.
  • The TUI returns data. pick prints the chosen name, so mytool open $(mytool pick) combines the TUI with ordinary commands. The picker itself can be as small as the one in testing Textual apps with Pilot.
  • One core. Commands, prompts and the TUI all call create_project and friends; there is no second implementation to drift.

Richer prompt flows — menus, checkboxes, fuzzy selection — are covered in building interactive prompts and menus; the layering rules stay the same.

What each style costs

What a TUI costs you The ongoing costs of shipping a full-screen terminal UI compared with what it gives users. What a TUI costs you A TUI gives Browsing large lists without flags Live views that update in place Discoverable actions via key hints A TUI costs A second UI to test and maintain Nothing a script can drive Accessibility and terminal-quirk work Import time for every invocation that loads it Import Textual only inside the command that runs the TUI, so the rest of the CLI stays fast.

Commands are the cheapest to build and test and the most valuable to automate. Prompts add a modest amount of code and a testing burden for the interactive path. A TUI is a second user interface: it needs its own tests, has its own terminal-compatibility quirks, adds import time wherever it is loaded, and produces nothing a script can use. None of that argues against building one — for the right task a TUI is transformative — but it argues for building it last, on top of commands that already work.

UX considerations

  • Default to the least interactive style that serves the task. Interactivity is a cost for experienced users; add it where newcomers or exploration genuinely need it.
  • Show the equivalent command. After a prompt flow, print the flags that would reproduce it: "next time: mytool init --name web --template service". Users learn the fast path from the slow one.
  • Keep TUI and commands in sync by construction, not by discipline: the same core functions, and tests that exercise both.
  • Respect the environment. Prompts and TUIs must detect when they cannot run — pipes, CI, TERM=dumb — and fall back or fail fast, as in detecting CI environments and non-interactive shells.

Testing the behaviour

Test every layer's non-interactive contract with CliRunner — which provides no terminal, so it exercises exactly the paths scripts and CI take:

# tests/test_layers.py
from typer.testing import CliRunner

from mytool.cli import app

runner = CliRunner()


def test_flags_only_never_prompts(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    result = runner.invoke(app, ["init", "--name", "web", "--template", "service"])
    assert result.exit_code == 0
    assert (tmp_path / "web" / "TEMPLATE").read_text() == "service\n"


def test_missing_name_without_terminal_fails_fast(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    result = runner.invoke(app, ["init"])
    assert result.exit_code == 2
    assert "--name" in result.output


def test_default_template_when_not_interactive(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    assert runner.invoke(app, ["init", "--name", "api"]).exit_code == 0
    assert (tmp_path / "api" / "TEMPLATE").read_text() == "basic\n"


def test_picker_refuses_without_terminal():
    result = runner.invoke(app, ["pick"])
    assert result.exit_code == 2 and "mytool list" in result.output

For the interactive paths, patch interactive to return True and feed answers through input= to test prompts, and use Textual's Pilot for the TUI. The non-interactive tests above matter most: they are what guarantees that adding interactivity never breaks automation.

Conclusion

Commands, prompt flows and TUIs are tools for different jobs: commands for anything repeated or automated, prompts for rare tasks with several decisions, TUIs for exploring and watching data. Choose by how the task is done, and build them as layers — prompts that only fill gaps left by flags, a TUI that calls the same core and returns data to commands — so every task gets the right interface and none of them becomes a second implementation. Keep the non-interactive paths tested, and interactivity will only ever add to what the tool can do.

Frequently asked questions

Should init prompt by default or require --interactive?

Prompting by default when a terminal is attached and values are missing is the friendliest behaviour, provided it never prompts without a terminal and --no-input disables it. Requiring --interactive suits tools whose users are mostly automation.

Is a "wizard" with many steps ever a good idea?

For genuinely complex one-time setup, yes — but keep it short, show progress ("step 2 of 4"), allow going back, and write the result to a config file the user can edit instead of re-running the wizard.

When is Rich's Live display enough instead of a TUI?

When the user only watches and does not navigate. A Live table or progress display, as in live dashboards with Rich Live, is far simpler than a TUI and stays inside a normal command.

Can the TUI call the CLI's commands directly?

Call the core functions instead. Invoking Click commands from inside a TUI mixes two input models and makes errors awkward to show. The TUI and commands should be siblings over the same core, not layered on each other.