Input & UX

Supporting Dumb Terminals and Screen Readers in a Python CLI

Make CLI output work without cursor tricks: honour TERM=dumb, offer a --plain mode for screen-reader users, replace spinners and box tables with linear text, and test both modes.

Updated

Modern CLI output leans on the terminal: spinners that redraw ten times a second, progress bars that rewrite one line, tables drawn with box characters, colour as the only difference between "ok" and "failed". On a capable terminal with a sighted user that looks great. In other places it ranges from noisy to unusable. Emacs shell buffers and some IDE consoles set TERM=dumb and cannot move the cursor, so every redraw becomes a new line. A screen reader announces each spinner frame, or reads ┏━━━━┳━━━━┓ as a string of symbol names. Braille displays and logs captured from a pseudo-terminal show the same clutter. Supporting these environments does not mean giving up rich output — it means detecting when to switch to linear, plain output and making that mode a first-class, tested part of the CLI. This guide builds that switch. It belongs to the cross-platform terminal topic.

Prerequisites

Three separate concerns

Three switches, not one How colour, animation and decoration should be set for different environments a command line tool runs in. Three switches, not one Environment Colour Animation Decoration Capable terminal on on on NO_COLOR set off on on TERM=dumb off off on --plain (screen reader) either off off Pipe or file off off on Bundling them into one "plain" flag gets at least one environment wrong.

"Plain output" bundles three decisions that are worth keeping separate, because different environments need different combinations:

  1. Colour — off for NO_COLOR, pipes and dumb terminals.
  2. Animation and cursor movement — spinners, live-updating progress, redrawn tables. Off for dumb terminals, non-TTY output and screen-reader users.
  3. Decoration — box-drawing tables, Unicode symbols like ✔ and ⠋, emoji. Off for screen-reader users, and on terminals that cannot encode them.

A sighted user with NO_COLOR still wants animation; a screen-reader user may be fine with colour (it is simply not announced) but needs no animation and no decoration.

What Rich already does

Rich detects some of this itself. With TERM=dumb, a Rich Console reports is_dumb_terminal, disables colour, and a Progress bar does not animate: in a test, a three-step progress run under TERM=dumb produced two plain lines of output with no escape sequences and no carriage returns, where the same run on xterm-256color produced continuous redraws. When stdout is not a terminal, Rich likewise drops colour and animation. What Rich cannot detect is a person who needs linear output on a perfectly capable terminal — screen-reader users rarely set TERM=dumb, because other programs need full terminal features. That case needs an explicit switch.

The recipe

Centralise the decision in one policy object, computed once at startup:

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

import os
import sys
from dataclasses import dataclass


@dataclass(frozen=True)
class OutputMode:
    color: bool
    animate: bool
    decorate: bool


def detect(*, plain_flag: bool = False, stream=None) -> OutputMode:
    stream = stream or sys.stdout
    env = os.environ
    tty = stream.isatty()
    dumb = env.get("TERM", "") == "dumb"
    plain = plain_flag or env.get("MYTOOL_PLAIN", "") not in ("", "0")
    no_color = "NO_COLOR" in env and env["NO_COLOR"] != ""
    return OutputMode(
        color=tty and not dumb and not no_color,
        animate=tty and not dumb and not plain,
        decorate=not plain,
    )

Then make every piece of fancy output consult it. Progress is the most important, because it is the noisiest when it goes wrong:

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

from collections.abc import Iterable, Iterator
from typing import TypeVar

from rich.console import Console
from rich.progress import track

from mytool.outputmode import OutputMode

T = TypeVar("T")


def progress(items: Iterable[T], total: int, label: str, mode: OutputMode,
             console: Console) -> Iterator[T]:
    if mode.animate:
        yield from track(items, total=total, description=label, console=console)
        return
    step = max(1, total // 4)                  # a few milestones, not one line per item
    for done, item in enumerate(items, start=1):
        yield item
        if done % step == 0 or done == total:
            console.print(f"{label}: {done} of {total} done")

In plain mode, the user hears or reads a handful of meaningful milestones — "upload: 25 of 100 done" — instead of a stream of redraws. Tables get the same treatment: with decorate=False, render records as name: value lines or tab-separated rows instead of a box-drawn grid; the record-and-renderer design from the output formats topic makes that a matter of choosing a different renderer.

# src/mytool/cli.py
import time

import typer
from rich.console import Console

from mytool.outputmode import detect
from mytool.progress import progress

app = typer.Typer()


@app.callback()
def main(ctx: typer.Context,
         plain: bool = typer.Option(False, "--plain", help="Linear output: no animation, no box drawing.")) -> None:
    """Uploader."""
    mode = detect(plain_flag=plain)
    ctx.obj = {"mode": mode, "console": Console(no_color=not mode.color, emoji=mode.decorate)}


@app.command()
def upload(ctx: typer.Context, count: int = 8) -> None:
    """Upload files."""
    mode, console = ctx.obj["mode"], ctx.obj["console"]
    for _ in progress(range(count), count, "upload", mode, console):
        time.sleep(0.01)
    console.print("OK: uploaded all files" if not mode.decorate else "✔ uploaded all files")
The same command, linear Terminal session running an upload command in plain mode, printing a few milestone lines and a word-based status instead of a progress bar and symbol. The same command, linear bash $ mytool --plain upload --count 8 upload: 2 of 8 done upload: 4 of 8 done upload: 6 of 8 done upload: 8 of 8 done OK: uploaded all files Five lines a screen reader can read — no redraws, no symbols, no box drawing.

Note the final line: in plain mode the status word OK carries the meaning that the check mark carries visually. Every status message should work without colour and without symbols.

UX considerations

  • Offer both a flag and an environment variable. --plain for one run, MYTOOL_PLAIN=1 in a shell profile for users who always need it. Mention both in help and in an accessibility section of the docs.
  • Words over symbols and colour. "failed", "warning", "3 of 10 done" — never a red ✘ alone.
  • Fewer, more meaningful lines. In linear mode, every line is read aloud; milestones beat per-item updates, and summaries beat repetition.
  • No interactive surprises. Prompts are fine for screen readers, but full-screen TUIs often are not; make sure every TUI feature has a plain command path, as in choosing between a CLI, a prompt flow and a TUI.
  • Ask users. Accessibility needs vary; an issue template that invites feedback from assistive-technology users finds problems no test will.
Fancy output and its plain form Rich command line output elements and the plain, linear equivalent to use in plain mode. Fancy output and its plain form Fancy Plain equivalent Spinner / progress bar milestone lines: 25%, 50%… Box-drawn table name: value lines or TSV ✔ / ✘ symbols OK / FAILED words Red text for errors "error:" prefix Words carry the meaning; colour and symbols only decorate it.

Testing the behaviour

Test the policy as a pure function across environments, and test that plain mode never emits escape sequences or carriage returns:

# tests/test_plain.py
import io

import pytest
from typer.testing import CliRunner

from mytool.cli import app
from mytool.outputmode import detect


class FakeTTY(io.StringIO):
    def isatty(self) -> bool:
        return True


@pytest.mark.parametrize("env,flag,expected", [
    ({"TERM": "xterm-256color"}, False, (True, True, True)),
    ({"TERM": "dumb"}, False, (False, False, True)),
    ({"TERM": "xterm-256color", "NO_COLOR": "1"}, False, (False, True, True)),
    ({"TERM": "xterm-256color"}, True, (True, False, False)),
    ({"TERM": "xterm-256color", "MYTOOL_PLAIN": "1"}, False, (True, False, False)),
])
def test_policy(monkeypatch, env, flag, expected):
    for var in ("TERM", "NO_COLOR", "MYTOOL_PLAIN"):
        monkeypatch.delenv(var, raising=False)
    for key, value in env.items():
        monkeypatch.setenv(key, value)
    mode = detect(plain_flag=flag, stream=FakeTTY())
    assert (mode.color, mode.animate, mode.decorate) == expected


def test_plain_output_is_linear():
    result = CliRunner().invoke(app, ["--plain", "upload", "--count", "8"])
    assert result.exit_code == 0
    assert "\x1b" not in result.output and "\r" not in result.output
    assert result.output.splitlines() == ["upload: 2 of 8 done", "upload: 4 of 8 done",
                                          "upload: 6 of 8 done", "upload: 8 of 8 done",
                                          "OK: uploaded all files"]

The second test is the contract plain mode promises: no escape sequences, no carriage returns, and a short, readable transcript. It is also a good candidate for a transcript test.

Conclusion

Rich output and accessible output are compatible if the choice is explicit. Separate colour, animation and decoration; let Rich handle TERM=dumb and non-TTY output, and add a --plain flag plus MYTOOL_PLAIN for people who need linear output on capable terminals; replace spinners with milestone lines, box tables with plain records and symbols with words; and test that plain mode emits nothing but text. Users of Emacs, IDE consoles, logs and screen readers all benefit.

Frequently asked questions

Is TERM=dumb enough for screen-reader users?

Usually not. Setting it globally degrades other programs that the user relies on, such as editors. A tool-specific switch lets users opt into linear output for your CLI only.

Do screen readers handle colour codes?

The escape sequences themselves are normally not spoken, but colour carries no meaning for a non-visual user. Plain mode can keep colour on; what matters is that words, not colours, carry the status.

What about progress for very long operations?

Milestones by percentage (25%, 50%, …) or by time (one line every 30 seconds) both work. Include an estimate of what remains when you can, since that is the information a progress bar conveys visually.

Should plain mode change JSON output?

No. Machine-readable formats are already linear and undecorated; plain mode only affects human-oriented output.

How do I check the experience with a real screen reader?

Try it: VoiceOver is built into macOS, NVDA is free on Windows, and Orca ships with many Linux desktops. Run a few common commands in both modes and listen. Ten minutes of this usually reveals more than any checklist — a header row read before every table line, a spinner announced as punctuation, a status that only exists as a colour.

Should plain mode be the default anywhere?

It is effectively the default whenever output is not a terminal, because Rich already stops animating and colouring there. Some teams also make it the default in CI logs for readability; the CI detection from the cross-platform topic makes that a one-line rule.