style enum
A closed enum stores a compact named style state rather than an unchecked integer constant.
Zig difference: The inferred `.green` and `.default` tags remain type-checked at assignment and switch sites.
The Parser now tells us what happened, but its actions disappear after we log them. Let’s add a small Terminal object that remembers characters, styles, and the cursor.
chapter-08-terminal-state$ zig build run
[entry] ghostty
[main] process started
[app] created
[runtime] initialized
[surface 1] created
[runtime] tick
[pipe child stdout]
child: hello
stdin_tty=no
stdout_tty=no
stderr_tty=no
[pipe child] exited 0
[termio] started
[termio read ready] 72 65 61 64 79 3E
[termio write] 68 65 6C 6C 6F 0A
[termio read reply] 68 65 6C 6C 6F 0D 0A 1B 5B 33 32 6D 72 65 70 6C 79 3A 68 65 6C 6C 6F 1B 5B 30 6D 0D 0A
[parser print] ready>hello
[parser execute] CR
[parser execute] LF
[parser sgr] 32
[parser print] reply:hello
[parser sgr] 0
[parser execute] CR
[parser execute] LF
[terminal row 0] ready>hello.............
[terminal style 0] ........................
[terminal row 1] reply:hello.............
[terminal style 1] GGGGGGGGGGG.............
[terminal row 2] ........................
[terminal style 2] ........................
[terminal cursor] row=2 col=0 style=default
[termio] child exited 0
[termio] stopped
[surface 1] destroyed
[runtime] terminated
[app] destroyed
[main] process exitingDots represent blank cells and G represents green cell style. The textual frame is generated from the same Terminal state a later renderer will consume.
printwrite char + current style, advance cursorCRcursor column → 0LFcursor row → next rowSGR 32 / 0current style → green / defaultA Parser action says “print A” or “turn green.” A renderer needs the result after all those instructions have run: which character is in each cell, what style it has, and where the cursor ended up.
DEPENDENCY FRONTIER
The next boundary owns:
cells + per-cell style + cursor + current styleIt still does not own glyphs, textures, frames, or a native window.
Ghostty introduced Terminal.zig on April 17, 2022 with a grid and parser owner. The next day printable strings and multiline movement started working. SGR attributes were connected on May 12. The modern screen/page model accumulated through years of scrolling, Unicode, modes, alternate screens, selection, hyperlinks, images, and performance work.
Early Terminal state turned print and controls into rows and cursor movement before broad VT behavior existed.
fc8bd85Open early TerminalA fixed 3×24 grid stores ASCII cells, default/green style, and cursor state produced by the live child interaction.
chapter-08-terminal-stateCurrent Terminal manages paged screens, scrollback, Unicode, modes, styles, links, images, selection, damage, and many protocol responses.
6ad1fe7Open current TerminalThe reconstruction does not copy modern page abstractions backward. Three fixed rows are enough to prove the parser-to-state contract.
Start with the C model, then inspect each highlighted Zig line.
Open in the cumulative Zig/C referenceA closed enum stores a compact named style state rather than an unchecked integer constant.
Zig difference: The inferred `.green` and `.default` tags remain type-checked at assignment and switch sites.
Why no higher-level tab: Nested JavaScript or Python lists are reference-based, dynamically sized objects and would misrepresent Zig’s fixed contiguous cell storage and value copies.
Text version:
Styleis a closed enum and eachCellis a small value.[rows][columns]Cellembeds every cell inline in Terminal; compile-time repetition creates blank rows without heap allocation. Cursor indexes select a cell for in-place assignment, then mutate explicitly.
Fixed 3×24 ASCII cells, cursor, default/green style, action application, and textual frame; production pages, modes, Unicode, and scrollback are deferred.
const Terminal = @This();
const std = @import("std");
const Action = @import("Parser.zig").Action;
pub const rows = 3;
pub const columns = 24;
pub const Style = enum { default, green };
pub const Cell = struct { char: u8 = ' ', style: Style = .default };
const blank_row = [_]Cell{.{}} ** columns;
cells: [rows][columns]Cell = [_][columns]Cell{blank_row} ** rows,
cursor_row: usize = 0,
cursor_column: usize = 0,
style: Style = .default,
pub fn apply(self: *Terminal, actions: []const Action) void {
for (actions) |action| switch (action) {
.print => |byte| self.print(byte),
.execute => |byte| self.execute(byte),
.sgr => |value| self.setGraphicRendition(value),
};
}
fn print(self: *Terminal, byte: u8) void {
self.cells[self.cursor_row][self.cursor_column] = .{
.char = byte,
.style = self.style,
};
self.cursor_column += 1;
if (self.cursor_column == columns) {
self.cursor_column = 0;
self.cursor_row = @min(self.cursor_row + 1, rows - 1);
}
}
fn execute(self: *Terminal, byte: u8) void {
switch (byte) {
'\r' => self.cursor_column = 0,
'\n' => self.cursor_row = @min(self.cursor_row + 1, rows - 1),
else => {},
}
}
fn setGraphicRendition(self: *Terminal, value: u16) void {
self.style = switch (value) {
32 => .green,
else => .default,
};
}
pub fn trace(self: *const Terminal) void {
for (self.cells, 0..) |row, row_index| {
std.debug.print("[terminal row {d}] ", .{row_index});
for (row) |cell| std.debug.print("{c}", .{if (cell.char == ' ') '.' else cell.char});
std.debug.print("\n", .{});
std.debug.print("[terminal style {d}] ", .{row_index});
for (row) |cell| std.debug.print("{c}", .{
if (cell.style == .green) @as(u8, 'G') else @as(u8, '.'),
});
std.debug.print("\n", .{});
}
std.debug.print("[terminal cursor] row={d} col={d} style={s}\n", .{
self.cursor_row,
self.cursor_column,
@tagName(self.style),
});
}
test "actions update cells cursor and style" {
var terminal: Terminal = .{};
terminal.apply(&.{
.{ .print = 'A' },
.{ .sgr = 32 },
.{ .print = 'B' },
.{ .sgr = 0 },
.{ .execute = '\r' },
.{ .execute = '\n' },
.{ .print = 'C' },
});
try std.testing.expectEqual(@as(u8, 'A'), terminal.cells[0][0].char);
try std.testing.expectEqual(Style.green, terminal.cells[0][1].style);
try std.testing.expectEqual(@as(u8, 'C'), terminal.cells[1][0].char);
try std.testing.expectEqual(@as(usize, 1), terminal.cursor_column);
}For now, one cell only needs a byte and a style:
pub const Cell = struct { char: u8 = ' ', style: Style = .default,};Terminal also stores cursor_row, cursor_column, and the current style applied to future printed cells.
The first mapping is almost mechanical:
| Parser action | State transition |
|---|---|
print(byte) |
write cell with current style; advance cursor |
execute(CR) |
cursor column becomes zero |
execute(LF) |
cursor moves to next row |
sgr(32) |
current style becomes green |
sgr(0) |
current style becomes default |
This separation is important: Terminal never sees raw ESC or CSI syntax. Parser already turned those bytes into clean actions.
Termio records the Parser action count before feeding bytes, then applies only newly emitted actions:
const action_start = parser.action_count;try parser.feed(bytes);terminal.apply(parser.actionsSince(action_start));This prevents old actions from being replayed and makes state advance incrementally with the live stream.
Owns Parser and Terminal together, applying newly emitted actions synchronously after each PTY read chunk.
const Termio = @This();
const std = @import("std");
const Command = @import("../Command.zig");
const ptypkg = @import("../pty.zig");
const Pty = ptypkg.Pty;
const terminalpkg = @import("../terminal/main.zig");
const Parser = terminalpkg.Parser;
const Terminal = terminalpkg.Terminal;
pty: Pty,
child: Command.Child,
phase_bytes: [4096]u8 = undefined,
phase_len: usize = 0,
parser: Parser = .{},
terminal: Terminal = .{},
pub fn init(command: Command, size: ptypkg.winsize) !Termio {
var pty = try Pty.open(size);
errdefer pty.deinit();
const child = try command.startPty(&pty);
std.debug.print("[termio] started\n", .{});
return .{ .pty = pty, .child = child, .parser = .{}, .terminal = .{} };
}
pub fn deinit(self: *Termio) void {
self.pty.deinit();
if (!self.child.waited) _ = self.child.wait() catch {};
std.debug.print("[termio] stopped\n", .{});
}
pub fn writeAll(self: *Termio, bytes: []const u8) !void {
try self.pty.writeAll(bytes);
traceHex("write", bytes);
}
pub fn readUntil(self: *Termio, marker: []const u8) ![]const u8 {
self.phase_len = 0;
var chunk: [3]u8 = undefined;
while (std.mem.indexOf(u8, self.phase_bytes[0..self.phase_len], marker) == null) {
const count = try self.pty.read(&chunk);
if (count == 0) return error.EndOfStream;
try self.append(chunk[0..count]);
}
return self.phase_bytes[0..self.phase_len];
}
pub fn readToEnd(self: *Termio) ![]const u8 {
self.phase_len = 0;
var chunk: [3]u8 = undefined;
while (true) {
const count = try self.pty.read(&chunk);
if (count == 0) break;
try self.append(chunk[0..count]);
}
return self.phase_bytes[0..self.phase_len];
}
pub fn wait(self: *Termio) !u8 {
return self.child.wait();
}
pub fn traceParser(self: *const Termio) void {
self.parser.traceActions();
}
pub fn traceTerminal(self: *const Termio) void {
self.terminal.trace();
}
fn append(self: *Termio, bytes: []const u8) !void {
if (self.phase_len + bytes.len > self.phase_bytes.len) return error.OutputTooLong;
const action_start = self.parser.action_count;
try self.parser.feed(bytes);
self.terminal.apply(self.parser.actionsSince(action_start));
@memcpy(self.phase_bytes[self.phase_len..][0..bytes.len], bytes);
self.phase_len += bytes.len;
}
pub fn traceHex(label: []const u8, bytes: []const u8) void {
std.debug.print("[termio {s}]", .{label});
for (bytes) |byte| std.debug.print(" {X:0>2}", .{byte});
std.debug.print("\n", .{});
}
test "Termio owns interactive PTY reads and writes" {
const script: []const u8 = "printf 'ready>'; IFS= read -r line; printf 'reply:%s\\n' \"$line\"";
const command: Command = .{ .argv = &.{ "/bin/sh", "-c", script } };
var termio = try Termio.init(command, .{});
defer termio.deinit();
_ = try termio.readUntil("ready>");
try termio.writeAll("test\n");
_ = try termio.readToEnd();
try std.testing.expectEqual(@as(u8, 0), try termio.wait());
}The child interaction produces:
ready> from childhello PTY local echoCR LF move to row 1SGR 32 future cells greenreply:hello green cellsSGR 0 future cells defaultCR LF cursor to row 2, column 0The first row therefore contains ready>hello in default style. The second contains reply:hello in green. The third is blank with the cursor at its start.
Terminal.trace renders:
.;.;G;This is not a pretty renderer, but it proves the boundary: give it Terminal state and it produces a frame we can check exactly.
zig fmt --check .zig buildzig build testzig build runThe Terminal unit test applies print, SGR, CR, and LF actions and asserts exact cells, style, and cursor movement independently of PTY behavior.
| File | Status | Deferred production behavior |
|---|---|---|
src/terminal/Terminal.zig |
adapted | dynamic pages, scrollback, Unicode, wide cells, modes, alternate screen, tabs, selection, hyperlinks, images, damage |
src/terminal/Parser.zig |
adapted | exposes action slices for incremental state application |
src/termio/Termio.zig |
adapted | owns synchronous Terminal; no renderer snapshot or mailbox |
src/Surface.zig |
adapted | emits textual frame rather than native rendering |
src/terminal/Terminal.zig:300–340Read the durable state owners and initialization order. Ignore configuration values not yet reconstructed.src/terminal/Terminal.zig:1120–1160Read how a printable codepoint reaches screen cells and cursor logic. Ignore unsupported modes for now.src/terminal/Terminal.zig:3573–3595Connect parser attributes to current style state used by future cells.commit d730e085c760ddaadf654c5c4430f3099932d3c9tag chapter-08-terminal-stateThe debug frame proves state, but users still cannot see a native application surface. The next chapter must choose and initialize the first real platform runtime, open a blank window, and capture an actual screenshot before any GPU or font complexity is added.