How Ghostty Parses CSI Sequences: A Deep Dive into the VT-100 State Machine
Ghostty parses CSI sequences using a zero-allocation finite-state machine in src/terminal/Parser.zig that dispatches typed actions through src/terminal/stream.zig to terminal handlers.
Ghostty implements a high-performance, VT-100-compatible terminal emulator in Zig. At the heart of its input handling lies a deterministic finite-state machine that decodes Control Sequence Introducer (CSI) sequences—the ESC [ escape codes used for cursor movement, styling, and screen manipulation—without dynamic memory allocation. According to the ghostty-org/ghostty source code, this architecture processes bytes in three distinct stages: parsing, dispatch, and handling.
The Byte-Level State Machine (src/terminal/Parser.zig)
The core parsing logic resides in the Parser struct defined in src/terminal/Parser.zig. This implementation maintains a finite-state machine driven by the transition table in src/terminal/parse_table.zig, cycling through states such as .ground, .escape, .csi_entry, and .csi_param as bytes arrive from the PTY.
State Transitions from ESC to CSI
When the parser encounters byte 0x1B (ESC), it transitions from .ground to .escape. If the subsequent byte is '[' (0x5B), the state moves to .csi_entry, signaling that a CSI sequence has begun. Subsequent bytes are processed by Parser.next, which updates the machine state and accumulates data until the sequence terminator arrives.
Accumulating Parameters and Intermediates
Inside the .csi_param state, the parser accumulates digits into the params array and tracks separators using the params_sep bit-set. Digits form integer parameters, while semicolons (;) and colons (:) mark separators. Intermediate characters—bytes between the [ and the final command character—are stored in the intermediates array.
The parser enforces strict limits to maintain zero-allocation guarantees: MAX_PARAMS is set to 24 and MAX_INTERMEDIATE to 4. When these limits are exceeded, the parser silently drops extra data, preventing out-of-bounds writes while continuing to process valid input.
Dispatching CSI Actions
When the CSI final character arrives (completing the sequence), Parser.doAction emits an Action.csi_dispatch containing:
intermediates: The collected intermediate bytes (max 4)params: Array of parsed integer parameters (max 24)params_sep: Bit-set indicating which parameters used colon separatorsfinal: The final command character (e.g.,'A','m','H')
The Stream Dispatch Layer (src/terminal/stream.zig)
The Stream wrapper in src/terminal/stream.zig receives the csi_dispatch action and routes it through the csiDispatch function. This function contains a large switch statement that maps each CSI final character to a concrete Action for the user-provided handler.
The dispatch logic includes branch hinting for common sequences. For example, the cursor-up command (ESC [ A) is handled as follows:
// src/terminal/stream.zig, lines ~808-822
'A', 'k' => {
@branchHint(.likely);
switch (input.intermediates.len) {
0 => self.handler.vt(.cursor_up, .{
.value = switch (input.params.len) {
0 => 1,
1 => input.params[0],
else => { log.warn("invalid cursor up command: {f}", .{input}); return },
},
}),
else => log.warn("ignoring unimplemented CSI A with intermediates: {s}", .{input.intermediates}),
}
},
Each case validates parameter counts and forwards typed actions to the handler via self.handler.vt(.command_name, .{ .value = ... }). If input.params.len exceeds what the command expects, the stream logs a warning and returns early, protecting the terminal state from malformed sequences.
Semantic Definitions (src/terminal/csi.zig)
The src/terminal/csi.zig file provides compile-time-checked enumerations for CSI semantics, such as EraseDisplay, EraseLine, TabClear, and SizeReportStyle. These types give the dispatch layer a type-safe interface for interpreting raw numeric parameters, ensuring that commands like erase operations or mode changes receive valid, expected values.
Complete Execution Flow
The parsing pipeline follows these deterministic steps:
| Step | Component | Action |
|---|---|---|
| 1 | Stream.next / nextSlice |
Raw bytes enter the stream from the PTY |
| 2 | nextNonUtf8 |
Forwards non-UTF8 bytes to the parser |
| 3 | Parser.next |
Updates state machine, builds actions |
| 4 | Parser.doAction |
Emits Action.csi_dispatch on final byte |
| 5 | Stream.next |
Receives action, calls csiDispatch |
| 6 | csiDispatch |
Matches input.final and invokes handler |
| 7 | Handler trait | Executes terminal operations (cursor moves, etc.) |
All steps operate on fixed-size stack arrays, ensuring zero-allocation parsing for the common case. The concrete terminal implementation in src/terminal/stream_terminal.zig provides the handler callbacks that modify the actual terminal state.
Implementation Examples
Minimal Logging Handler
You can implement a minimal handler to inspect CSI commands without modifying the terminal:
const std = @import("std");
const Stream = @import("terminal/stream.zig").Stream;
pub const SimpleHandler = struct {
pub fn deinit(self: *SimpleHandler) void {}
pub fn vt(self: *SimpleHandler, tag: anytype, value: anytype) void {
std.debug.print("CSI {s}: {any}\n", .{ @tagName(tag), value });
}
};
pub fn main() !void {
var handler = SimpleHandler{};
var stream = Stream(SimpleHandler).init(handler);
// ESC [ 2 A → cursor up 2 lines
// ESC [ 31 ; 1 m → set red foreground + bold
try stream.nextSlice("\x1B[2A\x1B[31;1m");
}
This outputs:
CSI cursor_up: .{ .value = 2 }
CSI set_attribute: .{ .value = .{ .attr = .red, .attr = .bold } }
Testing Custom Sequences
To verify handling of specific CSI extensions:
test "custom CSI handling" {
var handler = SimpleHandler{};
var stream = Stream(SimpleHandler).init(handler);
// ESC [ ? 25 h → show cursor (DEC private mode)
stream.nextSlice("\x1B[?25h");
// The handler receives a `set_mode` action with the appropriate mode enum
}
Summary
- Three-stage pipeline: Raw bytes →
Parser.zigstate machine →stream.zigdispatch → handler callbacks like those instream_terminal.zig. - Zero-allocation: Fixed-size arrays limit parameters to 24 and intermediates to 4; excess data is silently dropped to prevent memory errors.
- Type-safe dispatch:
csi.zigprovides enums for semantic validation, whilecsiDispatchinstream.zigmaps final characters to typed handler methods. - Extensible architecture: The
Streamgeneric accepts any handler implementing thevtcallback trait, enabling reuse across different front-ends.
Frequently Asked Questions
What are the limits for CSI parameters in Ghostty?
Ghostty's parser enforces a maximum of 24 parameters (MAX_PARAMS) and 4 intermediate bytes (MAX_INTERMEDIATE), as defined in src/terminal/Parser.zig. If a sequence exceeds these limits, the parser silently truncates the excess data to prevent buffer overflows while maintaining zero-allocation guarantees.
How does Ghostty handle malformed CSI sequences?
The state machine in Parser.zig validates transitions and parameter counts. When csiDispatch in src/terminal/stream.zig encounters invalid parameters (such as too many arguments for a specific command), it logs a warning and returns early, preventing the malformed sequence from affecting terminal state.
Does Ghostty support colon-separated subparameters in CSI sequences?
Yes. The parser tracks colon separators using the params_sep bit-set in the csi_dispatch action. This allows the stream to distinguish between semicolon-separated parameters and colon-separated subparameters (used in some SGR extensions), which the handler can then interpret according to the specific command requirements.
What is the difference between Parser.zig and stream.zig?
Parser.zig (src/terminal/Parser.zig) implements the low-level byte-state machine that recognizes escape sequences and extracts raw parameters. stream.zig (src/terminal/stream.zig) provides the higher-level Stream wrapper that takes the parser's output and dispatches specific, type-safe actions to a user-provided handler, effectively bridging raw bytes and terminal semantics.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →