Terminal programming means writing programs that interact with the command line as more than simple text output.
Terminal programming means writing programs that interact with the command line as more than simple text output.
A basic program prints text:
std.debug.print("hello\n", .{});A terminal program may also read keys, move the cursor, clear the screen, draw text interfaces, use colors, hide the cursor, or read input without waiting for the user to press Enter.
This is useful for command-line tools, text editors, shells, debuggers, log viewers, terminal dashboards, file managers, and interactive installers.
Standard Input, Output, and Error
A command-line program usually starts with three standard streams:
| Stream | File Descriptor | Purpose |
|---|---|---|
| standard input | 0 | reads input |
| standard output | 1 | writes normal output |
| standard error | 2 | writes errors and diagnostics |
In Zig, you can get writers for output streams:
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
const stderr = std.io.getStdErr().writer();
try stdout.print("normal output\n", .{});
try stderr.print("error output\n", .{});
}Use standard output for program results.
Use standard error for diagnostics, warnings, progress messages, and error messages.
This matters because shell users often redirect output:
mytool input.txt > result.txtIn this case, standard output goes into result.txt, but standard error still appears in the terminal.
Reading a Line
For simple input, read a line from standard input.
const std = @import("std");
pub fn main() !void {
const stdin = std.io.getStdIn().reader();
const stdout = std.io.getStdOut().writer();
var buffer: [1024]u8 = undefined;
try stdout.print("name: ", .{});
if (try stdin.readUntilDelimiterOrEof(&buffer, '\n')) |line| {
try stdout.print("hello, {s}\n", .{line});
}
}This reads bytes until it sees a newline or reaches end of file.
The buffer is fixed-size. If the user types more than the buffer can hold, the read operation may fail. That is good. The program has a clear limit.
Terminals Are Not Plain Files
Terminals use file descriptors, but they behave differently from normal files.
A file gives you stored bytes.
A terminal gives you interaction with a user.
When input comes from a terminal, the operating system often runs it in cooked mode. In cooked mode, the terminal driver handles editing before your program receives input.
For example, the user can type characters, press Backspace, and edit the line. Your program receives the final line only after the user presses Enter.
That is why this style works:
name: AliceYour program does not see each key immediately. It sees the line after Enter.
Raw Mode
Interactive terminal programs often need raw mode.
In raw mode, your program receives key input more directly. The terminal does less processing for you.
Raw mode is useful when you want to:
read one key at a time
detect arrow keys
build a text editor
draw a full-screen interface
handle Ctrl+C yourself
avoid waiting for Enter
Raw mode is operating-system-specific. On POSIX systems, it usually involves termios.
A simplified POSIX example looks like this:
const std = @import("std");
pub fn main() !void {
const stdin_fd = std.posix.STDIN_FILENO;
var original = try std.posix.tcgetattr(stdin_fd);
var raw = original;
raw.lflag.ICANON = false;
raw.lflag.ECHO = false;
try std.posix.tcsetattr(stdin_fd, .FLUSH, raw);
defer std.posix.tcsetattr(stdin_fd, .FLUSH, original) catch {};
var byte: [1]u8 = undefined;
_ = try std.posix.read(stdin_fd, &byte);
std.debug.print("byte: {}\n", .{byte[0]});
}This turns off canonical input and echo.
Canonical input means line-based input. Turning it off lets your program read input before Enter.
Echo means the terminal automatically displays typed characters. Turning it off prevents automatic display.
The important cleanup is:
defer std.posix.tcsetattr(stdin_fd, .FLUSH, original) catch {};Always restore the terminal settings. If your program exits without restoring them, the user’s terminal may behave strangely.
Escape Sequences
Terminals understand special byte sequences called escape sequences.
They usually start with the escape byte:
\x1bThis is also written as:
ESCEscape sequences can move the cursor, clear the screen, change colors, and style text.
For example, this clears the screen and moves the cursor to the top-left corner:
try stdout.writeAll("\x1b[2J\x1b[H");This prints red text on many ANSI-compatible terminals:
try stdout.writeAll("\x1b[31mred text\x1b[0m\n");The final sequence resets formatting:
\x1b[0mWithout reset, later text may continue using the same style.
Common ANSI Sequences
| Sequence | Meaning |
|---|---|
\x1b[2J | clear screen |
\x1b[H | move cursor to home position |
\x1b[?25l | hide cursor |
\x1b[?25h | show cursor |
\x1b[31m | red foreground |
\x1b[32m | green foreground |
\x1b[33m | yellow foreground |
\x1b[34m | blue foreground |
\x1b[0m | reset style |
A tiny example:
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.writeAll("\x1b[2J\x1b[H");
try stdout.writeAll("\x1b[32mHello in green\x1b[0m\n");
}This kind of output is enough for many simple terminal interfaces.
Drawing a Simple Screen
A terminal screen is just text arranged in rows and columns.
You can clear the screen, move the cursor, and print content.
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.writeAll("\x1b[2J\x1b[H");
try stdout.writeAll("Simple Dashboard\n");
try stdout.writeAll("================\n");
try stdout.writeAll("Status: running\n");
try stdout.writeAll("Press q to quit\n");
}This does not create a graphical window. It only writes control sequences and text to the terminal.
That is the core trick behind many terminal user interfaces.
Detecting Interactive Output
Sometimes your program writes to a terminal. Sometimes its output is redirected to a file or pipe.
For example:
mytool
mytool > output.txt
mytool | grep errorA good CLI tool may behave differently in these cases.
When output is a terminal, colors and progress bars are useful.
When output is a file or pipe, plain text is usually better.
In Zig, this kind of check is typically done through OS-specific APIs or standard library helpers where available. The general idea is called isatty.
The question is:
Is this file descriptor connected to a terminal?If yes, interactive output is reasonable. If no, avoid terminal escape sequences unless the user explicitly asks for them.
Progress Output
Progress messages should usually go to standard error, not standard output.
Bad:
try stdout.print("processing file 1\n", .{});
try stdout.print("actual result\n", .{});Better:
try stderr.print("processing file 1\n", .{});
try stdout.print("actual result\n", .{});This keeps machine-readable output clean.
Example:
mytool data.txt > result.txtThe result goes into the file. Progress still appears in the terminal.
Terminal Size
Full-screen terminal programs often need to know the terminal size.
They need rows and columns so they can decide how much text fits.
This is platform-specific. On Unix-like systems, programs often ask the terminal driver for window size. On Windows, they use console APIs.
For beginner code, avoid depending on terminal size at first. Write simple interfaces that still work on small terminals.
Signals and Ctrl+C
On many systems, pressing Ctrl+C sends an interrupt signal to the program.
In normal terminal mode, the operating system handles this. Your program is usually stopped.
In raw mode, behavior can differ depending on how you configure terminal flags.
This is another reason raw mode must be used carefully. When you take control from the terminal driver, you also take responsibility for behavior users expect.
For beginner terminal programs, restore terminal state with defer as soon as you enter raw mode.
A Small Interactive Example
Here is a small program that enters raw mode, reads one byte at a time, and exits when the user presses q.
const std = @import("std");
pub fn main() !void {
const stdin_fd = std.posix.STDIN_FILENO;
const stdout = std.io.getStdOut().writer();
var original = try std.posix.tcgetattr(stdin_fd);
var raw = original;
raw.lflag.ICANON = false;
raw.lflag.ECHO = false;
try std.posix.tcsetattr(stdin_fd, .FLUSH, raw);
defer std.posix.tcsetattr(stdin_fd, .FLUSH, original) catch {};
try stdout.writeAll("\x1b[2J\x1b[H");
try stdout.writeAll("Press q to quit.\n");
while (true) {
var byte: [1]u8 = undefined;
const n = try std.posix.read(stdin_fd, &byte);
if (n == 0) {
break;
}
if (byte[0] == 'q') {
break;
}
try stdout.print("you pressed byte {}\n", .{byte[0]});
}
}This is not a full text UI library. It is only the foundation.
It shows the essential pieces:
save terminal state
turn on raw input
restore terminal state on exit
read bytes
respond to input
Use Libraries for Serious TUIs
You can build terminal interfaces manually, but serious terminal programming becomes complicated.
Hard parts include:
Unicode width
combining characters
resize events
mouse input
color support
Windows compatibility
terminal capability detection
alternate screen buffers
keyboard escape sequence parsing
For real applications, consider using a Zig terminal UI library or a C library through Zig interop. Manual ANSI output is fine for simple tools, but full terminal applications need more infrastructure.
Mental Model
A terminal is a byte-based interface between your program and a user.
Your program writes bytes to display text and control sequences.
Your program reads bytes to receive input.
The operating system and terminal emulator interpret those bytes according to terminal rules.
For simple programs, line input and plain output are enough. For interactive programs, you need raw mode, escape sequences, cleanup, and careful handling of platform differences.