Thread ownership

The surface owns stable pointers for runtime callbacks, renderer state, `Termio`, and both worker threads. The renderer state points at `self.io.terminal`, so renderer and IO coordinate terminal access through `renderer_state.mutex`.

Hot path

The read side is optimized around small, frequent PTY reads. `Exec.ReadThread` reads bytes and calls `Termio.processOutput`, which locks render state, queues a render, and feeds bytes into the terminal stream parser.

Launch: process state to GUI runtime

`main` initializes global state, handles CLI actions, creates the core `App`, initializes the platform runtime app through `apprt`, and hands control to the GUI event loop.

const app: *App = try App.create(alloc);
defer app.destroy();

var app_runtime: apprt.App = undefined;
try app_runtime.init(app, .{});
defer app_runtime.terminate();

try app_runtime.run();
Surface: shared state, renderer, IO, and mailboxes

`Surface.init` is the main wiring point. It derives config, computes font and grid sizing, initializes the renderer, creates the renderer thread, creates the IO thread, initializes the exec backend, creates the SPSC termio mailbox, and finally starts both worker threads.

var io_mailbox = try termio.Mailbox.initSPSC(alloc);

try termio.Termio.init(&self.io, alloc, .{
    .backend = .{ .exec = io_exec },
    .mailbox = io_mailbox,
    .renderer_state = &self.renderer_state,
    .renderer_wakeup = render_thread.wakeup,
    .renderer_mailbox = render_thread.mailbox,
    .surface_mailbox = .{ .surface = self, .app = app_mailbox },
});
Input: UI events to IO mailbox writes

UI input is encoded into terminal sequences, wrapped in `termio.Message` variants, and sent through `Surface.queueIo`. `Termio.queueMessage` publishes the message to the mailbox and notifies the IO thread.

self.io.queueMessage(msg, mutex);

self.mailbox.send(msg, switch (mutex) {
    .locked => self.renderer_state.mutex,
    .unlocked => null,
});
self.mailbox.notify();
PTY: subprocess output enters Termio

The exec backend starts the subprocess when the IO thread enters. The read loop reads from the PTY and immediately calls `Termio.processOutput` with the bytes.

const n = posix.read(fd, &buf) catch |err| switch (err) {
    error.WouldBlock => break,
    else => unreachable,
};

@call(.always_inline, termio.Termio.processOutput, .{ io, buf[0..n] });
Stream: parser actions mutate terminal state

`Termio.processOutput` locks renderer state and feeds bytes into `terminal_stream`. `StreamHandler.vt` receives parsed VT actions and applies them to `terminal.Terminal`, including printing, cursor movement, screen erasing, modes, links, clipboard handling, and renderer or surface side effects.

self.terminal_stream.handler.queueRender() catch unreachable;

if (self.renderer_state.inspector) |insp| {
    for (buf, 0..) |byte, i| {
        insp.recordPtyRead(self.alloc, &self.terminal, buf[i .. i + 1]) catch {};
        self.terminal_stream.next(byte);
    }
} else {
    self.terminal_stream.nextSlice(buf);
}
Renderer: wakeups, messages, and draw

The renderer thread registers async wakeups, drain callbacks, draw timers, and cursor timers. When Termio or the surface notifies the renderer, the thread drains messages and either draws directly or asks the app thread to redraw if the platform requires app-thread drawing.

if (must_draw_from_app_thread) {
    _ = self.app_mailbox.push(.{ .redraw_surface = self.surface }, .{ .instant = {} });
} else {
    self.renderer.drawFrame(false) catch |err|
        log.warn("error drawing err={}", .{err});
}