Skip to content

Chapter 08: Terminal state and debug frame

Let’s keep the result instead of only printing actions

Section titled “Let’s keep the result instead of only printing actions”

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.

VISIBLE RESULTA deterministic terminal frame without a GUI
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 exiting

Dots represent blank cells and G represents green cell style. The textual frame is generated from the same Terminal state a later renderer will consume.

DURABLE STATEParser actions become styled cells and a cursor
  1. printwrite char + current style, advance cursor
  2. CRcursor column → 0
  3. LFcursor row → next row
  4. SGR 32 / 0current style → green / default

Why can’t a renderer draw Parser actions directly?

Section titled “Why can’t a renderer draw Parser actions directly?”

A 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

Build only what the current result needs

  1. 00Process entryReconstructed
  2. 01App lifecycleReconstructed
  3. 02Runtime + SurfaceReconstructed
  4. 03Child process + PTYReconstructed
  5. 04I/O + parserReconstructed
  6. 05Terminal stateYou are here
  7. 06Window + GPU + fontsNext limitation

The next boundary owns:

cells + per-cell style + cursor + current style

It still does not own glyphs, textures, frames, or a native window.

Real Ghostty’s Terminal grew one behavior at a time

Section titled “Real Ghostty’s Terminal grew one behavior at a time”

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.

SOURCE ARCHAEOLOGYThen, reconstruction, and now solve different-sized problems
  1. 01
    THEN · 2022-04-18Write strings into a grid

    Early Terminal state turned print and controls into rows and cursor movement before broad VT behavior existed.

    fc8bd85Open early Terminal
  2. 02
    RECONSTRUCTION · CHAPTER 08Keep only earned state

    A fixed 3×24 grid stores ASCII cells, default/green style, and cursor state produced by the live child interaction.

    chapter-08-terminal-state
  3. 03
    NOW · PINNED MAINModel production terminal behavior

    Current Terminal manages paged screens, scrollback, Unicode, modes, styles, links, images, selection, damage, and many protocol responses.

    6ad1fe7Open current Terminal

The reconstruction does not copy modern page abstractions backward. Three fixed rows are enough to prove the parser-to-state contract.

Start with one Cell: what must we remember?

Section titled “Start with one Cell: what must we remember?”
ZIG SYNTAX BRIDGE

Store cells inline and mutate the cursor

Start with the C model, then inspect each highlighted Zig line.

Open in the cumulative Zig/C reference
C MENTAL MODEL

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.

MEMORY / LIFETIME FLOW
  1. Terminal valueinline rows × columns Cells
  2. cursor fieldsindexes into the same storage
  3. apply callcopies Action payload into one Cell

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: Style is a closed enum and each Cell is a small value. [rows][columns]Cell embeds 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.

adaptedsrc/terminal/Terminal.zig

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.

Follow each Parser action into Terminal state

Section titled “Follow each Parser action into Terminal state”

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.

Return to Termio and apply only new actions

Section titled “Return to Termio and apply only new 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.

adaptedsrc/termio/Termio.zig

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 child
hello PTY local echo
CR LF move to row 1
SGR 32 future cells green
reply:hello green cells
SGR 0 future cells default
CR LF cursor to row 2, column 0

The 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.

Draw a text-only frame before building a GUI renderer

Section titled “Draw a text-only frame before building a GUI renderer”

Terminal.trace renders:

  • blank characters as .;
  • default styles as .;
  • green styles as G;
  • cursor and current style as metadata.

This is not a pretty renderer, but it proves the boundary: give it Terminal state and it produces a frame we can check exactly.

Terminal window
zig fmt --check .
zig build
zig build test
zig build run

The 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

How does this tiny grid grow into real Ghostty?

Section titled “How does this tiny grid grow into real Ghostty?”
</>
Current Terminal initializationsrc/terminal/Terminal.zig:300–340Read the durable state owners and initialization order. Ignore configuration values not yet reconstructed.
Read excerptGitHub
</>
Current print pathsrc/terminal/Terminal.zig:1120–1160Read how a printable codepoint reaches screen cells and cursor logic. Ignore unsupported modes for now.
Read excerptGitHub
</>
Current SGR style applicationsrc/terminal/Terminal.zig:3573–3595Connect parser attributes to current style state used by future cells.
Read excerptGitHub
commit d730e085c760ddaadf654c5c4430f3099932d3c9
tag chapter-08-terminal-state

The state works—how do we show it in a real app?

Section titled “The state works—how do we show it in a real app?”

The 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.