AgentSkillsCN

terminal-concepts

全面指南:构建 CLI 与 TUI 应用——深入解析终端内部原理、设计原则,以及经过实战检验的最佳实践模式 当用户着手开发 CLI/TUI 应用,需要实现参数解析、处理终端输入输出、运用转义字符、缓冲机制、信号处理,或就终端开发相关概念展开提问时

SKILL.md
--- frontmatter
name: terminal-concepts
description: |
  Comprehensive guide for building CLI and TUI applications - terminal internals, design principles, and battle-tested patterns
  When building CLI/TUI apps, implementing argument parsing, handling terminal input/output, escape codes, buffering, signals, or asking about terminal development concepts

Terminal Concepts for Developers

Overview

This agent provides comprehensive guidance for building and developing terminal applications (CLI tools and TUIs). Learn how terminals work, design principles from proven programs, and practical patterns for creating robust, user-friendly command-line applications.

Philosophy: Focus on timeless concepts and learn from battle-tested programs like git, vim, tmux, less, ripgrep, and fzf.

Target Audience: Developers at all levels building terminal applications in any language.

What Developers Need to Know

Building terminal applications requires understanding several interconnected concepts:

  1. Terminal Fundamentals: How TTYs, PTYs, streams, and buffering work
  2. CLI Design: User-facing interface principles (arguments, output, errors)
  3. TUI Architecture: Interactive full-screen applications
  4. Common Pitfalls: Issues developers face and how to avoid them

Learning from Proven Programs

The best way to understand terminal application design is to study programs that have stood the test of time:

  • git: Consistent subcommand interface, porcelain vs plumbing separation
  • vim: Modal editing, robust state management
  • tmux: Client-server architecture, session management
  • less: Pager pattern, search and navigation
  • ripgrep: Sensible defaults, performance-focused design
  • fzf: Interactive filtering, minimal interface
  • cargo: Clear progress indicators, helpful error messages

Throughout this guide, we'll reference these programs to illustrate concepts.


Part 1: Terminal Fundamentals for Developers

Understanding the Terminal Stack

When you build a terminal application, you're working within a layered system:

code
┌─────────────────────────────────┐
│   Terminal Emulator             │ ← User sees (renders text, sends input)
│   (iTerm2, Alacritty, etc.)     │
└─────────────────────────────────┘
              ↕ (PTY)
┌─────────────────────────────────┐
│   Shell (bash, zsh, fish)       │ ← Interprets commands
└─────────────────────────────────┘
              ↕ (fork/exec)
┌─────────────────────────────────┐
│   Your Program                  │ ← Your CLI/TUI application
└─────────────────────────────────┘

TTYs and PTYs

TTY (Teletypewriter): Originally physical devices, now refers to the terminal driver in the operating system.

PTY (Pseudo-Terminal): A pair of virtual devices that emulate a TTY:

  • Master side: Terminal emulator reads/writes here
  • Slave side: Your program sees this as stdin/stdout/stderr

Why This Matters for Developers:

  • Your program receives input filtered through the TTY driver
  • Some control characters are intercepted by the OS (Ctrl-C, Ctrl-Z)
  • Buffering behavior differs between TTY and pipes
  • You need to detect if you're running in a TTY vs pipe

Terminal Modes: Cooked vs Raw

Cooked Mode (Canonical Mode):

  • Default mode for most programs
  • OS provides line editing (backspace, Ctrl-U, Ctrl-W)
  • Input delivered to your program line-by-line after Enter
  • Control characters handled by OS (Ctrl-C sends SIGINT)

Raw Mode:

  • Character-by-character input (no line buffering)
  • Your program receives every keypress including Ctrl-C
  • Required for TUIs (vim, less, htop)
  • Your program must implement all input handling

When to Use Each:

  • Cooked mode: Normal CLI tools (git, cargo, npm)
  • Raw mode: Interactive TUIs, line editors

Standard Streams and File Descriptors

Every process has three standard streams:

StreamFDPurposeExamples
stdin0Input from user or pipeReading commands, file content
stdout1Primary outputResults, listings, JSON
stderr2Errors, logs, diagnosticsError messages, warnings, debug

Critical Design Principle: Separate stdout and stderr properly.

Why This Matters:

bash
# User wants to pipe your output
your-tool | jq .         # Only works if output goes to stdout

# User wants to capture errors
your-tool 2> errors.log  # Only works if errors go to stderr

Examples from Proven Programs:

  • git: Errors to stderr, porcelain output to stdout
  • ripgrep: Matches to stdout, warnings to stderr
  • cargo: Build status to stderr, --message-format=json to stdout

Detecting TTY vs Pipe

Your program must detect its output destination to format appropriately:

Concept (language-agnostic):

code
if isatty(stdout):
    # Human is watching - use colors, progress bars
    enable_colors()
    show_progress()
else:
    # Piped to another program - plain output
    disable_colors()
    no_progress()

Real-world examples:

  • ls --color=auto: Colors only for TTY
  • git status: Full output for TTY, shorter for pipes
  • ripgrep: Colors and summaries for TTY, plain matches for pipes

C Implementation:

c
#include <unistd.h>

if (isatty(STDOUT_FILENO)) {
    // stdout is a TTY
}

Rust Implementation:

rust
use std::io::IsTerminal;

if std::io::stdout().is_terminal() {
    // stdout is a TTY
}

Python Implementation:

python
import sys

if sys.stdout.isatty():
    # stdout is a TTY

Go Implementation:

go
import "golang.org/x/term"

if term.IsTerminal(int(os.Stdout.Fd())) {
    // stdout is a TTY
}

ASCII Control Characters

The 33 Control Characters

Control characters are created by holding Ctrl and pressing a key. There are 33 total:

  • Ctrl-A through Ctrl-Z (26 characters)
  • Plus 7 more: Ctrl-@, Ctrl-[, Ctrl-, Ctrl-], Ctrl-^, Ctrl-_, Ctrl-?

Three Categories

1. OS-Handled (Terminal Driver Intercepts):

KeyASCIINameFunction
Ctrl-C3ETXSends SIGINT (interrupt)
Ctrl-D4EOTEOF when line is empty
Ctrl-Z26SUBSends SIGTSTP (suspend)
Ctrl-S19XOFFFreezes output (flow control)
Ctrl-Q17XONResumes output
Ctrl-\28FSSends SIGQUIT

2. Keyboard Literals:

KeyASCIINameUsage
Enter13CRLine terminator
Tab9HTTab character
Backspace127DELDelete previous character
Ctrl-H8BSOften same as backspace

3. Application-Specific (Your Program Can Define):

KeyCommon Usage
Ctrl-AMove to line start (readline, emacs)
Ctrl-EMove to line end
Ctrl-WDelete word backwards
Ctrl-UDelete line
Ctrl-KKill to end of line
Ctrl-RReverse search (shells)
Ctrl-LClear screen
Ctrl-P/NPrevious/Next (history navigation)

What Developers Can and Cannot Intercept

In Cooked Mode:

  • OS handles: Ctrl-C, Ctrl-D, Ctrl-Z, Ctrl-S, Ctrl-Q
  • You see: Ctrl-A, Ctrl-E, Ctrl-W (already processed by line editing)

In Raw Mode (TUIs):

  • You can intercept almost everything including Ctrl-C
  • Exception: Ctrl-Z often still suspends (OS-level)
  • You must handle all line editing yourself

Best Practice: Respect user expectations. Don't redefine Ctrl-C unless you have a very good reason (and document it clearly).

Limited Modifier Combinations

Unlike GUI applications, terminals have severe limitations:

  • Only 33 Ctrl combinations total
  • Ctrl-Shift-X doesn't exist as a distinct character
  • Ctrl-[number] combinations limited
  • Alt/Meta combinations inconsistent across terminals

Implication: Design keyboard shortcuts carefully. You have far fewer options than GUI apps.


Escape Codes and Standards

What Are Escape Codes?

Escape codes are invisible character sequences that control terminals. They start with ESC (ASCII 27, written as \x1b, \033, or \e).

Two types:

  1. Input codes: Generated by keypresses (arrow keys, function keys)
  2. Output codes: Your program uses to control display (colors, cursor movement)

Output Escape Code Standards

ECMA-48 Foundation

The base standard defining escape code formats:

CSI (Control Sequence Introducer): ESC [ followed by parameters

code
ESC[2J        # Clear screen
ESC[H         # Move cursor to home
ESC[1;31m     # Red foreground color

OSC (Operating System Command): ESC ] followed by parameters

code
ESC]0;Title\x07    # Set window title
ESC]52;c;base64\x07   # Clipboard access (OSC 52)

XTerm Extensions

XTerm added features beyond ECMA-48:

  • Mouse reporting (click, drag, scroll)
  • Clipboard access via OSC 52
  • Extended color modes (256 colors, RGB)

These aren't formally standardized but are widely supported because xterm is so influential.

Terminfo Database

A database mapping terminal types to their capabilities:

bash
echo $TERM              # xterm-256color, screen-256color, etc.
infocmp $TERM           # Dump terminal capabilities
tput bold               # Output "bold" escape sequence for $TERM

Terminfo Approach:

  1. Query terminfo for escape sequences
  2. Adapts to different terminals automatically
  3. Complex to use, slower

Hardcoded Approach:

  1. Use common escape sequences directly
  2. Test across major terminals
  3. Simpler, faster, good enough for most

Most modern programs use the hardcoded approach for the subset of widely-supported sequences.

Practical Compatibility Strategies

Strategy 1: Stick to well-supported sequences

  • 16 ANSI colors (ESC[30-37m, ESC[40-47m)
  • Basic cursor movement (ESC[H, ESC[A/B/C/D)
  • Clear screen/line (ESC[2J, ESC[K)

Strategy 2: Test on major terminal emulators

  • iTerm2, Alacritty (macOS)
  • GNOME Terminal, Konsole (Linux)
  • Windows Terminal (Windows)
  • tmux and screen (multiplexers)

Strategy 3: Provide fallbacks

code
if supports_256_colors():
    use_256_color_palette()
elif supports_16_colors():
    use_basic_colors()
else:
    no_colors()

Examples from Proven Programs:

  • less: Queries terminfo, falls back to hardcoded
  • vim: Extensive terminfo support with fallbacks
  • ripgrep: Hardcoded ANSI, works everywhere

When to Use Libraries vs Raw Escape Codes

Use Libraries When:

  • Building complex TUIs (ncurses, crossterm, etc.)
  • Need mouse support
  • Want automatic capability detection
  • Cross-platform support (Windows Console API)

Use Raw Escape Codes When:

  • Simple color output
  • Progress bars
  • Cursor positioning for simple UIs
  • You want minimal dependencies

Buffering Deep Dive

Three Buffering Modes

Unbuffered: Every write goes directly to the destination

  • Slowest (syscall overhead)
  • Used for stderr by default

Line Buffered: Flush on newlines

  • Used for stdout when writing to TTY
  • Balance of performance and responsiveness

Block Buffered: Flush when buffer full (~8KB)

  • Used for stdout when writing to pipe/file
  • Most efficient for throughput

Why Your Program Uses Different Buffering

The standard library (libc, Go runtime, Python runtime) automatically detects with isatty():

code
if isatty(stdout):
    use_line_buffering()     # Interactive user
else:
    use_block_buffering()    # Pipe or file

This is why pipes get stuck!

The Pipe Buffering Problem

bash
tail -f log.txt | grep ERROR
# Hangs! grep is waiting for 8KB before flushing

Why:

  1. grep sees stdout is a pipe (not TTY)
  2. Uses block buffering (8KB threshold)
  3. Waits to accumulate data
  4. Never reaches threshold with sparse matches
  5. Appears frozen

Solutions for Developers Building CLI Tools

Solution 1: Add --line-buffered flag

C Implementation:

c
#include <stdio.h>

if (line_buffered_flag) {
    setvbuf(stdout, NULL, _IOLBF, 0);
}

// Or manually flush:
printf("output\n");
fflush(stdout);

Rust Implementation:

rust
use std::io::{self, Write};

fn main() {
    let stdout = io::stdout();
    let mut handle = stdout.lock();

    writeln!(handle, "output").unwrap();
    handle.flush().unwrap();  // Manual flush
}

Python Implementation:

python
import sys

# Enable line buffering
sys.stdout.reconfigure(line_buffering=True)

# Or manual flush
print("output", flush=True)

# Or environment variable
# PYTHONUNBUFFERED=1 python script.py

Go Implementation:

go
import (
    "bufio"
    "os"
)

writer := bufio.NewWriter(os.Stdout)
writer.WriteString("output\n")
writer.Flush()  // Manual flush

Solution 2: Always flush after important output

Best Practices:

  • Flush after progress updates
  • Flush after each line of JSON in streaming mode
  • Flush before long computations
  • Provide --line-buffered for tools that filter streams

Examples from Proven Programs:

  • grep --line-buffered: Solves pipe buffering
  • sed -u: Unbuffered mode
  • awk: Has no built-in flag (common complaint)

Testing Buffered Output

In CI/CD:

bash
# Force line buffering
stdbuf -oL your-tool | other-tool

# Or use unbuffer (expect package)
unbuffer your-tool | other-tool

In Tests:

  • Mock the TTY with PTY libraries
  • Test both TTY and non-TTY paths
  • Verify flushing behavior

Input Escape Sequences

Arrow Keys and Function Keys

When users press special keys, terminals send multi-character escape sequences:

KeySequenceNotes
UpESC[ACSI sequence
DownESC[B
RightESC[C
LeftESC[D
HomeESC[H or ESC[1~Varies by terminal
EndESC[F or ESC[4~
Page UpESC[5~
Page DownESC[6~
F1ESC OP or ESC[[AHighly variable
F12ESC[24~

Distinguishing ESC Key from Escape Sequences

The Problem: ESC character can mean:

  1. User pressed ESC key (standalone)
  2. Start of escape sequence (ESC[A for up arrow)

Solution: Timeout-based parsing

code
Read character:
    If ESC:
        Wait ~50ms for next character:
            If timeout: User pressed ESC
            Else: Start of sequence, continue reading

Proven Programs:

  • vim: Uses 1 second timeout (customizable with ttimeoutlen)
  • less: Uses short timeout
  • readline: Configurable timeout

Mouse Events

Modern terminals can send mouse events (clicks, drags, scrolls):

code
ESC[<0;10;5M    # Mouse button press at column 10, row 5

Enable mouse reporting:

code
ESC[?1000h      # Send button press/release
ESC[?1002h      # Send button press/release/drag
ESC[?1006h      # SGR mouse mode (better format)

Disable when exiting:

code
ESC[?1000l

Used by: vim, tmux, less, htop


Part 2: CLI Design Guidelines for Developers

Based on clig.dev with implementation focus

Philosophy: The 9 Core Principles

1. Human-First Design

Principle: CLIs are primarily for humans, not just machines.

Practical Implications:

  • Show what's happening (don't hang silently)
  • Confirm dangerous operations
  • Provide helpful errors
  • Use conversational language

Counter to UNIX Tradition: "Silence is golden" doesn't work for modern tools. Users expect feedback.

Example from cargo:

code
$ cargo build
   Compiling myapp v0.1.0
    Finished dev [unoptimized + debuginfo] target(s) in 2.34s

Clear indication of progress and completion.

2. Simple, Composable Parts

Principle: Standard streams, pipes, and exit codes enable composition.

Practical Implications:

  • Output primary results to stdout
  • Output logs/errors to stderr
  • Exit 0 for success, non-zero for failure
  • Accept stdin, write to stdout for filters

Example from ripgrep:

bash
rg "pattern" | rg "filter" | wc -l

Composes naturally because stdout contains only matches.

3. Consistency Across Programs

Principle: Follow established conventions.

Practical Implications:

  • Use standard flag names (-h, --help, -v, --version)
  • Follow argument conventions (POSIX or GNU style)
  • Ctrl-C should interrupt
  • 'q' should quit TUIs

Example from git: Every subcommand uses consistent flags:

bash
git commit --verbose
git log --verbose
git diff --verbose

4. Saying Just Enough

Principle: Balance information density. Too little confuses, too much overwhelms.

Guidelines:

  • Default: Show what changed
  • Quiet mode (-q): Silence non-errors
  • Verbose mode (-v): Show details
  • Debug mode (--debug): Everything

Example from docker:

code
$ docker pull nginx
Using default tag: latest
latest: Pulling from library/nginx
a2abf6c4d29d: Pull complete
Status: Downloaded newer image for nginx:latest

Just enough to understand progress.

5. Ease of Discovery

Principle: Users shouldn't need to memorize everything.

Practical Implications:

  • Comprehensive help text
  • Examples in help
  • Suggest corrections for typos
  • Show next steps

Example from git:

code
$ git pul
git: 'pul' is not a git command. See 'git --help'.

The most similar command is
        pull

6. Conversation as the Norm

Principle: Design for iterative use and trial-and-error.

Practical Implications:

  • Clear error messages that suggest fixes
  • Allow undoing or --dry-run
  • Preserve state when safe
  • Show current state easily

Example from git:

code
$ git status
On branch main
Changes not staged for commit:
  modified:   file.txt

no changes added to commit

Always shows current state.

7. Robustness

Principle: Feel solid, not fragile. Handle errors gracefully.

Practical Implications:

  • Validate input early
  • Meaningful error messages (not stack traces)
  • Handle SIGINT gracefully
  • Restore terminal state on crash (TUIs)

Example from cargo:

code
$ cargo build
error: Could not compile `myapp` due to 2 previous errors

Clear, actionable error.

8. Empathy

Principle: Show you're on the user's side.

Practical Implications:

  • Assume mistakes are honest
  • Explain what went wrong
  • Suggest how to fix it
  • Be encouraging, not condescending

Bad:

code
Error: Invalid argument

Good (from rustc):

code
error: unexpected end of file
  --> src/main.rs:5:1
   |
5  | }
   | ^ expected one of 8 possible tokens here

9. Chaos as Power

Principle: Terminal inconsistency enables innovation. Break rules intentionally with purpose.

When to Break Conventions:

  • Clear usability improvement
  • Document the deviation
  • Provide escape hatches

Example: ripgrep's default behavior (auto-ignore .gitignore) breaks UNIX tradition but is more useful for developers.


Arguments and Flags Implementation

Terminology

Arguments (Positional Parameters):

  • Order matters
  • No dashes
  • Example: cp source dest

Flags (Named Parameters):

  • Order independent
  • Start with - or --
  • May have values
  • Example: ls --color=auto -l

Options: Sometimes used interchangeably with flags

Design Decision: Args vs Flags

Use Positional Args When:

  • One or two required parameters
  • Order is obvious
  • Examples: cat file.txt, cd /path, rm file1 file2

Use Flags When:

  • Optional parameters
  • Many parameters
  • Order shouldn't matter
  • Need extensibility
  • Examples: git commit --message "msg" --amend

Prefer flags over args for anything complex. They're more discoverable and extensible.

Example from git: Heavily flag-based for flexibility

bash
git log --oneline --graph --all --decorate
# Order doesn't matter
git log --all --oneline --decorate --graph

Standard Flag Names

Follow these conventions for consistency:

FlagMeaningExample Usage
-h, --helpShow help (only)tool --help
--versionShow version (only)tool --version
-v, --verboseMore outputtool -v
-q, --quietLess outputtool -q
-f, --forceSkip confirmationsrm -f file
-r, --recursiveRecurse directoriesrm -r dir
-n, --dry-runPreview without executinggit clean -n
-a, --allInclude all itemsgit add -a
-o, --output FILEOutput filegcc -o program
-i, --interactivePrompt for decisionsrm -i file
--no-inputNo interactive promptstool --no-input
-d, --debugDebug outputtool -d
--jsonJSON outputtool --json

Don't Repurpose These: Users have muscle memory for these flags.

POSIX vs GNU Style

POSIX Style:

bash
tool -a -b -c         # Short flags
tool -abc             # Bundled: same as above
tool -o file          # Flag with value (space separated)

GNU Style:

bash
tool --long-flag      # Long flags
tool --output=file    # Equals-separated value
tool --output file    # Space-separated value
tool -a --long -b     # Mixed short and long

Modern Best Practice: Support both

  • Short flags for common options
  • Long flags for all options
  • Allow bundling short flags (-la for -l -a)
  • Support both = and space for values

Example from git:

bash
git commit -m "msg"           # POSIX short
git commit --message="msg"    # GNU long with =
git commit --message "msg"    # GNU long with space

Dangerous Operations: Confirmations

Three Levels of Danger:

1. Low (Reversible):

  • Delete a single file
  • No confirmation needed (or -i flag)
  • Example: rm file.txt (can restore from trash)

2. Medium (Significant Impact):

  • Delete directory, remote changes
  • Interactive mode: Prompt for y/n
  • Non-interactive: Require --force
  • Example: rm -r dir (should prompt or need -f)

3. High (Destructive/Widespread):

  • Delete entire application, mass operations
  • Require typing something non-trivial
  • Example: Heroku's pattern:
code
$ heroku apps:destroy myapp
 ▸    WARNING: This will delete myapp including all add-ons.
 ▸    To proceed, type myapp or re-run this command with --confirm myapp

> myapp

Destroying myapp... done

For Scripts: Always provide --force or --confirm=VALUE to bypass prompts

bash
tool --force                    # Bypass all confirmations
tool --confirm="dangerous"      # Confirm with specific value

Order Independence

Principle: Users shouldn't need to remember flag order.

Support All These:

bash
tool subcommand --flag value arg
tool --flag value subcommand arg
tool --flag value arg subcommand

Example from git (all equivalent):

bash
git --no-pager log --oneline
git log --oneline --no-pager

Example from docker (noun-verb pattern):

bash
docker container rm --force nginx
docker container rm nginx --force

No Secrets in Flags

Never:

bash
tool --password secret123    # Visible in ps, shell history

Instead:

bash
# Option 1: Prompt interactively
tool --prompt-password

# Option 2: Read from file
tool --password-file ~/.secret

# Option 3: Read from stdin
cat ~/.secret | tool --password-stdin

# Option 4: Environment variable (also risky)
PASSWORD=secret123 tool

Why: ps aux shows all flags to all users. Shell history is often world-readable.


Help and Documentation

Help Display Requirements

Minimal Help (-h or no arguments):

code
USAGE:
    tool [OPTIONS] <FILE>

A brief one-line description of what this tool does.

OPTIONS:
    -h, --help       Print help information
    -v, --version    Print version
    -o, --output     Output file (default: stdout)

EXAMPLES:
    tool input.txt              Process input.txt
    tool -o out.txt in.txt      Write to out.txt

For more information, run: tool --help

Full Help (--help):

code
tool 1.2.3

A comprehensive description of what this tool does and why
you might want to use it.

USAGE:
    tool [FLAGS] [OPTIONS] <INPUT> [OUTPUT]

ARGS:
    <INPUT>     Input file to process
    [OUTPUT]    Output file (default: stdout)

FLAGS:
    -h, --help       Print help information
    -V, --version    Print version information
    -v, --verbose    Verbose output
    -q, --quiet      Suppress non-error output
    -f, --force      Overwrite existing files

OPTIONS:
    -o, --output <FILE>    Write output to FILE
    -c, --config <FILE>    Use configuration from FILE

EXAMPLES:
    # Basic usage
    tool input.txt

    # Write to file
    tool input.txt output.txt

    # With options
    tool -v --config my.conf input.txt

    # Pipe input
    cat input.txt | tool > output.txt

ENVIRONMENT:
    TOOL_CONFIG    Default configuration file path

For bug reports and feature requests:
https://github.com/user/tool/issues

Examples-First Approach

Principle: Show examples before describing flags.

Why: Users learn faster from examples than from parameter descriptions.

Learning from git:

code
$ git help commit
NAME
    git-commit - Record changes to the repository

SYNOPSIS
    git commit [-a | --interactive | --patch] ...

DESCRIPTION
    Create a new commit containing the current contents of the index...

EXAMPLES
    Record your own changes
        $ git commit -a

    Commit with a detailed message
        $ git commit -m "Initial commit" -m "More details"

Examples section shows common patterns.

Dynamic Help Generation

Generate help from the same source as argument parsing:

Benefits:

  • Always stays in sync
  • No duplicate maintenance
  • Consistent formatting

Concept (language-agnostic):

code
define_cli():
    add_flag("output", short="o", help="Output file")
    add_flag("verbose", short="v", help="Verbose output")
    generate_help_from_definitions()

Most argument parsing libraries do this automatically.

Man Pages

When to Provide:

  • Professional tools
  • System-wide installation
  • Complex functionality

Man Page Structure:

code
NAME
    tool - one-line description

SYNOPSIS
    tool [OPTIONS] FILES...

DESCRIPTION
    Detailed description

OPTIONS
    Detailed flag descriptions

EXAMPLES
    Usage examples

SEE ALSO
    Related commands

BUGS
    Bug tracker URL

Generation Tools:

  • help2man: Auto-generate from --help output
  • ronn: Markdown to man page
  • scdoc: Simple man page format
  • asciidoc: Comprehensive documentation system

Example from git: Extensive man pages for every subcommand

bash
man git-commit
man git-rebase

Output Design for Developers

TTY Detection Pattern

Core Pattern:

code
if is_tty(stdout):
    format = HumanReadable(colors=True, progress=True)
else:
    format = MachineReadable(colors=False, progress=False)

Implementation (shown earlier, repeated for context):

  • Use isatty() system call
  • Check file descriptor 1 (stdout)
  • Make decision at startup

Override Flags:

bash
tool --color=always    # Force colors even in pipe
tool --color=never     # No colors even in TTY
tool --color=auto      # Default (detect TTY)

Example from ls:

bash
ls --color=auto    # Default on many systems

Color Implementation

Respect NO_COLOR Environment Variable:

code
if getenv("NO_COLOR"):
    disable_all_colors()
elif not is_tty(stdout):
    disable_all_colors()
elif color_flag == "never":
    disable_all_colors()
else:
    enable_colors()

16 ANSI Colors (Safest):

CodeColorCodeColor
30Black40Black background
31Red41Red background
32Green42Green background
33Yellow43Yellow background
34Blue44Blue background
35Magenta45Magenta background
36Cyan46Cyan background
37White47White background
90-97Bright colors100-107Bright backgrounds

Usage:

code
ESC[31m red text ESC[0m     # Red foreground
ESC[1;31m bold red ESC[0m   # Bold red
ESC[0m                      # Reset all attributes

Example Code (Rust):

rust
fn print_colored(text: &str, color: u8) {
    if atty::is(atty::Stream::Stdout) && std::env::var("NO_COLOR").is_err() {
        println!("\x1b[{}m{}\x1b[0m", color, text);
    } else {
        println!("{}", text);
    }
}

Learning from ripgrep:

  • Automatic color detection
  • Respects NO_COLOR
  • Highlights matches in red by default
  • Disables in pipes

JSON and Machine-Readable Output

Pattern: Provide --json flag for structured output

Design:

bash
# Human-readable (default for TTY)
$ tool list
Found 3 items:
  - Item 1 (active)
  - Item 2 (inactive)
  - Item 3 (active)

# Machine-readable
$ tool list --json
[{"name":"Item 1","status":"active"},{"name":"Item 2","status":"inactive"}]

Guidelines:

  • One JSON object per line for streaming (JSONL/ndjson)
  • Valid JSON even on errors
  • Include error information in JSON

Example (streaming):

bash
$ tool process --json
{"type":"start","count":100}
{"type":"progress","done":50,"total":100}
{"type":"complete","duration":1.5}

Learning from cargo:

bash
cargo build --message-format=json

Outputs JSON for tooling integration.

Progress Indicators

When to Show:

  • TTY output only
  • Long-running operations (>1 second)
  • User needs feedback

When to Hide:

  • Piped output
  • --quiet flag
  • CI/CD environments (use CI env var)

Types:

Spinner (indeterminate):

code
⠋ Processing...
⠙ Processing...
⠹ Processing...

Progress Bar (determinate):

code
[=========>          ] 45% (450/1000)

Example Code (Concept):

code
if is_tty(stderr) and not quiet_mode:
    progress = ProgressBar(total=100)
    for item in items:
        process(item)
        progress.increment()

Learning from cargo:

code
    Updating crates.io index
  Downloaded 2 crates (50.3 KB) in 0.38s
   Compiling serde v1.0.152
   Compiling toml v0.5.11
    Finished dev [unoptimized + debuginfo] target(s) in 3.42s

Clear progress with meaningful stages.

Pager Integration

When to Use Pager:

  • Output longer than terminal height
  • User might want to scroll/search
  • Examples: git log, man, --help output

How to Detect:

code
if is_tty(stdout) and output_lines > terminal_height:
    pipe_to_pager()

Respect PAGER Environment Variable:

code
pager = getenv("PAGER") or "less"

Common Pager Options for less:

code
LESS="-FIRX"
  F: Quit if output fits on screen
  I: Case-insensitive search
  R: Allow ANSI color codes
  X: Don't clear screen on exit

Example from git:

bash
git log    # Automatically pages long output

Disable When Needed:

bash
git --no-pager log    # Don't page

Error Handling Implementation

User-Focused Error Messages

Bad:

code
Error: FileNotFoundError: [Errno 2] No such file or directory: 'config.toml'
  at read_config (tool.py:42)
  at main (tool.py:120)

Good:

code
Error: Could not find configuration file 'config.toml'

Try creating one with: tool init
Or specify a different location: tool --config path/to/config.toml

Principles:

  • Say what went wrong (not code-level details)
  • Suggest how to fix it
  • No stack traces unless --debug

Implementation Pattern:

code
catch FileNotFoundError as e:
    if debug_mode:
        print_stack_trace(e)
    else:
        print("Error: Could not find file '{}'".format(e.filename))
        print("Try: ...")

Error Message Hierarchy

Write to stderr: All errors and warnings

Structure:

code
ERROR: Critical failure, operation cannot complete
WARNING: Something's wrong, but continuing
INFO: Notable state change (when verbose)
DEBUG: Detailed diagnostics (when --debug)

Color Coding (if TTY):

code
ERROR: red
WARNING: yellow
INFO: blue/cyan
DEBUG: gray/dim

End with Critical Info: Terminal scrolls, last line is most visible

Example from rustc:

code
error: aborting due to 2 previous errors

For more information about this error, try `rustc --explain E0425`.

Summary and next steps at the end.

Exit Codes

POSIX Conventions:

  • 0: Success
  • 1: General error
  • 2: Misuse (invalid arguments)
  • 126: Command found but not executable
  • 127: Command not found
  • 128+N: Killed by signal N (e.g., 130 for Ctrl-C)

Design Your Own for specific errors:

code
0: Success
1: General error
2: Invalid arguments
10: File not found
11: Permission denied
12: Network error

Document them:

code
EXIT CODES:
    0   Success
    1   General error
    2   Invalid arguments
    10  File not found

Why They Matter: Scripts check exit codes

bash
if tool process file.txt; then
    echo "Success"
else
    echo "Failed with code $?"
fi

Interactivity Implementation

Confirmation Prompts

Simple Yes/No:

code
$ tool delete-all
Really delete all data? [y/N]: _

Implementation Concept:

code
if is_tty(stdin) and not no_input_flag:
    response = prompt("Really delete all data? [y/N]: ")
    if response.lower() != 'y':
        exit(0)
elif force_flag:
    # Proceed without confirmation
else:
    error("Cannot confirm in non-interactive mode. Use --force.")
    exit(1)

Type-to-Confirm Pattern (for dangerous operations):

code
$ heroku apps:destroy myapp
Type the app name to confirm: _

Password Input (No Echo)

Requirement: Don't display password as user types

C Implementation:

c
#include <termios.h>
#include <unistd.h>

void disable_echo() {
    struct termios tty;
    tcgetattr(STDIN_FILENO, &tty);
    tty.c_lflag &= ~ECHO;
    tcsetattr(STDIN_FILENO, TCSANOW, &tty);
}

void enable_echo() {
    struct termios tty;
    tcgetattr(STDIN_FILENO, &tty);
    tty.c_lflag |= ECHO;
    tcsetattr(STDIN_FILENO, TCSANOW, &tty);
}

Python:

python
import getpass
password = getpass.getpass("Password: ")

Rust:

rust
use rpassword::read_password;
println!("Password: ");
let password = read_password().unwrap();

--no-input Flag

Critical for CI/CD: Never hang waiting for input

Implementation:

code
if no_input_flag:
    # Never prompt
    # Use defaults or fail with error
    if required_confirmation:
        error("Cannot prompt in --no-input mode. Use --force.")
        exit(1)

Example:

bash
# Interactive (prompts for confirmation)
tool deploy

# CI/CD (fails without --force)
tool deploy --no-input --force

Configuration Management

Configuration Sources and Precedence

Load Order (highest to lowest priority):

  1. Command-line flags
  2. Environment variables
  3. Local config file (./.toolrc)
  4. User config file (~/.config/tool/config)
  5. System config file (/etc/tool/config)
  6. Built-in defaults

Implementation Pattern:

code
config = load_defaults()
config.update(load_system_config())
config.update(load_user_config())
config.update(load_local_config())
config.update(load_environment())
config.update(load_flags())

XDG Base Directory Specification

Standard Paths:

code
$XDG_CONFIG_HOME/tool/config    # User config (default: ~/.config/)
$XDG_DATA_HOME/tool/data        # User data (default: ~/.local/share/)
$XDG_CACHE_HOME/tool/cache      # Cache (default: ~/.cache/)

Fallbacks:

code
config_dir = getenv("XDG_CONFIG_HOME") or join(getenv("HOME"), ".config")
config_file = join(config_dir, "tool", "config.toml")

Why: Reduces dotfile clutter in home directory

Example from git:

code
~/.gitconfig               # Traditional
~/.config/git/config       # XDG (takes precedence)

Environment Variables to Respect

Standard Variables:

VariablePurposeExample Usage
NO_COLORDisable all colorsCheck before colorizing
EDITORUser's preferred editortool edit opens this
VISUALVisual editor (prefer over EDITOR)Same as EDITOR
PAGERPaging programUse for long output
HOMEUser's home directoryFor config paths
TMPDIRTemporary directoryFor temp files
TERMTerminal typeFor escape sequences
COLUMNSTerminal widthFor formatting
LINESTerminal heightFor paging decisions
CIRunning in CI environmentDisable progress bars
DEBUGEnable debug modeShow verbose output

Your Own Variables:

code
TOOL_CONFIG=/path/to/config
TOOL_API_KEY=secret123
TOOL_LOG_LEVEL=debug

Naming Convention: ALL_CAPS, prefix with tool name


Part 3: Building TUIs - Developer's Guide

Core Patterns Developers Must Implement

Terminal Rules for TUIs

Rule 1: 'q' quits the program

  • Examples: less, htop, man
  • Exception: Text editors where 'q' has other meaning

Rule 2: Ctrl-D quits REPLs

  • Examples: python, irb, node, psql
  • Mimics: OS-level EOF behavior

Rule 3: Ctrl-C should exit or interrupt

  • In raw mode, you receive Ctrl-C as character 3
  • Either exit immediately or cancel current operation
  • Don't: Ignore it completely

Rule 4: ESC cancels or goes back

  • Close dialog, return to previous screen
  • Not: As primary input (conflicts with escape sequences)

Rule 5: Ctrl-L redraws screen

  • Useful when terminal gets corrupted
  • Implementation: Resend entire screen

Readline Keybindings

Users expect these to work in line editors:

KeyFunctionOrigin
Ctrl-AStart of lineEmacs
Ctrl-EEnd of lineEmacs
Ctrl-BBack one characterEmacs
Ctrl-FForward one characterEmacs
Ctrl-PPrevious line/historyEmacs
Ctrl-NNext line/historyEmacs
Ctrl-KKill to end of lineEmacs
Ctrl-UKill entire lineUNIX
Ctrl-WDelete word backwardUNIX
Ctrl-DDelete character forward (or EOF)UNIX
Ctrl-HDelete character backwardUNIX

When to Implement: Any time you have line editing (command input, search box)

When to Skip: Full-screen editors (vim, emacs use their own bindings)

Color Constraints

Recommendation: Stick to 16 ANSI base colors

Why:

  • Works on all terminals
  • Respects user's color scheme
  • Avoids unreadable combinations

Bad:

code
# Hardcoded RGB colors
\x1b[38;2;255;100;50m  # May be unreadable on some backgrounds

Good:

code
# Base 16 ANSI colors
\x1b[31m    # Red (user's terminal defines exact shade)
\x1b[32m    # Green

Learning from vim: Theme files use named colors ("Red", "Blue") that adapt to terminal color scheme.


TUI Architecture Patterns from Proven Programs

vim: Modal Editing Architecture

Key Concepts:

Modes as State Machine:

code
Normal Mode → (i) → Insert Mode
           ↓ (v)           ↑ (ESC)
     Visual Mode ←←←←←←←←←←←
           ↓ (:)
     Command Mode

Separation of Concerns:

  • Normal mode: Navigation and commands
  • Insert mode: Text input
  • Visual mode: Selection
  • Command mode: Ex commands

Why It Works:

  • Clear mental model
  • Composable commands (d3w = delete 3 words)
  • Efficient keyboard-only navigation

Lessons for TUI Developers:

  • State machines clarify complex interactions
  • Modal interfaces reduce key combination needs
  • Visual feedback for mode (status line)

tmux: Client-Server Architecture

Key Concepts:

Server Persistence:

code
Terminal 1 → tmux client →
                          → tmux server → sessions → windows → panes
Terminal 2 → tmux client →

Benefits:

  • Sessions survive terminal disconnect
  • Multiple clients can attach
  • Server manages all state

Command Prefix (Ctrl-B):

  • Escapes command mode
  • Avoid conflicting with application keys
  • User customizable

Lessons for TUI Developers:

  • Client-server separation enables persistence
  • Prefix keys solve key binding conflicts
  • Named sessions provide organization

less: Pager Pattern

Key Concepts:

Lazy Loading:

  • Don't load entire file into memory
  • Seek to positions on demand
  • Efficient for huge files

Search and Navigation:

  • / to search forward
  • ? to search backward
  • n / N for next/previous match
  • g / G for start/end

Stateless Display:

  • Each redraw is independent
  • No complex state tracking

Lessons for TUI Developers:

  • Lazy loading enables handling large data
  • Consistent search pattern across tools
  • Simple state is more robust

htop: Real-Time Updates

Key Concepts:

Event Loop with Timeout:

code
loop:
    timeout_event = poll_input(timeout=1000ms)
    if timeout_event or no_input:
        refresh_display()
    elif key_pressed:
        handle_input(key)

Efficient Redrawing:

  • Only update changed regions
  • Diff previous state
  • Minimize escape sequences

Interactive Filtering:

  • Type to filter live
  • Immediate visual feedback
  • No enter key needed

Lessons for TUI Developers:

  • Periodic refreshes for real-time data
  • Incremental search is discoverable
  • Visual feedback for all actions

zellij: Layout System

Key Concepts:

Layout Definitions:

  • Declarative layout files
  • Nested panes and tabs
  • Serializable state

Plugin Architecture (WASM):

  • Sandboxed extensions
  • Language-agnostic
  • Safe execution

Lessons for TUI Developers:

  • Declarative layouts easier than imperative
  • Serialization enables session saving
  • Plugin systems enable extensibility

Input Handling Implementation

Switching to Raw Mode

What Raw Mode Does:

  • Disable line buffering (character-by-character input)
  • Disable echo (characters not printed automatically)
  • Disable special character processing (Ctrl-C doesn't send SIGINT)

C Implementation:

c
#include <termios.h>
#include <unistd.h>

struct termios orig_termios;

void enable_raw_mode() {
    tcgetattr(STDIN_FILENO, &orig_termios);

    struct termios raw = orig_termios;
    raw.c_lflag &= ~(ECHO | ICANON | ISIG | IEXTEN);
    raw.c_iflag &= ~(IXON | ICRNL | BRKINT | INPCK | ISTRIP);
    raw.c_oflag &= ~(OPOST);
    raw.c_cflag |= (CS8);

    tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}

void disable_raw_mode() {
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios);
}

Rust Implementation:

rust
use termios::{Termios, TCSAFLUSH, ECHO, ICANON, tcsetattr};

fn enable_raw_mode() -> std::io::Result<Termios> {
    let stdin = 0;
    let mut termios = Termios::from_fd(stdin)?;
    let orig = termios.clone();

    termios.c_lflag &= !(ICANON | ECHO);
    tcsetattr(stdin, TCSAFLUSH, &termios)?;

    Ok(orig)
}

Python Implementation:

python
import tty
import sys

def enable_raw_mode():
    tty.setraw(sys.stdin.fileno())

Critical: Always restore original mode before exit!

Event Loop Patterns

Blocking Event Loop (simple):

code
loop:
    key = read_key()      # Blocks until keypress
    handle_key(key)
    redraw_if_needed()

Non-Blocking with Timeout (for real-time updates):

code
loop:
    key = read_key_with_timeout(100ms)
    if key:
        handle_key(key)
    else:
        update_realtime_data()
    redraw()

Select-Based (Unix):

c
#include <sys/select.h>

fd_set readfds;
struct timeval timeout = {.tv_sec = 0, .tv_usec = 100000};

while (running) {
    FD_ZERO(&readfds);
    FD_SET(STDIN_FILENO, &readfds);

    int ret = select(STDIN_FILENO + 1, &readfds, NULL, NULL, &timeout);
    if (ret > 0) {
        char c = read_char();
        handle_input(c);
    } else {
        // Timeout - update display
        update_realtime_data();
    }
    redraw();
}

Parsing Arrow Keys and Function Keys

Reading Escape Sequences:

code
read char:
    if char == ESC:
        start_sequence = [ESC]
        read next char with timeout:
            if timeout:
                return ESC key
            if next == '[':
                read until letter:
                    return parse_csi_sequence()

Common Sequences:

code
ESC[A  → Up
ESC[B  → Down
ESC[C  → Right
ESC[D  → Left
ESC[H  → Home
ESC[F  → End
ESC[5~ → Page Up
ESC[6~ → Page Down

Example Implementation (Concept):

code
function read_key():
    c = read_char()
    if c != ESC:
        return c

    c = read_char_with_timeout(50ms)
    if timeout:
        return KEY_ESC

    if c == '[':
        c = read_char()
        match c:
            'A': return KEY_UP
            'B': return KEY_DOWN
            'C': return KEY_RIGHT
            'D': return KEY_LEFT
            ...

Mouse Support

Enable Mouse Reporting:

code
# Button press and release
printf "\x1b[?1000h"

# Button press, release, and drag
printf "\x1b[?1002h"

# SGR mouse mode (better format, works beyond column 223)
printf "\x1b[?1006h"

Disable Mouse Reporting:

code
printf "\x1b[?1000l\x1b[?1002l\x1b[?1006l"

Parse Mouse Events:

code
SGR format: ESC[<button;col;row[M|m]
    M = press
    m = release

button values:
    0 = left
    1 = middle
    2 = right
    64 = scroll up
    65 = scroll down

Handling Resize Events (SIGWINCH)

Signal Handler:

c
#include <signal.h>
#include <sys/ioctl.h>

volatile sig_atomic_t resized = 0;

void handle_sigwinch(int sig) {
    resized = 1;
}

int main() {
    signal(SIGWINCH, handle_sigwinch);

    while (running) {
        if (resized) {
            struct winsize ws;
            ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws);
            terminal_width = ws.ws_col;
            terminal_height = ws.ws_row;
            redraw_all();
            resized = 0;
        }
        // ... event loop
    }
}

Rendering Strategies

Alternate Screen Buffer

What It Does:

  • Saves current terminal content
  • Provides blank screen for your TUI
  • Restores previous content on exit

Enable:

code
printf "\x1b[?1049h"   # Switch to alternate screen
printf "\x1b[2J"       # Clear screen
printf "\x1b[H"        # Move cursor to home

Disable:

code
printf "\x1b[?1049l"   # Switch back to main screen

When to Use:

  • Full-screen TUIs (vim, less, htop)
  • User expects to return to previous terminal state

When Not to Use:

  • Persistent output desired (build progress, test results)
  • User might want to scroll back

Learning from less:

  • Uses alternate screen by default
  • -X flag disables it (leaves output on screen after quit)

Double Buffering

Problem: Visible flicker when redrawing

Solution: Build output in memory, write all at once

Implementation Concept:

code
# Bad (flickers)
for row in screen:
    print(row)

# Good (double buffered)
buffer = []
for row in screen:
    buffer.append(row)
output = "\n".join(buffer)
print(output)

Advanced: Diff-based rendering

code
previous_screen = current_screen
current_screen = build_new_screen()

diff = compute_diff(previous_screen, current_screen)
apply_diff(diff)  # Only update changed cells

Efficient Cursor Movement

Minimize Escape Sequences:

Bad (many small writes):

code
for each_change:
    printf "\x1b[%d;%dH%c"  # Move and write one char

Good (batch adjacent changes):

code
collect changes into runs:
    printf "\x1b[%d;%dH%s"  # Move once, write string

Relative vs Absolute Movement:

code
# Absolute (always works)
ESC[5;10H   # Move to row 5, col 10

# Relative (shorter when moving nearby)
ESC[3A      # Move up 3 rows
ESC[5C      # Move right 5 columns

Layout Management

Fixed Layout:

code
┌────────────────┬──────────┐
│                │          │
│   Main Area    │ Sidebar  │
│                │          │
├────────────────┴──────────┤
│      Status Bar           │
└───────────────────────────┘

Responsive Layout (adapt to terminal size):

code
if width < 80:
    single_column_layout()
else:
    two_column_layout()

if height < 24:
    hide_status_bar()

Widget Tree:

code
Container(vertical)
├─ Header(height=1)
├─ Body(flex=1)
│  ├─ Main(flex=3)
│  └─ Sidebar(flex=1)
└─ Footer(height=1)

Calculate Sizes:

code
available_height = terminal_height - header - footer
main_width = (available_width * 3) // 4
sidebar_width = available_width - main_width

Advanced TUI Topics

Focus Management

Concept: Only one widget receives keyboard input

Implementation:

code
class FocusManager:
    widgets = [widget1, widget2, widget3]
    focused_index = 0

    def handle_key(key):
        if key == TAB:
            focused_index = (focused_index + 1) % len(widgets)
        else:
            widgets[focused_index].handle_key(key)

Visual Indication:

  • Highlighted border
  • Different color
  • Cursor position

Example from htop: Arrow keys change focused process

Modal Dialogs

Pattern: Overlay on top of main screen

Implementation:

code
render_main_screen()
if dialog_open:
    render_dialog_overlay()
    handle_dialog_input()
else:
    handle_main_input()

Drawing Overlay:

code
# Save screen state
saved_screen = current_screen

# Draw dialog
draw_rectangle(center, size)
draw_shadow()
draw_dialog_content()

# On close
restore(saved_screen)

Tables and Lists with Scrolling

Virtual Scrolling:

code
visible_rows = terminal_height - header - footer
viewport_start = scroll_offset
viewport_end = scroll_offset + visible_rows

for i in range(viewport_start, viewport_end):
    render_row(data[i])

Scrolling Logic:

code
if cursor > viewport_end:
    scroll_offset += (cursor - viewport_end)
elif cursor < viewport_start:
    scroll_offset -= (viewport_start - cursor)

Learning from less:

  • Smooth scrolling
  • Search highlights
  • Line numbers

Part 4: Common Development Pitfalls & Solutions

Buffering Issues (Deep Dive)

(Expanded from earlier)

The 8KB Threshold

Why 8KB?: Historical constant from libc (BUFSIZ typically 8192)

Problem Scenario:

bash
tail -f /var/log/app.log | grep ERROR | your-tool
# your-tool sees nothing until grep accumulates 8KB

Testing for Buffering Issues

Test Script:

bash
#!/bin/bash
# test-buffering.sh

# Simulate slow input
for i in {1..10}; do
    echo "Line $i"
    sleep 1
done | your-tool

# If tool waits until end, it's block-buffered
# If tool shows each line immediately, it's line-buffered

Line-Buffered Flag Implementation

Add flag to your tool:

code
--line-buffered    Flush output after each line

C Implementation:

c
if (line_buffered) {
    setvbuf(stdout, NULL, _IOLBF, 0);
}

// Or manual after each line:
printf("%s\n", line);
if (line_buffered || isatty(STDOUT_FILENO)) {
    fflush(stdout);
}

Color and Styling Issues

Detecting Color Support

Check Multiple Factors:

code
function should_use_color():
    # Check NO_COLOR (user preference)
    if getenv("NO_COLOR"):
        return false

    # Check if stdout is TTY
    if not isatty(stdout):
        return false

    # Check TERM variable
    term = getenv("TERM")
    if term in ["dumb", "unknown"]:
        return false

    # Check explicit flag
    if color_flag == "never":
        return false
    if color_flag == "always":
        return true

    # Default: yes for TTY, no for pipe
    return true

16 Color vs 256 Color vs RGB

16 Colors (safest):

code
ESC[31m    # Red
ESC[32m    # Green
ESC[33m    # Yellow
ESC[34m    # Blue

256 Colors:

code
ESC[38;5;COLOR_NUMBERm    # Foreground
ESC[48;5;COLOR_NUMBERm    # Background
# COLOR_NUMBER: 0-255

RGB (TrueColor):

code
ESC[38;2;R;G;Bm    # Foreground
ESC[48;2;R;G;Bm    # Background

Detection:

code
# Check for 256-color support
if "256color" in getenv("TERM"):
    use_256_colors()

# Check for RGB support
if getenv("COLORTERM") in ["truecolor", "24bit"]:
    use_rgb_colors()

Unreadable Color Combinations

Problem: Hardcoded colors invisible on some backgrounds

Solution: Use semantic colors

code
# Bad
\x1b[38;2;30;30;30m    # Dark gray (invisible on dark terminal)

# Good
\x1b[31m               # Red (user's terminal defines the shade)

Best Practice: Let users customize theme, or stick to 16 colors


Signal Handling

SIGINT (Ctrl-C) Implementation

Requirements:

  • Exit gracefully
  • Clean up resources
  • Don't leave terminal in broken state

C Implementation:

c
#include <signal.h>

volatile sig_atomic_t interrupted = 0;

void sigint_handler(int sig) {
    interrupted = 1;
}

int main() {
    signal(SIGINT, sigint_handler);

    while (!interrupted) {
        // ... work
    }

    // Cleanup
    cleanup();
    exit(130);  // 128 + SIGINT (2)
}

Rust Implementation:

rust
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

let interrupted = Arc::new(AtomicBool::new(false));
let r = interrupted.clone();

ctrlc::set_handler(move || {
    r.store(true, Ordering::SeqCst);
}).expect("Error setting Ctrl-C handler");

while !interrupted.load(Ordering::SeqCst) {
    // ... work
}

Multi-Ctrl-C Pattern

Pattern: First Ctrl-C = graceful, second Ctrl-C = immediate

Implementation:

code
sigint_count = 0

on_sigint:
    sigint_count++

    if sigint_count == 1:
        print("Shutting down gracefully... (Ctrl-C again to force)")
        start_graceful_shutdown()
    elif sigint_count >= 2:
        print("Forcing immediate exit")
        _exit(1)  # Skip cleanup

Learning from Docker Compose:

code
$ docker-compose down
Stopping container1 ...
^CGracefully stopping... (press Ctrl+C again to force)
^CForcing shutdown

SIGWINCH (Terminal Resize)

Requirement: Redraw when terminal size changes

Implementation:

c
#include <signal.h>
#include <sys/ioctl.h>

volatile sig_atomic_t winch_received = 0;

void sigwinch_handler(int sig) {
    winch_received = 1;
}

void get_terminal_size(int *width, int *height) {
    struct winsize ws;
    ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws);
    *width = ws.ws_col;
    *height = ws.ws_row;
}

int main() {
    signal(SIGWINCH, sigwinch_handler);

    while (1) {
        if (winch_received) {
            winch_received = 0;
            get_terminal_size(&width, &height);
            redraw_everything();
        }
        // ...
    }
}

Terminal State Management

Saving and Restoring State

Problem: If your TUI crashes, terminal is left in broken state

Solution: Save state on entry, restore on exit

Implementation:

c
#include <termios.h>

struct termios orig_termios;
int orig_cursor_visible;

void setup_terminal() {
    // Save original state
    tcgetattr(STDIN_FILENO, &orig_termios);

    // Enter raw mode
    // ...

    // Hide cursor
    printf("\x1b[?25l");

    // Enter alternate screen
    printf("\x1b[?1049h");
}

void restore_terminal() {
    // Show cursor
    printf("\x1b[?25h");

    // Exit alternate screen
    printf("\x1b[?1049l");

    // Restore original terminal state
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios);

    // Flush output
    fflush(stdout);
}

void cleanup_and_exit(int code) {
    restore_terminal();
    exit(code);
}

Register Cleanup:

c
#include <stdlib.h>

int main() {
    atexit(restore_terminal);

    // Or handle signals
    signal(SIGINT, cleanup_signal_handler);
    signal(SIGTERM, cleanup_signal_handler);

    setup_terminal();
    // ... run TUI
}

Crash-Only Design

Principle: Minimize cleanup requirements

Implementation:

  • Don't require cleanup on exit
  • Use atomic operations
  • Write state to disk continuously
  • Can restart from any point

Example: Transaction logs

code
Instead of:
    load_state()
    modify_state()
    save_state()  # ← If this fails, data lost

Use:
    append_operation_to_log()  # ← Atomic
    replay_log_on_startup()

Input Parsing Pitfalls

Unicode Handling

Problem: Multi-byte UTF-8 characters

Example: 日本語 is 9 bytes but 3 characters

Solutions:

  • Use UTF-8 aware string length functions
  • Be careful with substring operations
  • Terminal width != byte count != character count

Width Calculation:

code
# ASCII 'A': 1 byte, 1 character, 1 cell width
# 日: 3 bytes, 1 character, 2 cell width (CJK)
# 👍: 4 bytes, 1 character, 2 cell width (emoji)

Libraries:

  • C: libunistring, ICU
  • Rust: unicode-width crate
  • Python: wcwidth library

Distinguishing ESC from Escape Sequences

(Covered earlier, reiterated for pitfalls)

Problem: ESC key vs ESC[A (up arrow)

Solution: Timeout-based parsing (50-100ms)

Pitfall: Timeout too short = arrow keys broken on slow connections

Pitfall: Timeout too long = ESC key feels sluggish


Output Corruption

Interleaved Output from Threads

Problem:

code
Thread 1: print("Processing item 1")
Thread 2: print("Processing item 2")
Output:   ProceProcessing item 2
          ssing item 1

Solution 1: Mutex around output

rust
use std::sync::Mutex;
use std::io::{self, Write};

lazy_static! {
    static ref STDOUT: Mutex<io::Stdout> = Mutex::new(io::stdout());
}

fn print_safe(msg: &str) {
    let mut handle = STDOUT.lock().unwrap();
    writeln!(handle, "{}", msg).unwrap();
}

Solution 2: Channel to single writer thread

code
Worker threads → Channel → Writer thread → stdout

Progress Bars with Concurrent Output

Problem: Log messages corrupt progress bar

Solution: Clear line, print message, redraw progress

code
function log_message(msg):
    clear_current_line()
    print(msg)
    redraw_progress_bar()

Better: Use library that handles this (e.g., indicatif for Rust)


Cross-Platform Issues

Windows Console vs Unix Terminal

Differences:

  • Windows traditionally didn't support ANSI escape codes
  • Different line endings (CRLF vs LF)
  • Different path separators (\ vs /)
  • Different PTY implementation

Windows 10+ Improvements:

  • ANSI escape codes now supported
  • Must enable with virtual terminal mode:
c
#include <windows.h>

void enable_ansi_on_windows() {
    HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
    DWORD dwMode = 0;
    GetConsoleMode(hOut, &dwMode);
    dwMode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING;
    SetConsoleMode(hOut, dwMode);
}

Cross-Platform Abstraction:

rust
#[cfg(windows)]
fn setup_terminal() {
    enable_virtual_terminal_processing();
}

#[cfg(unix)]
fn setup_terminal() {
    enable_raw_mode();
}

Performance Pitfalls

Excessive Terminal Writes

Problem: Writing to terminal is slow (syscall overhead)

Bad:

rust
for i in 0..1000 {
    println!("{}", i);  // 1000 write syscalls
}

Good:

rust
let mut buf = String::new();
for i in 0..1000 {
    buf.push_str(&format!("{}\n", i));
}
print!("{}", buf);  // 1 write syscall

Inefficient Escape Sequences

Bad: Redundant sequences

code
ESC[31m R ESC[0m ESC[31m E ESC[0m ESC[31m D ESC[0m
# 15 bytes * 3 = 45 bytes

Good: Batch coloring

code
ESC[31m RED ESC[0m
# 15 bytes total

Rendering Too Frequently

Problem: Redrawing 60 FPS when user only types 1 char/sec

Solution: Event-driven updates

code
on_input:
    update_state()
    redraw()

on_timer:
    if has_realtime_data():
        update_data()
        redraw()

Rate Limiting:

code
last_draw = now()

on_need_redraw:
    if now() - last_draw > 16ms:  # ~60 FPS max
        redraw()
        last_draw = now()

Part 5: Development Techniques & Patterns

Testing Terminal Applications

Mocking TTY in Tests

Problem: Tests don't run in a real TTY

Solutions:

1. PTY (Pseudo-Terminal):

Python with pexpect:

python
import pexpect

def test_interactive_prompt():
    child = pexpect.spawn('your-tool')
    child.expect('Enter name:')
    child.sendline('Alice')
    child.expect('Hello, Alice!')
    child.expect(pexpect.EOF)

Rust with pty crate:

rust
#[test]
fn test_tty_detection() {
    let pty = pty::fork().unwrap();
    if pty.is_parent() {
        // Parent process - verify child detected TTY
    } else {
        // Child process - runs in PTY
        assert!(atty::is(atty::Stream::Stdout));
    }
}

2. Mock isatty Function:

c
// In tests
#define isatty(fd) mock_isatty(fd)
int mock_isatty(int fd) {
    return test_wants_tty ? 1 : 0;
}

Snapshot Testing for Output

Concept: Record output, compare on future runs

Tool: insta (Rust), jest (JavaScript), pytest (Python)

Example (Rust with insta):

rust
#[test]
fn test_help_output() {
    let output = run_command("your-tool --help");
    insta::assert_snapshot!(output);
}

First run: Saves output to snapshot file Future runs: Compares against snapshot On change: Review diff, accept or reject

Testing Interactive Prompts

Using expect (traditional Unix tool):

tcl
spawn your-tool
expect "Enter password:"
send "secret123\r"
expect "Login successful"

CI/CD Testing:

bash
# Ensure --no-input works
your-tool --no-input --config test.conf

# Should exit with error if interaction required
if your-tool --no-input; then
    echo "FAIL: Should require --force"
    exit 1
fi

Development Tools

Terminal Recording

asciinema: Record and share terminal sessions

bash
# Record
asciinema rec demo.cast

# Play back
asciinema play demo.cast

# Embed in README
asciinema upload demo.cast

VHS (by Charm): Script terminal recordings

bash
# demo.tape
Type "your-tool --help"
Enter
Sleep 2s
Screenshot demo.png

Escape Sequence Debugging

Technique: Pipe output to cat -v

bash
your-tool | cat -v
# Shows: Hello ^[[31mworld^[[0m
#        (reveals ANSI codes)

Technique: Use hexdump

bash
your-tool | hexdump -C

Technique: Enable terminal debugging

bash
# iTerm2: Session > Log > Start Logging
# Captures all raw input/output

expect for Testing

Install:

bash
# macOS
brew install expect

# Linux
apt-get install expect

Example Test:

tcl
#!/usr/bin/expect

spawn your-tool interactive

expect "Enter name:"
send "Alice\r"

expect "Enter age:"
send "30\r"

expect {
    "Success" { exit 0 }
    timeout { exit 1 }
    eof { exit 1 }
}

Build and Distribution

Single Binary Philosophy

Benefits:

  • Easy installation (just download)
  • No dependency hell
  • No version conflicts
  • Works in containers

Implementation:

  • Statically link dependencies (Rust, Go do this by default)
  • Embed assets at compile time
  • Cross-compile for multiple platforms

Rust Example:

toml
# Cargo.toml
[profile.release]
strip = true
lto = true
codegen-units = 1
panic = 'abort'

Cross-Compilation

Rust:

bash
# Install target
rustup target add x86_64-unknown-linux-musl

# Build
cargo build --release --target x86_64-unknown-linux-musl

Go:

bash
GOOS=linux GOARCH=amd64 go build
GOOS=darwin GOARCH=arm64 go build
GOOS=windows GOARCH=amd64 go build

Size Optimization

Techniques:

  • Strip debug symbols
  • Enable LTO (Link-Time Optimization)
  • Use release mode
  • Compress with UPX (controversial)

Rust:

toml
[profile.release]
strip = true
lto = true
opt-level = "z"  # Optimize for size

Documentation

Man Page Structure

Sections:

code
NAME
    tool - one-line description

SYNOPSIS
    tool [OPTIONS] FILE...

DESCRIPTION
    Detailed description of what the tool does and how to use it.
    Multiple paragraphs explaining functionality.

OPTIONS
    -h, --help
        Print help information

    -v, --verbose
        Enable verbose output

EXAMPLES
    Basic usage:
        $ tool input.txt

    With options:
        $ tool -v input.txt output.txt

ENVIRONMENT
    TOOL_CONFIG
        Configuration file path

EXIT STATUS
    0   Success
    1   General error
    2   Invalid arguments

SEE ALSO
    related-tool(1), another-tool(1)

BUGS
    Report bugs to: https://github.com/user/tool/issues

AUTHOR
    Written by Your Name.

Learning from git's Help System

Hierarchical Help:

bash
git                  # Lists common commands
git help             # Same as above
git help commit      # Detailed help for subcommand
git commit --help    # Same as above (opens man page)
git commit -h        # Quick reference

Porcelain vs Plumbing:

  • Porcelain: User-facing commands (commit, push, pull)
  • Plumbing: Low-level tools (hash-object, update-index)

Design Pattern: Separate user-friendly interface from internal tools


Part 6: Practical Implementation Patterns

Complete CLI Application Structure

Project Layout

Rust:

code
my-tool/
├── Cargo.toml
├── src/
│   ├── main.rs           # Entry point, argument parsing
│   ├── cli.rs            # CLI definitions
│   ├── commands/         # Subcommand implementations
│   │   ├── mod.rs
│   │   ├── build.rs
│   │   └── deploy.rs
│   ├── lib.rs            # Library code (reusable)
│   ├── error.rs          # Error types
│   └── config.rs         # Configuration
└── tests/
    └── integration_test.rs

Python:

code
my-tool/
├── pyproject.toml
├── src/
│   └── mytool/
│       ├── __init__.py
│       ├── __main__.py   # Entry point
│       ├── cli.py        # Argument parsing
│       ├── commands/     # Subcommands
│       │   ├── __init__.py
│       │   ├── build.py
│       │   └── deploy.py
│       └── lib.py        # Core logic
└── tests/
    └── test_cli.py

Entry Point Design

Concept:

code
main():
    parse_arguments()
    load_config()
    setup_logging()
    dispatch_to_subcommand()
    handle_errors()
    exit_with_code()

Implementation Pattern:

rust
fn main() {
    let result = run();

    match result {
        Ok(()) => std::process::exit(0),
        Err(e) => {
            eprintln!("Error: {}", e);
            std::process::exit(1);
        }
    }
}

fn run() -> Result<(), Box<dyn std::error::Error>> {
    let args = parse_args()?;
    let config = load_config(&args)?;

    match args.subcommand {
        Subcommand::Build(opts) => commands::build(opts, &config),
        Subcommand::Deploy(opts) => commands::deploy(opts, &config),
    }
}

Argument Parsing Deep Dive

Getopt Patterns

Short Options: -a -b -c Long Options: --all --verbose --config=file Bundling: -abc = -a -b -c Values: -o file or -ofile or --output file or --output=file End of Options: -- stops parsing, rest are arguments

POSIX Conventions:

  • Short options: single dash, single letter
  • Option arguments: separated by space or directly attached
  • No permutation (options must come before arguments)

GNU Conventions (extensions):

  • Long options: double dash, full word
  • Option arguments: = or space
  • Permutation (options can be anywhere)
  • -- to stop parsing

Subcommand Dispatch Pattern

Learning from git:

code
git <global-options> <command> <command-options>

Examples:
git --no-pager log --oneline
git -C /path/to/repo status

Implementation Pattern:

code
parse phase 1: global options
identify subcommand
parse phase 2: subcommand options

dispatch:
    match subcommand:
        "build": build_command(opts)
        "test": test_command(opts)
        "deploy": deploy_command(opts)

Learning from docker's Command Structure

Pattern: docker <object> <action> <options>

bash
docker container create
docker container start
docker container stop
docker container rm

docker image build
docker image push
docker image pull

Benefits:

  • Clear grouping
  • Consistent interface
  • Discoverability

Progress & Feedback

Spinner Implementation (Core Algorithm)

Frames:

code
frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
# Or: ["-", "\\", "|", "/"]

Implementation:

code
frame_index = 0

while working:
    clear_line()
    print(frames[frame_index % len(frames)] + " Processing...")
    frame_index++
    sleep(100ms)

clear_line()
print("✓ Done!")

ANSI Codes:

code
\r         # Carriage return (go to start of line)
ESC[K      # Clear from cursor to end of line
ESC[?25l   # Hide cursor
ESC[?25h   # Show cursor

Progress Bar Implementation (Core Algorithm)

Concept:

code
[=========>          ] 45% (450/1000) 2.5MB/s ETA 5s

Implementation:

code
width = 20
filled = int(width * (done / total))
bar = "=" * filled + ">" + " " * (width - filled - 1)

percentage = int(100 * done / total)
text = f"[{bar}] {percentage}% ({done}/{total})"

print(f"\r{text}", end="", flush=True)

With Rate and ETA:

code
elapsed = time_now - start_time
rate = done / elapsed
remaining = total - done
eta = remaining / rate

text += f" {format_bytes(rate)}/s ETA {format_duration(eta)}"

Learning from cargo's Progress Patterns

Stages:

code
    Updating crates.io index
   Compiling serde v1.0.152 (1/10)
   Compiling tokio v1.25.0 (2/10)
    Finished dev [unoptimized] target(s) in 3.42s

Patterns:

  • Right-aligned verbs (uniform column)
  • Package names and versions
  • Progress count (2/10)
  • Summary with timing

Configuration Management

Loading Order Implementation

Pattern (highest to lowest priority):

code
1. Command-line flags (--config, --output)
2. Environment variables (TOOL_OUTPUT, TOOL_CONFIG)
3. Local config file (./.toolrc)
4. User config file (~/.config/tool/config.toml)
5. System config file (/etc/tool/config.toml)
6. Built-in defaults

Implementation:

rust
fn load_config() -> Config {
    let mut config = Config::defaults();

    if let Some(path) = find_system_config() {
        config.merge(load_file(path)?);
    }

    if let Some(path) = find_user_config() {
        config.merge(load_file(path)?);
    }

    if let Some(path) = find_local_config() {
        config.merge(load_file(path)?);
    }

    config.merge(load_env_vars());
    config.merge(parse_cli_flags());

    config
}

XDG Base Directory Implementation

rust
use std::path::PathBuf;
use std::env;

fn get_config_dir() -> PathBuf {
    env::var("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|_| {
            let home = env::var("HOME").expect("HOME not set");
            PathBuf::from(home).join(".config")
        })
        .join("mytool")
}

fn get_data_dir() -> PathBuf {
    env::var("XDG_DATA_HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|_| {
            let home = env::var("HOME").expect("HOME not set");
            PathBuf::from(home).join(".local/share")
        })
        .join("mytool")
}

fn get_cache_dir() -> PathBuf {
    env::var("XDG_CACHE_HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|_| {
            let home = env::var("HOME").expect("HOME not set");
            PathBuf::from(home).join(".cache")
        })
        .join("mytool")
}

Interactive Features

Confirmation Prompt Pattern

Yes/No:

code
Really delete all files? [y/N]:

Implementation:

code
if is_tty(stdin):
    print("Really delete all files? [y/N]: ", flush=True)
    response = read_line()
    if response.lower() != 'y':
        exit(0)
elif force_flag:
    # Proceed
    pass
else:
    error("Cannot confirm in non-interactive mode. Use --force.")
    exit(1)

Menu Selection (Arrow Key Navigation)

Pattern:

code
Select an option:
> Option 1
  Option 2
  Option 3

(Use arrow keys, Enter to select, q to quit)

Implementation Concept:

code
selected = 0
options = ["Option 1", "Option 2", "Option 3"]

enable_raw_mode()

loop:
    clear_screen()
    print_menu(options, selected)

    key = read_key()
    match key:
        UP_ARROW:
            selected = (selected - 1) % len(options)
        DOWN_ARROW:
            selected = (selected + 1) % len(options)
        ENTER:
            return options[selected]
        'q':
            exit(0)

disable_raw_mode()

Learning from git's Interactive Rebase

Pattern: Full-screen editor for multi-item selection

code
pick a1b2c3d First commit
pick d4e5f6g Second commit
pick h7i8j9k Third commit

# Commands:
# p, pick = use commit
# r, reword = use commit, but edit message
# e, edit = use commit, but stop for amending
# s, squash = use commit, but meld into previous
# d, drop = remove commit

Design Lessons:

  • Use $EDITOR for complex interactions
  • Provide help comments inline
  • Clear, mnemonic commands

Crash-Only Design

Atomic Operations

Pattern: Operations that are fully complete or fully not done

Example: File writes

code
# Bad (non-atomic)
open(file, 'w')
write(data)
close()
# ← If crash here, partial file written

# Good (atomic)
write(file + ".tmp", data)
rename(file + ".tmp", file)  # Atomic operation

Resume-able Operations

Pattern: Save progress, allow restart

Example: Download with resume

code
State file: .download_state.json
{
  "url": "...",
  "total_bytes": 10000000,
  "downloaded_bytes": 5000000,
  "chunks": ["chunk1", "chunk2"]
}

On start:
    if state_file exists:
        resume from state
    else:
        start fresh

On progress:
    update state file

On completion:
    remove state file

Transaction Logs

Pattern: Write-ahead log (WAL)

Example:

code
Before operation:
    append to log: "DELETE file.txt"

Perform operation:
    delete(file.txt)

After success:
    append to log: "COMMITTED"

On crash recovery:
    replay_log()

Part 7: Technical Reference for Developers

Argument Parsing Concepts

POSIX getopt

Format:

c
int getopt(int argc, char *argv[], const char *optstring);

Example optstring: "ab:c::"
  a    - flag without argument
  b:   - flag with required argument
  c::  - flag with optional argument

GNU getopt_long

Format:

c
struct option {
    const char *name;    // Long name
    int has_arg;         // no_argument, required_argument, optional_argument
    int *flag;           // NULL or pointer to int
    int val;             // Value to return (or store in *flag)
};

int getopt_long(int argc, char *argv[],
                const char *optstring,
                const struct option *longopts,
                int *longindex);

Example:

c
struct option long_options[] = {
    {"help",    no_argument,       0, 'h'},
    {"verbose", no_argument,       0, 'v'},
    {"output",  required_argument, 0, 'o'},
    {0, 0, 0, 0}
};

while ((c = getopt_long(argc, argv, "hvo:", long_options, NULL)) != -1) {
    switch (c) {
        case 'h': print_help(); break;
        case 'v': verbose = 1; break;
        case 'o': output_file = optarg; break;
    }
}

Option Bundling

Rule: -abc = -a -b -c for flags without arguments

Implementation: Most libraries handle automatically

Limitation: Can't bundle options with arguments

bash
# OK
ls -la

# Not OK (ambiguous)
tool -abc file    # Is 'c' a flag or does 'b' take 'c' as argument?

Double Dash Convention

Rule: -- stops option parsing

Usage:

bash
# Pass filename that starts with dash
tool -- -weird-filename.txt

# Pass flags to subcommand
tool --verbose -- subcommand --its-own-flag

Implementation:

code
for arg in args:
    if arg == "--":
        stop_parsing_options = true
        continue

    if stop_parsing_options or not arg.startswith("-"):
        positional_args.append(arg)
    else:
        parse_option(arg)

Terminal Capabilities

Terminfo Concepts

Purpose: Database of terminal capabilities

Structure:

code
Terminal Type (from $TERM)
  ├─ Boolean Capabilities (am, xenl, etc.)
  ├─ Numeric Capabilities (cols, lines, colors)
  └─ String Capabilities (cup, clear, bold, etc.)

Querying Terminfo:

bash
# Show all capabilities for current terminal
infocmp

# Show specific capability
tput bold        # Output escape sequence for bold
tput colors      # Output number of colors
tput cols        # Output terminal width

Using in Code (C):

c
#include <term.h>
#include <curses.h>

setupterm(NULL, STDOUT_FILENO, NULL);

char *clear_screen = tigetstr("clear");
char *bold = tigetstr("bold");

printf("%s", clear_screen);  // Clear screen
printf("%sHello%s", bold, tparm(tigetstr("sgr0")));  // Bold text

Capability Detection Patterns

Pattern 1: Try and fallback

code
try:
    output(complex_escape_sequence)
    query_terminal_response()
    if response == expected:
        terminal_supports_feature = true
timeout:
    terminal_supports_feature = false

Pattern 2: Check $TERM

code
if "256color" in $TERM:
    use_256_colors = true

if $TERM in ["dumb", "unknown"]:
    disable_all_formatting = true

Pattern 3: Check $COLORTERM

code
if $COLORTERM in ["truecolor", "24bit"]:
    use_rgb_colors = true

Environment Variables Reference

Standard Variables

VariablePurposeHow to Use
NO_COLORDisable colors (user preference)If set (any value), disable colors
FORCE_COLORForce colors (override detection)If set, enable colors even in pipes
CLICOLOREnable colors (0=no, 1=yes)BSD convention
CLICOLOR_FORCEForce colors (0=no, 1=yes)BSD convention
TERMTerminal type identifier"xterm-256color", "screen", "dumb"
COLORTERMColor capability"truecolor", "24bit" for RGB
COLUMNSTerminal widthNumber of columns
LINESTerminal heightNumber of rows
EDITORUser's text editor"vim", "nano", "code"
VISUALVisual editor (preferred)Same as EDITOR but for visual editors
PAGERPaging program"less", "more"
SHELLUser's shell"/bin/bash", "/bin/zsh"
HOMEUser's home directory"/home/username"
USERCurrent username"alice"
TMPDIRTemporary directory"/tmp" or "/var/tmp"
PATHExecutable search paths":/usr/bin:/usr/local/bin:..."
LANGLocale"en_US.UTF-8"
LC_ALLLocale overrideOverrides all LC_* variables
TZTimezone"America/New_York"
CIRunning in CI environment"true" (GitHub Actions, GitLab CI)
DEBUGEnable debug output"1" or "true"

Naming Your Own Variables

Convention: ALL_CAPS with tool name prefix

Examples:

code
MYTOOL_CONFIG=/path/to/config
MYTOOL_LOG_LEVEL=debug
MYTOOL_API_KEY=secret
MYTOOL_CACHE_DIR=/tmp/cache

Security: Never put secrets in environment variables!

  • Visible in ps e to all users
  • Passed to all subprocesses
  • Often logged

Exit Codes

POSIX and Common Codes

CodeMeaningUsage
0SuccessEverything worked
1General errorUnspecified failure
2MisuseInvalid arguments or usage
64-78Various/usr/include/sysexits.h
126Cannot executePermission or exec format error
127Command not foundShell couldn't find the command
128Invalid exit codeExit code out of range
128+NKilled by signal N130 = killed by SIGINT (Ctrl-C)
130Terminated by Ctrl-CSpecifically SIGINT
255Exit code out of rangeReturn values capped at 255

Designing Exit Codes

Strategy: Define meaningful codes for your application

Example:

code
0   - Success
1   - General error
2   - Invalid command-line arguments
10  - File not found
11  - Permission denied
12  - Network error
13  - Timeout
20  - Configuration error
30  - Build failed
31  - Tests failed

Document Them:

code
EXIT STATUS:
    0   Success
    1   General error
    2   Invalid arguments
    10  File not found
    11  Permission denied

ANSI Escape Sequences

CSI Sequences (Control Sequence Introducer)

Format: ESC [ <parameters> <command>

Cursor Movement:

code
ESC[H        # Move to home (1,1)
ESC[<r>;<c>H # Move to row r, column c
ESC[<n>A     # Move up n lines
ESC[<n>B     # Move down n lines
ESC[<n>C     # Move right n columns
ESC[<n>D     # Move left n columns
ESC[<n>E     # Move to beginning of line n lines down
ESC[<n>F     # Move to beginning of line n lines up
ESC[<n>G     # Move to column n
ESC[6n       # Query cursor position (response: ESC[<r>;<c>R)

Erasing:

code
ESC[J        # Clear from cursor to end of screen
ESC[1J       # Clear from cursor to beginning of screen
ESC[2J       # Clear entire screen
ESC[K        # Clear from cursor to end of line
ESC[1K       # Clear from cursor to beginning of line
ESC[2K       # Clear entire line

Scrolling:

code
ESC[<n>S     # Scroll up n lines
ESC[<n>T     # Scroll down n lines

SGR (Select Graphic Rendition) - Colors and Styles:

code
ESC[0m       # Reset all attributes
ESC[1m       # Bold
ESC[2m       # Dim
ESC[3m       # Italic
ESC[4m       # Underline
ESC[5m       # Blinking
ESC[7m       # Reverse video
ESC[8m       # Hidden
ESC[9m       # Strikethrough

# Foreground colors (30-37, 90-97)
ESC[30m      # Black
ESC[31m      # Red
ESC[32m      # Green
ESC[33m      # Yellow
ESC[34m      # Blue
ESC[35m      # Magenta
ESC[36m      # Cyan
ESC[37m      # White
ESC[90-97m   # Bright colors

# Background colors (40-47, 100-107)
ESC[40m      # Black background
ESC[41m      # Red background
...

# 256 colors
ESC[38;5;<n>m   # Foreground (n = 0-255)
ESC[48;5;<n>m   # Background

# RGB colors
ESC[38;2;<r>;<g>;<b>m   # Foreground
ESC[48;2;<r>;<g>;<b>m   # Background

OSC Sequences (Operating System Command)

Format: ESC ] <command> ; <parameters> BEL or ESC ] <command> ; <parameters> ESC \

Common Uses:

code
ESC]0;Title\x07          # Set window title
ESC]1;Icon Name\x07      # Set icon name
ESC]2;Window Title\x07   # Set window title (same as 0)

# OSC 52 - Clipboard
ESC]52;c;<base64>\x07    # Copy to clipboard
ESC]52;c;?\x07           # Query clipboard

Private Sequences

Format: ESC [ ? <n> h (set) or ESC [ ? <n> l (reset)

Common Uses:

code
ESC[?25h     # Show cursor
ESC[?25l     # Hide cursor
ESC[?1049h   # Use alternate screen buffer
ESC[?1049l   # Use main screen buffer
ESC[?1000h   # Enable mouse button tracking
ESC[?1002h   # Enable mouse button and drag tracking
ESC[?1006h   # Enable SGR mouse mode

Signal Reference

Common Signals

SignalNumberDefault ActionPurpose
SIGHUP1TerminateHangup (terminal disconnected)
SIGINT2TerminateInterrupt (Ctrl-C)
SIGQUIT3Core dumpQuit (Ctrl-)
SIGKILL9TerminateKill (cannot be caught)
SIGTERM15TerminateTermination (polite kill)
SIGSTOP19StopStop (cannot be caught)
SIGTSTP20StopStop (Ctrl-Z)
SIGCONT18ContinueContinue after stop
SIGWINCH28IgnoreWindow size change

Handling Signals

C Implementation:

c
#include <signal.h>

void sigint_handler(int sig) {
    // Cleanup
    cleanup();
    exit(128 + sig);
}

int main() {
    signal(SIGINT, sigint_handler);
    signal(SIGTERM, sigint_handler);

    // ... run program
}

Platform Differences

Signal Numbers Vary:

  • SIGWINCH is 28 on Linux, 23 on BSD/macOS
  • Use constants (SIGWINCH) not numbers

Windows:

  • Very limited signal support
  • Ctrl-C sends SIGINT
  • No SIGWINCH, SIGHUP, etc.

Part 8: Best Practices Summary

Top 10 Design Principles

  1. Detect TTY, Format Appropriately

    • Colors and progress for humans
    • Plain output for pipes
    • Provide --color override
  2. Separate stdout and stderr

    • Results to stdout
    • Errors and diagnostics to stderr
    • Enables composability
  3. Handle Buffering Correctly

    • Add --line-buffered flag
    • Flush after important output
    • Test with pipes
  4. Use Standard Flag Names

    • -h/--help, --version, -v/--verbose, -q/--quiet
    • Users have muscle memory
  5. Provide Excellent Error Messages

    • Say what went wrong
    • Suggest how to fix it
    • No stack traces by default
  6. Exit with Meaningful Codes

    • 0 for success
    • Non-zero for errors
    • Document specific codes
  7. Handle Signals Gracefully

    • Ctrl-C should always work
    • Clean up terminal state
    • Save progress when possible
  8. Support Non-Interactive Mode

    • --no-input flag
    • --force for confirmations
    • Critical for CI/CD
  9. Respect User Environment

    • NO_COLOR, EDITOR, PAGER
    • XDG Base Directory
    • Standard conventions
  10. Test with Real Terminals

    • Different emulators
    • Different sizes
    • Pipes and redirects

Common Mistakes to Avoid

Ignoring isatty() - Always check before colors/progress ❌ Stack traces for users - Hide by default, show with --debug ❌ Secrets in flags - Use files or prompts instead ❌ Hanging without feedback - Show progress or spinner ❌ Forgetting to flush - Buffering causes pipes to hang ❌ Hardcoding RGB colors - Use 16 ANSI colors for compatibility ❌ Ignoring SIGINT - Users expect Ctrl-C to work ❌ Not restoring terminal state - Crashes leave broken terminal ❌ Inconsistent flag names - Follow standards ❌ Poor help text - Include examples


Quick Reference Checklist

Before releasing your CLI tool:

  • -h shows brief help
  • --help shows detailed help
  • --version shows version
  • Colors only when TTY (respect NO_COLOR)
  • Progress indicators only when TTY
  • --no-input flag works in CI
  • --json flag for machine output
  • Exit 0 on success, non-zero on failure
  • Errors go to stderr
  • Results go to stdout
  • Ctrl-C exits gracefully
  • Works in pipes (tool | grep, tool | jq)
  • Line buffering option for streaming
  • Man page or web docs
  • Examples in help text
  • Clear error messages
  • Single binary (no dependencies)
  • Cross-platform (or clearly document limitations)
  • Tested on major terminals

When to Break the Rules

Valid reasons:

  • Clear usability improvement
  • Domain-specific requirements
  • Technical limitations
  • Explicitly documented

Examples:

  • ripgrep: Ignores .gitignore by default (breaks UNIX tradition, but better for developers)
  • bat: Uses full RGB colors (breaks compatibility guideline, but beautiful output is core feature)
  • fzf: Uses entire screen even for one item (breaks minimalism, but interactive selection is core feature)

How to break rules:

  1. Document the deviation
  2. Provide escape hatch (flag to disable)
  3. Have clear rationale
  4. Get user feedback

Part 9: When to Ask for Help

Ask the user for clarification or direction when:

Complex TUI State Management

  • Designing widget trees with deep nesting
  • Managing focus across multiple panels
  • Implementing undo/redo for TUI applications
  • Handling modal dialogs with complex interactions

Platform-Specific Terminal Behavior

  • Windows Console API integration
  • Handling terminal quirks on specific platforms
  • Supporting legacy terminal types
  • Cross-platform PTY allocation

Performance Optimization

  • Rendering bottlenecks in large TUIs
  • Optimizing large file processing
  • Reducing memory usage for streaming data
  • Profiling terminal write performance

Custom Escape Sequence Needs

  • Implementing features beyond standard sequences
  • Detecting terminal capabilities at runtime
  • Falling back when features unsupported
  • Working with non-standard terminals

Testing Strategies

  • Mocking complex terminal interactions
  • Testing TUI applications in CI
  • Snapshot testing for terminal output
  • Integration testing with PTYs

Architecture Decisions

  • Choosing between CLI and TUI interfaces
  • Client-server architecture for terminal apps
  • Plugin systems for CLI tools
  • Configuration format selection

Security Considerations

  • Handling sensitive input (passwords, API keys)
  • Preventing command injection
  • Secure clipboard access
  • Sandboxing subprocesses

Accessibility

  • Screen reader compatibility
  • High contrast modes
  • Keyboard-only navigation
  • Alternative text for visual elements

Advanced Topics

  • Building language servers (LSP)
  • REPL implementation
  • Terminal multiplexer design
  • Remote terminal protocols