TTY, Colour & Interactivity

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Python 3.12, Typer 0.27.2 and Rich 15.0.0, Java 21 with notes for Java 22 and later, picocli 4.7.7 and the docsctl running example of this sub-section, as checked on 2026-09-30. The docsctl behaviour shown is illustrative; the fictional tool exists only on these pages.

A terminal is where colour, animation and questions make sense, because a person is looking at it and can answer. Almost everywhere else — a CI log, a pipe, a cron job, a container without a TTY, a tool call issued by an AI agent — those same features corrupt the output or hang the process. An agent cannot press "y", and an unexpected ANSI escape sequence in a tool result is noise that costs tokens and confuses parsing. The rule of this page is short: detect whether a terminal is really there, per stream, and turn the human-only features off when it is not.

Detecting a terminal with isatty

A TTY (from "teletypewriter") is a terminal device. The C library function isatty(3) tells a program whether a file descriptor refers to one. Every language wraps it: sys.stdout.isatty() in Python, Console.isTerminal() in Java 22 and later, Console.IsOutputRedirected in .NET, process.stdout.isTTY in Node.

Test each stream on its own, for the decision that stream affects:

Stream tested Question it answers Feature it controls

stdout is a TTY

Is a person reading the results?

Colour and styling of the output, tables that adapt to terminal width.

stderr is a TTY

Is a person watching the commentary?

Colour on error messages, progress bars and spinners.

stdin is a TTY

Can a person type an answer?

Prompts and confirmations.

clig.dev is explicit that the checks are independent: when you pipe stdout to another program, colour on stderr is still useful, because the errors are still going to a person’s screen. Redirection combinations are common (docsctl search kalman > out.txt leaves stdout a file but stderr a terminal), and a tool that checks only one stream gets one of them wrong.

Checking each stream (Python)
import sys

stdin_is_tty = sys.stdin.isatty()
stdout_is_tty = sys.stdout.isatty()
stderr_is_tty = sys.stderr.isatty()
Checking a terminal in Java (Java 22 and later)
import java.io.Console;

final class Terminal {
    private Terminal() { }

    /** True when the JVM's console is a terminal. In Java 22+ System.console() may be non-null even when redirected. */
    static boolean isInteractive() {
        Console console = System.console();
        return console != null && console.isTerminal();
    }
}

Console.isTerminal() was added in Java 22; its documentation says the default implementation behaves like isatty on standard input and output on POSIX systems. Before Java 22, System.console() returns null unless the JVM was started from an interactive command line without redirecting standard input and output, so System.console() != null is the equivalent test on Java 17 to 21. Two consequences: the Java class library offers no per-stream check for stderr alone, and a tool that must know about stderr specifically should treat "stdin and stdout are terminals" as its definition of interactive, or call a native-access library. Reference: Java — java.io.Console and isatty(3).

Colour: NO_COLOR, TERM=dumb and --no-color

Colour is useful when it points at something (an error, a changed line) and harmful everywhere a program does not render it. clig.dev’s rule is to disable colour when any of these is true:

  • stdout or stderr is not a TTY (checked per stream, as above);

  • the NO_COLOR environment variable is set and not empty, whatever its value (the convention published at no-color.org);

  • TERM is dumb;

  • the user passes --no-color.

Optionally, honour a tool-specific variable such as a DOCSCTL_NO_COLOR, and FORCE_COLOR for the opposite override (clig.dev lists both NO_COLOR and FORCE_COLOR among the general-purpose variables worth respecting). Two further habits keep output clean: never colour anything in --json, jsonl or --quiet output, and never rely on colour alone to convey meaning; error: and warning: prefixes carry the message when the colour is gone.

One decision function for colour (Python)
import os
import sys
from typing import TextIO


def use_colour(stream: TextIO, *, no_color_flag: bool = False) -> bool:
    if no_color_flag:                       # --no-color always wins
        return False
    if os.environ.get("NO_COLOR"):          # set and non-empty
        return False
    if os.environ.get("TERM") == "dumb":
        return False
    return stream.isatty()                  # decide per stream


# docsctl writes errors to stderr, so colour them only if stderr is a terminal
ERROR_PREFIX = "\x1b[31merror:\x1b[0m" if use_colour(sys.stderr) else "error:"

Libraries usually implement most of this for you. Rich, for example, honours NO_COLOR (which takes precedence over FORCE_COLOR), detects a dumb terminal, and turns colour off for files and pipes; you only add your own --no-color flag, by passing no_color=True to the Console. Note that Rich’s NO_COLOR handling removes colour only and keeps styles such as bold and underline, which is what no-color.org asks for.

Rich on stderr, with the tool’s own --no-color flag
from rich.console import Console


def make_error_console(no_color_flag: bool) -> Console:
    # no_color=None lets Rich auto-detect from NO_COLOR, TERM and whether stderr is a terminal
    return Console(stderr=True, no_color=True if no_color_flag else None)


console = make_error_console(no_color_flag=False)
console.print("[bold red]error:[/] component 'irurueta-algbra' not found")

On the Java side, picocli’s own help and error output follow a documented sequence: an explicit picocli.ansi system property wins, then NO_COLOR (picocli treats the variable as set regardless of its value), then CLICOLOR_FORCE and CLICOLOR, and finally a check on whether System.console() is non-null. Verify that last step on the JDK you ship, because Java 22 changed when System.console() is non-null. For the tool’s own output, apply the same rules explicitly.

Colour policy in Java
final class ColourPolicy {
    private ColourPolicy() { }

    static boolean enabled(boolean noColorFlag, boolean stderrIsTerminal) {
        if (noColorFlag) {
            return false;
        }
        String noColor = System.getenv("NO_COLOR");
        if (noColor != null && !noColor.isEmpty()) {
            return false;
        }
        if ("dumb".equals(System.getenv("TERM"))) {
            return false;
        }
        return stderrIsTerminal;
    }
}

Prompting only on a TTY

A prompt is a request for input from a person, and there is no person in a pipeline or an agent’s tool call. Reading from a stream that will never answer makes the process hang until something kills it. The rule from clig.dev: prompt only if stdin is an interactive terminal; otherwise fail immediately and say which flag would supply the answer. For docsctl, the tool is interactive only when:

  • stdin and stderr are TTYs (the prompt is written to stderr so that stdout stays clean for data), and

  • neither --no-input nor DOCSCTL_NO_INPUT=1 is set.

Anything else is non-interactive. Two more details from clig.dev: never echo a password as it is typed (Python’s getpass.getpass() and Java’s Console.readPassword() turn echo off), and always let the user escape with Ctrl-C. For an agent, the better answer is not to prompt for secrets at all: docsctl login --token-stdin reads the token from a pipe, and the page on configuration and auth covers headless authentication.

--no-input and --yes: fail fast and name the bypass

Two flags turn "interactive by default" into something a script or agent can control:

  • --no-input (or DOCSCTL_NO_INPUT=1) means never prompt, even if a terminal is present. This is the flag an agent or a CI job passes defensively. When a value would have been prompted for, the command fails and names the flag that supplies it.

  • --yes (-y) answers "yes" to ordinary confirmations. It is a deliberate opt-in: the caller has read what the command will do. It is not the same as --force, which bypasses safety checks rather than confirmations (the page on safety and idempotency separates them).

The failure message is part of the interface. It must state the reason, name the exact bypass flag, and exit with a distinct code, so that a driver knows whether to fix the command line (exit 2, a missing value) or ask a person (exit 5, confirmation required).

Failing fast when there is nobody to ask
$ docsctl publish --component irurueta-algebra < /dev/null
docsctl: publishing 'irurueta-algebra' overwrites the live site and needs confirmation,
but there is no terminal to ask on.
Re-run with --yes to confirm, or preview the change first with --dry-run.
$ echo $?
5
Confirmation with a fail-fast branch (Python, Typer)
import os
import sys

import typer

EXIT_CONFIRMATION_REQUIRED = 5


def is_interactive(no_input: bool) -> bool:
    if no_input or os.environ.get("DOCSCTL_NO_INPUT") == "1":
        return False
    return sys.stdin.isatty() and sys.stderr.isatty()


def confirm_publish(component: str, *, yes: bool, no_input: bool) -> None:
    if yes:
        return
    if not is_interactive(no_input):
        typer.echo(
            f"docsctl: publishing '{component}' overwrites the live site and needs confirmation, "
            "but there is no terminal to ask on.\n"
            "Re-run with --yes to confirm, or preview the change first with --dry-run.",
            err=True,
        )
        raise typer.Exit(code=EXIT_CONFIRMATION_REQUIRED)
    if not typer.confirm(f"Publish '{component}' to the live site?", default=False, err=True):
        raise typer.Exit(code=EXIT_CONFIRMATION_REQUIRED)  # the person declined

typer.confirm mirrors Click’s confirm, including the err=True argument that writes the question to stderr, and typer.Exit(code=…​) ends the command with a chosen status. Reference: Typer — Ask with Interactive Prompts and Typer — Terminating.

The same rule in Java (picocli and System.console)
import java.io.Console;
import java.util.concurrent.Callable;

import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;

@Command(name = "publish", description = "Publish a component to the site.")
final class PublishCommand implements Callable<Integer> {
    private static final int CONFIRMATION_REQUIRED = 5;

    @Option(names = "--component", required = true, paramLabel = "NAME")
    private String component;

    @Option(names = {"--yes", "-y"}, description = "Assume yes for confirmations.")
    private boolean yes;

    @Option(names = "--no-input", description = "Never prompt; fail if input would be needed.")
    private boolean noInput;

    private boolean interactive() {
        if (noInput || "1".equals(System.getenv("DOCSCTL_NO_INPUT"))) {
            return false;
        }
        Console console = System.console();
        return console != null && console.isTerminal(); // Java 22+; use console != null on Java 17-21
    }

    @Override
    public Integer call() {
        if (!yes) {
            if (!interactive()) {
                System.err.printf(
                    "docsctl: publishing '%s' overwrites the live site and needs confirmation, "
                        + "but there is no terminal to ask on.%n"
                        + "Re-run with --yes to confirm, or preview the change first with --dry-run.%n",
                    component);
                return CONFIRMATION_REQUIRED;
            }
            String answer = System.console().readLine("Publish '%s' to the live site? [y/N] ", component);
            if (answer == null || !answer.trim().equalsIgnoreCase("y")) {
                return CONFIRMATION_REQUIRED; // declined
            }
        }
        // ... perform the publish ...
        return 0;
    }

    public static void main(String[] args) {
        System.exit(new CommandLine(new PublishCommand()).execute(args));
    }
}

picocli passes the Integer returned by call() to execute(…​), which returns it as the exit code, so System.exit(…​) receives it. The question written by Console.readLine goes to the console device rather than to System.err, which is another reason the tool refuses to prompt unless stdout is a terminal too. Reference: picocli user manual and Java — java.io.Console.

Progress bars and spinners only on a TTY

An animated progress bar redraws one line using carriage returns and cursor movement. On a terminal that looks fine; in a log file or an agent’s tool result it becomes hundreds of partial lines that hide the useful ones (clig.dev warns that animation is what turns progress bars into an unreadable mess in CI logs). The rules are:

  • Animate only if stderr is a TTY, and never in --quiet mode or when the output format is JSON.

  • Send progress to stderr, never stdout, so that redirecting the data does not capture the bars.

  • When not on a TTY, print nothing, or print a few plain, timestamp-free lines (Indexed 120 of 480 pages) at coarse intervals. A silent tool feels broken, but a chatty one costs tokens.

  • Prefer a spinner or a short line for indeterminate work; use a bar only when the total is known.

A progress bar that only exists on a terminal (Python, Rich)
from rich.console import Console
from rich.progress import Progress


def index_pages(pages: list[str], *, quiet: bool, no_color: bool) -> None:
    console = Console(stderr=True, no_color=True if no_color else None)
    animated = console.is_terminal and not console.is_dumb_terminal and not quiet

    with Progress(console=console, transient=True, disable=not animated) as progress:
        task = progress.add_task("Indexing pages", total=len(pages))
        for page in pages:
            index_one(page)                     # the actual work
            progress.advance(task)

    if not animated and not quiet:
        console.print(f"Indexed {len(pages)} pages")  # one plain line for logs

Progress takes a console and a disable argument, and Console exposes is_terminal and is_dumb_terminal, as listed in the Rich reference. Making the decision explicit, rather than relying on library defaults alone, lets --quiet and the JSON modes take part in it. Reference: Rich — Progress Display and Rich — Console reference.

The same rule in Java (a spinner on stderr, only on a terminal)
final class Spinner implements AutoCloseable {
    private static final char[] FRAMES = {'|', '/', '-', '\\'};
    private final boolean animated;
    private int frame;

    Spinner(boolean stderrIsTerminal, boolean quiet) {
        this.animated = stderrIsTerminal && !quiet;
    }

    void tick(String message) {
        if (animated) {
            System.err.print("\r" + FRAMES[frame++ % FRAMES.length] + " " + message);
            System.err.flush();
        }
    }

    @Override
    public void close() {
        if (animated) {
            System.err.print("\r\u001b[2K"); // clear the line before the result is printed
            System.err.flush();
        }
    }
}

Java has no per-stream terminal test in the class library, so stderrIsTerminal has to come from the same "stdin and stdout are terminals" approximation used above, or from a native-access library. Where a lot of terminal control is needed, JLine (covered on Java CLI Frameworks) wraps the platform differences.

Windows console notes

Windows differs from Unix in ways that matter to these rules:

  • Escape sequences. Colour on Windows is done with the same ANSI/VT sequences, but the console must have virtual terminal processing enabled; Windows Terminal and current consoles support it, and older consoles may not. Libraries such as Rich detect a legacy console and adapt; if you write raw escape codes, enable ENABLE_VIRTUAL_TERMINAL_PROCESSING yourself, as described in Microsoft’s console virtual terminal sequences documentation, or use a library that does.

  • Detection. TERM is normally not set in cmd.exe and PowerShell, so do not treat its absence as a reason to disable colour; treat only the value dumb as a signal. Terminal emulators that run through a pseudo-console (Git Bash’s mintty is the usual case) may not look like a console to a native Windows program, so a tool can report "not a TTY" there while a person is present. Offer --no-color and, where you need the opposite, a force option.

  • .NET and PowerShell. Console.IsInputRedirected, Console.IsOutputRedirected and Console.IsErrorRedirected answer the per-stream question in .NET; in PowerShell set $env:NO_COLOR = "1" for a session.

  • Encoding. Do not assume UTF-8 on older code pages; keep non-TTY output ASCII-safe or make the encoding explicit (in Python, PYTHONUTF8=1), and avoid box-drawing characters and emoji in anything a script or agent will parse.

  • Ctrl-C and signals. The Unix signal model does not map one to one onto Windows, so test Ctrl-C handling and cleanup on Windows separately from Linux and macOS (see the page on streams and exit codes for the behaviour to aim for).