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.
import sys
stdin_is_tty = sys.stdin.isatty()
stdout_is_tty = sys.stdout.isatty()
stderr_is_tty = sys.stderr.isatty()
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_COLORenvironment variable is set and not empty, whatever its value (the convention published at no-color.org); -
TERMisdumb; -
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.
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.
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.
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;
}
}
Reference: NO_COLOR, clig.dev — Output, Rich — Console and picocli — Heuristics for enabling ANSI.
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-inputnorDOCSCTL_NO_INPUT=1is 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(orDOCSCTL_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).
$ 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
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.
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
--quietmode 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.
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.
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_PROCESSINGyourself, as described in Microsoft’s console virtual terminal sequences documentation, or use a library that does. -
Detection.
TERMis normally not set in cmd.exe and PowerShell, so do not treat its absence as a reason to disable colour; treat only the valuedumbas 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-colorand, where you need the opposite, a force option. -
.NET and PowerShell.
Console.IsInputRedirected,Console.IsOutputRedirectedandConsole.IsErrorRedirectedanswer 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-Chandling and cleanup on Windows separately from Linux and macOS (see the page on streams and exit codes for the behaviour to aim for).