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:
- •Terminal Fundamentals: How TTYs, PTYs, streams, and buffering work
- •CLI Design: User-facing interface principles (arguments, output, errors)
- •TUI Architecture: Interactive full-screen applications
- •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:
┌─────────────────────────────────┐
│ 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:
| Stream | FD | Purpose | Examples |
|---|---|---|---|
| stdin | 0 | Input from user or pipe | Reading commands, file content |
| stdout | 1 | Primary output | Results, listings, JSON |
| stderr | 2 | Errors, logs, diagnostics | Error messages, warnings, debug |
Critical Design Principle: Separate stdout and stderr properly.
Why This Matters:
# 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=jsonto stdout
Detecting TTY vs Pipe
Your program must detect its output destination to format appropriately:
Concept (language-agnostic):
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:
#include <unistd.h>
if (isatty(STDOUT_FILENO)) {
// stdout is a TTY
}
Rust Implementation:
use std::io::IsTerminal;
if std::io::stdout().is_terminal() {
// stdout is a TTY
}
Python Implementation:
import sys
if sys.stdout.isatty():
# stdout is a TTY
Go Implementation:
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):
| Key | ASCII | Name | Function |
|---|---|---|---|
| Ctrl-C | 3 | ETX | Sends SIGINT (interrupt) |
| Ctrl-D | 4 | EOT | EOF when line is empty |
| Ctrl-Z | 26 | SUB | Sends SIGTSTP (suspend) |
| Ctrl-S | 19 | XOFF | Freezes output (flow control) |
| Ctrl-Q | 17 | XON | Resumes output |
| Ctrl-\ | 28 | FS | Sends SIGQUIT |
2. Keyboard Literals:
| Key | ASCII | Name | Usage |
|---|---|---|---|
| Enter | 13 | CR | Line terminator |
| Tab | 9 | HT | Tab character |
| Backspace | 127 | DEL | Delete previous character |
| Ctrl-H | 8 | BS | Often same as backspace |
3. Application-Specific (Your Program Can Define):
| Key | Common Usage |
|---|---|
| Ctrl-A | Move to line start (readline, emacs) |
| Ctrl-E | Move to line end |
| Ctrl-W | Delete word backwards |
| Ctrl-U | Delete line |
| Ctrl-K | Kill to end of line |
| Ctrl-R | Reverse search (shells) |
| Ctrl-L | Clear screen |
| Ctrl-P/N | Previous/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:
- •Input codes: Generated by keypresses (arrow keys, function keys)
- •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
ESC[2J # Clear screen ESC[H # Move cursor to home ESC[1;31m # Red foreground color
OSC (Operating System Command): ESC ] followed by parameters
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:
echo $TERM # xterm-256color, screen-256color, etc. infocmp $TERM # Dump terminal capabilities tput bold # Output "bold" escape sequence for $TERM
Terminfo Approach:
- •Query terminfo for escape sequences
- •Adapts to different terminals automatically
- •Complex to use, slower
Hardcoded Approach:
- •Use common escape sequences directly
- •Test across major terminals
- •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
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():
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
tail -f log.txt | grep ERROR # Hangs! grep is waiting for 8KB before flushing
Why:
- •
grepsees stdout is a pipe (not TTY) - •Uses block buffering (8KB threshold)
- •Waits to accumulate data
- •Never reaches threshold with sparse matches
- •Appears frozen
Solutions for Developers Building CLI Tools
Solution 1: Add --line-buffered flag
C Implementation:
#include <stdio.h>
if (line_buffered_flag) {
setvbuf(stdout, NULL, _IOLBF, 0);
}
// Or manually flush:
printf("output\n");
fflush(stdout);
Rust Implementation:
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:
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:
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-bufferedfor 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:
# 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:
| Key | Sequence | Notes |
|---|---|---|
| Up | ESC[A | CSI sequence |
| Down | ESC[B | |
| Right | ESC[C | |
| Left | ESC[D | |
| Home | ESC[H or ESC[1~ | Varies by terminal |
| End | ESC[F or ESC[4~ | |
| Page Up | ESC[5~ | |
| Page Down | ESC[6~ | |
| F1 | ESC OP or ESC[[A | Highly variable |
| F12 | ESC[24~ |
Distinguishing ESC Key from Escape Sequences
The Problem: ESC character can mean:
- •User pressed ESC key (standalone)
- •Start of escape sequence (ESC[A for up arrow)
Solution: Timeout-based parsing
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):
ESC[<0;10;5M # Mouse button press at column 10, row 5
Enable mouse reporting:
ESC[?1000h # Send button press/release ESC[?1002h # Send button press/release/drag ESC[?1006h # SGR mouse mode (better format)
Disable when exiting:
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:
$ 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:
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:
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:
$ 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:
$ 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:
$ 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:
$ 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:
Error: Invalid argument
Good (from rustc):
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
git log --oneline --graph --all --decorate # Order doesn't matter git log --all --oneline --decorate --graph
Standard Flag Names
Follow these conventions for consistency:
| Flag | Meaning | Example Usage |
|---|---|---|
-h, --help | Show help (only) | tool --help |
--version | Show version (only) | tool --version |
-v, --verbose | More output | tool -v |
-q, --quiet | Less output | tool -q |
-f, --force | Skip confirmations | rm -f file |
-r, --recursive | Recurse directories | rm -r dir |
-n, --dry-run | Preview without executing | git clean -n |
-a, --all | Include all items | git add -a |
-o, --output FILE | Output file | gcc -o program |
-i, --interactive | Prompt for decisions | rm -i file |
--no-input | No interactive prompts | tool --no-input |
-d, --debug | Debug output | tool -d |
--json | JSON output | tool --json |
Don't Repurpose These: Users have muscle memory for these flags.
POSIX vs GNU Style
POSIX Style:
tool -a -b -c # Short flags tool -abc # Bundled: same as above tool -o file # Flag with value (space separated)
GNU Style:
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 (
-lafor-l -a) - •Support both
=and space for values
Example from git:
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:
$ 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
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:
tool subcommand --flag value arg tool --flag value subcommand arg tool --flag value arg subcommand
Example from git (all equivalent):
git --no-pager log --oneline git log --oneline --no-pager
Example from docker (noun-verb pattern):
docker container rm --force nginx docker container rm nginx --force
No Secrets in Flags
Never:
tool --password secret123 # Visible in ps, shell history
Instead:
# 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):
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):
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:
$ 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):
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:
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
man git-commit man git-rebase
Output Design for Developers
TTY Detection Pattern
Core Pattern:
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:
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:
ls --color=auto # Default on many systems
Color Implementation
Respect NO_COLOR Environment Variable:
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):
| Code | Color | Code | Color |
|---|---|---|---|
| 30 | Black | 40 | Black background |
| 31 | Red | 41 | Red background |
| 32 | Green | 42 | Green background |
| 33 | Yellow | 43 | Yellow background |
| 34 | Blue | 44 | Blue background |
| 35 | Magenta | 45 | Magenta background |
| 36 | Cyan | 46 | Cyan background |
| 37 | White | 47 | White background |
| 90-97 | Bright colors | 100-107 | Bright backgrounds |
Usage:
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):
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:
# 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):
$ tool process --json
{"type":"start","count":100}
{"type":"progress","done":50,"total":100}
{"type":"complete","duration":1.5}
Learning from cargo:
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
- •
--quietflag - •CI/CD environments (use
CIenv var)
Types:
Spinner (indeterminate):
⠋ Processing... ⠙ Processing... ⠹ Processing...
Progress Bar (determinate):
[=========> ] 45% (450/1000)
Example Code (Concept):
if is_tty(stderr) and not quiet_mode:
progress = ProgressBar(total=100)
for item in items:
process(item)
progress.increment()
Learning from cargo:
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:
if is_tty(stdout) and output_lines > terminal_height:
pipe_to_pager()
Respect PAGER Environment Variable:
pager = getenv("PAGER") or "less"
Common Pager Options for less:
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:
git log # Automatically pages long output
Disable When Needed:
git --no-pager log # Don't page
Error Handling Implementation
User-Focused Error Messages
Bad:
Error: FileNotFoundError: [Errno 2] No such file or directory: 'config.toml' at read_config (tool.py:42) at main (tool.py:120)
Good:
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:
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:
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):
ERROR: red WARNING: yellow INFO: blue/cyan DEBUG: gray/dim
End with Critical Info: Terminal scrolls, last line is most visible
Example from rustc:
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:
0: Success 1: General error 2: Invalid arguments 10: File not found 11: Permission denied 12: Network error
Document them:
EXIT CODES:
0 Success
1 General error
2 Invalid arguments
10 File not found
Why They Matter: Scripts check exit codes
if tool process file.txt; then
echo "Success"
else
echo "Failed with code $?"
fi
Interactivity Implementation
Confirmation Prompts
Simple Yes/No:
$ tool delete-all Really delete all data? [y/N]: _
Implementation Concept:
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):
$ heroku apps:destroy myapp Type the app name to confirm: _
Password Input (No Echo)
Requirement: Don't display password as user types
C Implementation:
#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:
import getpass
password = getpass.getpass("Password: ")
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:
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:
# 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):
- •Command-line flags
- •Environment variables
- •Local config file (./.toolrc)
- •User config file (~/.config/tool/config)
- •System config file (/etc/tool/config)
- •Built-in defaults
Implementation Pattern:
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:
$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:
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:
~/.gitconfig # Traditional ~/.config/git/config # XDG (takes precedence)
Environment Variables to Respect
Standard Variables:
| Variable | Purpose | Example Usage |
|---|---|---|
| NO_COLOR | Disable all colors | Check before colorizing |
| EDITOR | User's preferred editor | tool edit opens this |
| VISUAL | Visual editor (prefer over EDITOR) | Same as EDITOR |
| PAGER | Paging program | Use for long output |
| HOME | User's home directory | For config paths |
| TMPDIR | Temporary directory | For temp files |
| TERM | Terminal type | For escape sequences |
| COLUMNS | Terminal width | For formatting |
| LINES | Terminal height | For paging decisions |
| CI | Running in CI environment | Disable progress bars |
| DEBUG | Enable debug mode | Show verbose output |
Your Own Variables:
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:
| Key | Function | Origin |
|---|---|---|
| Ctrl-A | Start of line | Emacs |
| Ctrl-E | End of line | Emacs |
| Ctrl-B | Back one character | Emacs |
| Ctrl-F | Forward one character | Emacs |
| Ctrl-P | Previous line/history | Emacs |
| Ctrl-N | Next line/history | Emacs |
| Ctrl-K | Kill to end of line | Emacs |
| Ctrl-U | Kill entire line | UNIX |
| Ctrl-W | Delete word backward | UNIX |
| Ctrl-D | Delete character forward (or EOF) | UNIX |
| Ctrl-H | Delete character backward | UNIX |
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:
# Hardcoded RGB colors \x1b[38;2;255;100;50m # May be unreadable on some backgrounds
Good:
# 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:
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:
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/Nfor next/previous match - •
g/Gfor 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:
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:
#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:
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:
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):
loop:
key = read_key() # Blocks until keypress
handle_key(key)
redraw_if_needed()
Non-Blocking with Timeout (for real-time updates):
loop:
key = read_key_with_timeout(100ms)
if key:
handle_key(key)
else:
update_realtime_data()
redraw()
Select-Based (Unix):
#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:
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:
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):
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:
# 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:
printf "\x1b[?1000l\x1b[?1002l\x1b[?1006l"
Parse Mouse Events:
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:
#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:
printf "\x1b[?1049h" # Switch to alternate screen printf "\x1b[2J" # Clear screen printf "\x1b[H" # Move cursor to home
Disable:
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
- •
-Xflag 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:
# 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
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):
for each_change:
printf "\x1b[%d;%dH%c" # Move and write one char
Good (batch adjacent changes):
collect changes into runs:
printf "\x1b[%d;%dH%s" # Move once, write string
Relative vs Absolute Movement:
# 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:
┌────────────────┬──────────┐ │ │ │ │ Main Area │ Sidebar │ │ │ │ ├────────────────┴──────────┤ │ Status Bar │ └───────────────────────────┘
Responsive Layout (adapt to terminal size):
if width < 80:
single_column_layout()
else:
two_column_layout()
if height < 24:
hide_status_bar()
Widget Tree:
Container(vertical) ├─ Header(height=1) ├─ Body(flex=1) │ ├─ Main(flex=3) │ └─ Sidebar(flex=1) └─ Footer(height=1)
Calculate Sizes:
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:
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:
render_main_screen()
if dialog_open:
render_dialog_overlay()
handle_dialog_input()
else:
handle_main_input()
Drawing Overlay:
# 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:
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:
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:
tail -f /var/log/app.log | grep ERROR | your-tool # your-tool sees nothing until grep accumulates 8KB
Testing for Buffering Issues
Test Script:
#!/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:
--line-buffered Flush output after each line
C Implementation:
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:
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):
ESC[31m # Red ESC[32m # Green ESC[33m # Yellow ESC[34m # Blue
256 Colors:
ESC[38;5;COLOR_NUMBERm # Foreground ESC[48;5;COLOR_NUMBERm # Background # COLOR_NUMBER: 0-255
RGB (TrueColor):
ESC[38;2;R;G;Bm # Foreground ESC[48;2;R;G;Bm # Background
Detection:
# 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
# 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:
#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:
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:
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:
$ 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:
#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:
#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:
#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
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:
# 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:
Thread 1: print("Processing item 1")
Thread 2: print("Processing item 2")
Output: ProceProcessing item 2
ssing item 1
Solution 1: Mutex around output
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
Worker threads → Channel → Writer thread → stdout
Progress Bars with Concurrent Output
Problem: Log messages corrupt progress bar
Solution: Clear line, print message, redraw progress
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:
#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:
#[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:
for i in 0..1000 {
println!("{}", i); // 1000 write syscalls
}
Good:
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
ESC[31m R ESC[0m ESC[31m E ESC[0m ESC[31m D ESC[0m # 15 bytes * 3 = 45 bytes
Good: Batch coloring
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
on_input:
update_state()
redraw()
on_timer:
if has_realtime_data():
update_data()
redraw()
Rate Limiting:
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:
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:
#[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:
// 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):
#[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):
spawn your-tool expect "Enter password:" send "secret123\r" expect "Login successful"
CI/CD Testing:
# 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
# Record asciinema rec demo.cast # Play back asciinema play demo.cast # Embed in README asciinema upload demo.cast
VHS (by Charm): Script terminal recordings
# demo.tape Type "your-tool --help" Enter Sleep 2s Screenshot demo.png
Escape Sequence Debugging
Technique: Pipe output to cat -v
your-tool | cat -v # Shows: Hello ^[[31mworld^[[0m # (reveals ANSI codes)
Technique: Use hexdump
your-tool | hexdump -C
Technique: Enable terminal debugging
# iTerm2: Session > Log > Start Logging # Captures all raw input/output
expect for Testing
Install:
# macOS brew install expect # Linux apt-get install expect
Example Test:
#!/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:
# Cargo.toml [profile.release] strip = true lto = true codegen-units = 1 panic = 'abort'
Cross-Compilation
Rust:
# Install target rustup target add x86_64-unknown-linux-musl # Build cargo build --release --target x86_64-unknown-linux-musl
Go:
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:
[profile.release] strip = true lto = true opt-level = "z" # Optimize for size
Documentation
Man Page Structure
Sections:
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:
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:
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:
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:
main():
parse_arguments()
load_config()
setup_logging()
dispatch_to_subcommand()
handle_errors()
exit_with_code()
Implementation Pattern:
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:
git <global-options> <command> <command-options> Examples: git --no-pager log --oneline git -C /path/to/repo status
Implementation Pattern:
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>
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:
frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] # Or: ["-", "\\", "|", "/"]
Implementation:
frame_index = 0
while working:
clear_line()
print(frames[frame_index % len(frames)] + " Processing...")
frame_index++
sleep(100ms)
clear_line()
print("✓ Done!")
ANSI Codes:
\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:
[=========> ] 45% (450/1000) 2.5MB/s ETA 5s
Implementation:
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:
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:
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):
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:
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
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:
Really delete all files? [y/N]:
Implementation:
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:
Select an option: > Option 1 Option 2 Option 3 (Use arrow keys, Enter to select, q to quit)
Implementation Concept:
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
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
# 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
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:
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:
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:
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:
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
# 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:
# Pass filename that starts with dash tool -- -weird-filename.txt # Pass flags to subcommand tool --verbose -- subcommand --its-own-flag
Implementation:
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:
Terminal Type (from $TERM) ├─ Boolean Capabilities (am, xenl, etc.) ├─ Numeric Capabilities (cols, lines, colors) └─ String Capabilities (cup, clear, bold, etc.)
Querying Terminfo:
# 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):
#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
try:
output(complex_escape_sequence)
query_terminal_response()
if response == expected:
terminal_supports_feature = true
timeout:
terminal_supports_feature = false
Pattern 2: Check $TERM
if "256color" in $TERM:
use_256_colors = true
if $TERM in ["dumb", "unknown"]:
disable_all_formatting = true
Pattern 3: Check $COLORTERM
if $COLORTERM in ["truecolor", "24bit"]:
use_rgb_colors = true
Environment Variables Reference
Standard Variables
| Variable | Purpose | How to Use |
|---|---|---|
| NO_COLOR | Disable colors (user preference) | If set (any value), disable colors |
| FORCE_COLOR | Force colors (override detection) | If set, enable colors even in pipes |
| CLICOLOR | Enable colors (0=no, 1=yes) | BSD convention |
| CLICOLOR_FORCE | Force colors (0=no, 1=yes) | BSD convention |
| TERM | Terminal type identifier | "xterm-256color", "screen", "dumb" |
| COLORTERM | Color capability | "truecolor", "24bit" for RGB |
| COLUMNS | Terminal width | Number of columns |
| LINES | Terminal height | Number of rows |
| EDITOR | User's text editor | "vim", "nano", "code" |
| VISUAL | Visual editor (preferred) | Same as EDITOR but for visual editors |
| PAGER | Paging program | "less", "more" |
| SHELL | User's shell | "/bin/bash", "/bin/zsh" |
| HOME | User's home directory | "/home/username" |
| USER | Current username | "alice" |
| TMPDIR | Temporary directory | "/tmp" or "/var/tmp" |
| PATH | Executable search paths | ":/usr/bin:/usr/local/bin:..." |
| LANG | Locale | "en_US.UTF-8" |
| LC_ALL | Locale override | Overrides all LC_* variables |
| TZ | Timezone | "America/New_York" |
| CI | Running in CI environment | "true" (GitHub Actions, GitLab CI) |
| DEBUG | Enable debug output | "1" or "true" |
Naming Your Own Variables
Convention: ALL_CAPS with tool name prefix
Examples:
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 eto all users - •Passed to all subprocesses
- •Often logged
Exit Codes
POSIX and Common Codes
| Code | Meaning | Usage |
|---|---|---|
| 0 | Success | Everything worked |
| 1 | General error | Unspecified failure |
| 2 | Misuse | Invalid arguments or usage |
| 64-78 | Various | /usr/include/sysexits.h |
| 126 | Cannot execute | Permission or exec format error |
| 127 | Command not found | Shell couldn't find the command |
| 128 | Invalid exit code | Exit code out of range |
| 128+N | Killed by signal N | 130 = killed by SIGINT (Ctrl-C) |
| 130 | Terminated by Ctrl-C | Specifically SIGINT |
| 255 | Exit code out of range | Return values capped at 255 |
Designing Exit Codes
Strategy: Define meaningful codes for your application
Example:
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:
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:
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:
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:
ESC[<n>S # Scroll up n lines ESC[<n>T # Scroll down n lines
SGR (Select Graphic Rendition) - Colors and Styles:
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:
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:
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
| Signal | Number | Default Action | Purpose |
|---|---|---|---|
| SIGHUP | 1 | Terminate | Hangup (terminal disconnected) |
| SIGINT | 2 | Terminate | Interrupt (Ctrl-C) |
| SIGQUIT | 3 | Core dump | Quit (Ctrl-) |
| SIGKILL | 9 | Terminate | Kill (cannot be caught) |
| SIGTERM | 15 | Terminate | Termination (polite kill) |
| SIGSTOP | 19 | Stop | Stop (cannot be caught) |
| SIGTSTP | 20 | Stop | Stop (Ctrl-Z) |
| SIGCONT | 18 | Continue | Continue after stop |
| SIGWINCH | 28 | Ignore | Window size change |
Handling Signals
C Implementation:
#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
- •
Detect TTY, Format Appropriately
- •Colors and progress for humans
- •Plain output for pipes
- •Provide
--coloroverride
- •
Separate stdout and stderr
- •Results to stdout
- •Errors and diagnostics to stderr
- •Enables composability
- •
Handle Buffering Correctly
- •Add
--line-bufferedflag - •Flush after important output
- •Test with pipes
- •Add
- •
Use Standard Flag Names
- •
-h/--help,--version,-v/--verbose,-q/--quiet - •Users have muscle memory
- •
- •
Provide Excellent Error Messages
- •Say what went wrong
- •Suggest how to fix it
- •No stack traces by default
- •
Exit with Meaningful Codes
- •0 for success
- •Non-zero for errors
- •Document specific codes
- •
Handle Signals Gracefully
- •Ctrl-C should always work
- •Clean up terminal state
- •Save progress when possible
- •
Support Non-Interactive Mode
- •
--no-inputflag - •
--forcefor confirmations - •Critical for CI/CD
- •
- •
Respect User Environment
- •NO_COLOR, EDITOR, PAGER
- •XDG Base Directory
- •Standard conventions
- •
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:
- •
-hshows brief help - •
--helpshows detailed help - •
--versionshows version - • Colors only when TTY (respect NO_COLOR)
- • Progress indicators only when TTY
- •
--no-inputflag works in CI - •
--jsonflag 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:
- •Document the deviation
- •Provide escape hatch (flag to disable)
- •Have clear rationale
- •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