summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/main.zig280
-rw-r--r--src/tui_app.zig824
-rw-r--r--src/tui_component.zig337
-rw-r--r--src/tui_components.zig1101
-rw-r--r--src/tui_engine.zig1116
-rw-r--r--src/tui_input.zig489
-rw-r--r--src/tui_key.zig129
-rw-r--r--src/tui_terminal.zig405
-rw-r--r--src/tui_theme.zig136
9 files changed, 4648 insertions, 169 deletions
diff --git a/src/main.zig b/src/main.zig
index 2a1de27..f1527d1 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -15,6 +15,17 @@ const system_prompt = @import("system_prompt.zig");
const command = @import("command.zig");
const command_compaction = @import("compaction.zig");
+// TUI foundation layer (Phase 1, sub-phase 1). Not yet wired into the REPL;
+// referenced here so `zig build test` type-checks and exercises them.
+const tui_terminal = @import("tui_terminal.zig");
+const tui_key = @import("tui_key.zig");
+const tui_input = @import("tui_input.zig");
+const tui_theme = @import("tui_theme.zig");
+const tui_component = @import("tui_component.zig");
+const tui_engine = @import("tui_engine.zig");
+const tui_components = @import("tui_components.zig");
+const tui_app = @import("tui_app.zig");
+
// Shorthand alias for the Lua C API. The bridge module owns the actual
// `@cImport`; we re-use it here so the smoke check uses identical types.
const lua = lua_bridge.c;
@@ -43,111 +54,14 @@ test {
_ = system_prompt;
_ = command;
_ = command_compaction;
-}
-
-const ContentBlockType = panto.ContentBlockType;
-const MessageRole = panto.MessageRole;
-const Event = panto.Event;
-
-/// Display state for the pull-stream CLI renderer. Prints streaming deltas
-/// to stdout; thinking blocks are dimmed with ANSI escape codes, text blocks
-/// render plain. Persistence is owned by the agent; the renderer is
-/// display-only.
-const CLIRenderer = struct {
- stdout: *std.Io.Writer,
- file: *std.Io.File.Writer,
- allocator: std.mem.Allocator,
-
- /// Per-turn reset hook. Currently a no-op (no per-turn render state),
- /// retained as the REPL's turn-boundary signal.
- pub fn beginTurn(self: *CLIRenderer) void {
- _ = self;
- }
-
- pub fn deinit(self: *CLIRenderer) void {
- _ = self;
- }
-
- /// Render one pull `Event`. Mirrors the prior push receiver's output
- /// exactly: dim `[thinking]`, a `tool:` prefix opened at block start
- /// with the name appended at block_complete, a trailing newline at each
- /// `message_complete`, and a dim retry status line.
- fn render(self: *CLIRenderer, ev: Event) !void {
- switch (ev) {
- .message_start => {},
- .block_start => |b| {
- switch (b.block_type) {
- .Thinking => try self.stdout.writeAll("\x1b[2m[thinking] "),
- // Tool name is not known reliably at start time (OpenAI
- // may stream id/name across fragments). Open with a bare
- // prefix; the name lands at block_complete from the
- // assembled ContentBlock.
- .ToolUse => try self.stdout.writeAll("\n\x1b[36mtool: \x1b[0m"),
- else => {},
- }
- try self.file.flush();
- },
- // The print-based CLI defers tool-name rendering to
- // block_complete to avoid cursor gymnastics (we can't go back and
- // edit the prefix), so tool_details is a no-op here.
- .tool_details => {},
- .content_delta => |d| {
- try self.stdout.writeAll(d.delta);
- try self.file.flush();
- },
- .block_complete => |b| {
- switch (b.block) {
- .Thinking => try self.stdout.writeAll("\x1b[0m\n"),
- // Append the tool name now that we know it for certain.
- .ToolUse => |tu| try self.stdout.print("\x1b[36m : ({s})\x1b[0m", .{tu.name}),
- else => {},
- }
- try self.file.flush();
- },
- .message_complete => {
- try self.stdout.writeAll("\n");
- try self.file.flush();
- },
- // Surface provider retry scheduling as a dim status line so the
- // user can see the agent is waiting on the provider, not hung.
- .provider_retry => |info| {
- if (info.compaction) {
- self.stdout.print(
- "\x1b[2mcontext overflow: compacting and retrying\x1b[0m\n",
- .{},
- ) catch {};
- } else {
- const secs = @as(f64, @floatFromInt(info.delay_ms)) / 1000.0;
- self.stdout.print(
- "\x1b[2mprovider unavailable ({s}): retrying in {d:.1}s (attempt {d}/{d})\x1b[0m\n",
- .{ @errorName(info.err), secs, info.attempt + 1, info.max_attempts },
- ) catch {};
- }
- self.file.flush() catch {};
- },
- .tool_dispatch_start, .tool_dispatch_complete, .turn_complete => {},
- }
- }
-
- /// Reset any in-progress display state after a failed turn. Errors here
- /// must be swallowed — we're already in the error path.
- fn renderError(self: *CLIRenderer, err: anyerror) void {
- _ = &err;
- // If we were rendering a Thinking block, clear the dim style so
- // subsequent output (including the "[error: ...]" line) is readable.
- self.stdout.writeAll("\x1b[0m") catch {};
- self.file.flush() catch {};
- }
-};
-
-/// Drive a whole turn: open the pull `Stream` and render every event until
-/// it terminates (`turn_complete` → `next()` returns null). A failure
-/// surfaces as the error from `next()`; the caller renders it. The stream is
-/// always `deinit`ed (persisting the turn tail) on every exit path.
-fn driveTurn(agent: *panto.Agent, message: panto.UserMessage, renderer: *CLIRenderer) !void {
- var stream = try agent.run(message);
- defer stream.deinit();
- while (try stream.next()) |ev| try renderer.render(ev);
+ _ = tui_terminal;
+ _ = tui_key;
+ _ = tui_input;
+ _ = tui_theme;
+ _ = tui_component;
+ _ = tui_engine;
+ _ = tui_components;
+ _ = tui_app;
}
/// Spin up a Lua interpreter, run a no-op, tear it down. Catches
@@ -261,9 +175,9 @@ pub fn main(init: std.process.Init) !void {
var stdout_file = std.Io.File.stdout().writer(io, &stdout_buffer);
const stdout = &stdout_file.interface;
- var stdin_buffer: [4096]u8 = undefined;
- var stdin_file = std.Io.File.stdin().reader(io, &stdin_buffer);
- const stdin = &stdin_file.interface;
+ // The TUI reads raw bytes from the tty fd directly (raw mode); we only
+ // need the stdin file handle, not a buffered reader.
+ const stdin_handle = std.Io.File.stdin().handle;
// Resolve where this project's sessions live.
var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
@@ -480,23 +394,6 @@ pub fn main(init: std.process.Init) !void {
// config the agent re-reads each turn (visible before the first turn).
active_config.compaction.compaction_prompt = compaction_prompt;
- const banner_base: []const u8 = switch (provider_config) {
- inline else => |c| c.base_url,
- };
- try stdout.print(
- "panto — {s}: {s} @ {s}\n",
- .{ banner_provider_initial, banner_model_initial, banner_base },
- );
- try stdout.print("> ", .{});
- try stdout_file.flush();
-
- var cli_renderer = CLIRenderer{
- .stdout = stdout,
- .file = &stdout_file,
- .allocator = alloc,
- };
- defer cli_renderer.deinit();
-
// Build the slash-command registry and register builtins, then append
// any commands declared by Lua extensions (harvested at load time).
var cmd_registry = command.Registry.init(alloc);
@@ -521,10 +418,18 @@ pub fn main(init: std.process.Init) !void {
};
}
+ // Command output is captured into an in-memory buffer (the command
+ // Context's `stdout`) and flushed into the TUI transcript after each
+ // dispatch — TUI-safe: handlers never write raw bytes mid-frame. This is
+ // the documented minimal choice for slash-command output under the TUI; a
+ // richer routing (per-command transcript components) can refine it later.
+ var cmd_capture = std.Io.Writer.Allocating.init(alloc);
+ defer cmd_capture.deinit();
+
var cmd_ctx: command.Context = .{
.allocator = alloc,
.agent = agent,
- .stdout = stdout,
+ .stdout = &cmd_capture.writer,
.stdout_file = &stdout_file,
.compaction_prompt = compaction_prompt,
.provider_name = banner_provider_initial,
@@ -532,54 +437,91 @@ pub fn main(init: std.process.Init) !void {
.lua_rt = rt,
};
- while (true) {
- const maybe_line = stdin.takeDelimiter('\n') catch |err| {
- std.debug.print("read error: {}\n", .{err});
- return;
- };
- const line = maybe_line orelse {
- try stdout.writeAll("\n");
- try stdout_file.flush();
- break;
- };
+ // -- TUI bring-up ------------------------------------------------------
+ //
+ // Full replacement of the print REPL (version control is the safety net).
+ // The terminal enters raw mode + bracketed paste; the engine drives a
+ // differential render of the transcript + pinned input box + footer; the
+ // app loop pumps libpanto's pull Stream into component state. The terminal
+ // is restored on every exit path (defer) and on crash (signal handlers
+ // installed by `enableRawMode` + the panic-restore hook).
+ var term = tui_terminal.Terminal.init(stdin_handle, init.environ_map.*) catch |err| switch (err) {
+ tui_terminal.Error.NotATty => {
+ std.debug.print(
+ "error: panto's TUI requires an interactive terminal (stdin/stdout must be a tty).\n",
+ .{},
+ );
+ std.process.exit(1);
+ },
+ else => return err,
+ };
+ try term.enableRawMode();
+ defer term.deinit();
- if (line.len == 0) {
- try stdout.writeAll("\n> ");
- try stdout_file.flush();
- continue;
- }
+ const size = term.refreshSize();
+ var tui_writer_buf: [16 * 1024]u8 = undefined;
+ var tui_file = std.Io.File.stdout().writer(io, &tui_writer_buf);
+ var engine = tui_engine.Engine.init(
+ alloc,
+ &tui_file.interface,
+ size.cols,
+ size.rows,
+ term.caps.synchronized_output,
+ );
+ defer engine.deinit();
- // Slash commands are handled by the CLI, not sent to the model.
- // Any line starting with `/` is a command; an unrecognized one
- // prints an error rather than reaching the model.
- if (std.mem.startsWith(u8, line, "/")) {
- cmd_registry.dispatch(line, &cmd_ctx) catch |err| switch (err) {
- command.Error.CommandNotFound => {
- try stdout.print("\n[unknown command: {s}]\n> ", .{line});
- try stdout_file.flush();
- },
- else => {
- try stdout.print("\n[command error: {s}]\n> ", .{@errorName(err)});
- try stdout_file.flush();
- },
- };
- continue;
+ var input_box = tui_components.InputBox.init(alloc);
+ defer input_box.deinit();
+ var footer = tui_components.Footer.init(alloc);
+ defer footer.deinit();
+
+ var io_clock = tui_app.IoClock.init(io);
+ var app = tui_app.App.init(
+ alloc,
+ &engine,
+ io_clock.clock(),
+ &input_box,
+ &footer,
+ );
+ defer app.deinit();
+
+ const Flusher = struct {
+ fn flush(ctx: *anyopaque) void {
+ const fw: *std.Io.File.Writer = @ptrCast(@alignCast(ctx));
+ fw.interface.flush() catch {};
}
+ };
+ app.setFlusher(&tui_file, Flusher.flush);
- // Drive the turn. `run` submits + persists the user prompt and the
- // agent owns the conversation, persisting everything it generates
- // (assistant/tool-result messages, and the compaction window on
- // auto-compaction) through its session store — the CLI no longer
- // touches persistence.
- cli_renderer.beginTurn();
- driveTurn(agent, .{ .text = line }, &cli_renderer) catch |err| {
- cli_renderer.renderError(err);
- try stdout.print("\n[error: {s}]\n", .{@errorName(err)});
- };
+ const model_label = try std.fmt.allocPrint(
+ alloc,
+ "{s}:{s}",
+ .{ banner_provider_initial, banner_model_initial },
+ );
+ defer alloc.free(model_label);
- try stdout.writeAll("\n> ");
- try stdout_file.flush();
- }
+ // The render engine writes through a buffered file writer; flush after the
+ // loop so the final frame and teardown sequences reach the terminal.
+ defer tui_file.interface.flush() catch {};
+
+ tui_app.runLoop(&app, &term, .{
+ .agent = agent,
+ .cmd_registry = &cmd_registry,
+ .cmd_ctx = &cmd_ctx,
+ .cmd_capture = &cmd_capture,
+ .model_label = model_label,
+ }) catch |err| switch (err) {
+ // Clean user-initiated exit (Ctrl+C / Ctrl+D). Not an error.
+ error.UserExit => {},
+ else => {
+ // Restore the terminal before surfacing the diagnostic so the
+ // message is readable.
+ term.deinit();
+ tui_file.interface.flush() catch {};
+ std.debug.print("\n[fatal: {s}]\n", .{@errorName(err)});
+ return err;
+ },
+ };
}
// -----------------------------------------------------------------------------
diff --git a/src/tui_app.zig b/src/tui_app.zig
new file mode 100644
index 0000000..03b32c5
--- /dev/null
+++ b/src/tui_app.zig
@@ -0,0 +1,824 @@
+//! The TUI application loop (plan §2/§9): wires the libpanto pull `Stream`
+//! into component state and drives the differential render engine.
+//!
+//! This module is the NEW app/chat loop that `main.zig` shrinks to wiring
+//! around. It owns:
+//! - a `Terminal` (raw mode + bracketed paste + SIGWINCH/restore),
+//! - a `tui_engine.Engine` driving a LIST of components,
+//! - the transcript (heap-allocated user/assistant/status components that
+//! persist for the engine to borrow),
+//! - a pinned `InputBox` (focused) and `Footer` (fps element),
+//! - the libpanto stream pump that routes each `Event` to component state.
+//!
+//! ## No "active component" invariant (plan §6)
+//!
+//! Streaming state is keyed by libpanto BLOCK INDEX (and tool call identity),
+//! never a single mutable "current component" pointer. `TurnRouter` holds a
+//! `block_index -> *transcript entry` map, so when parallel tool calls or
+//! interleaved blocks arrive later (P2), each delta lands on the right
+//! component without restructuring. P1 only spawns the minimal component set
+//! (user/assistant/input/footer + minimal status lines), but the routing
+//! structure is already parallel-safe.
+//!
+//! ## Streaming -> component state (plan §8)
+//!
+//! There is no per-delta render method. The pump consumes the pull `Stream`
+//! and, for each event, MUTATES component state and calls
+//! `scheduler.requestRender()`. The engine's append fast path
+//! (`firstLineChanged` near the tail via the render cache + the line-diff
+//! backstop) repaints only the dirty tail. stdout is never written directly.
+//!
+//! ## Thinking / tool deltas in P1 (deferred display, non-crashing)
+//!
+//! P1's minimal component set is user/assistant/input/footer. There is no
+//! dedicated thinking or collapsible tool-use component yet (P2). To avoid
+//! crashing on those blocks while keeping the loop honest:
+//! - a Thinking block streams its deltas into a DIM status line (one
+//! `AssistantText`-style component styled dim), and
+//! - a ToolUse block renders a single dim `tool: <name>` status line
+//! (name resolved at `tool_details` / `block_complete`).
+//! The full thinking component and the collapsible tool-use component are
+//! deferred to P2; this is the documented minimal stand-in.
+
+const std = @import("std");
+const posix = std.posix;
+const panto = @import("panto");
+
+const terminal_mod = @import("tui_terminal.zig");
+const engine_mod = @import("tui_engine.zig");
+const components = @import("tui_components.zig");
+const input_mod = @import("tui_input.zig");
+const theme = @import("tui_theme.zig");
+const component = @import("tui_component.zig");
+const command = @import("command.zig");
+
+const Terminal = terminal_mod.Terminal;
+const Engine = engine_mod.Engine;
+const Scheduler = engine_mod.Scheduler;
+const Clock = engine_mod.Clock;
+const AssistantText = components.AssistantText;
+const UserText = components.UserText;
+const InputBox = components.InputBox;
+const Footer = components.Footer;
+const Component = component.Component;
+
+const Event = panto.Event;
+
+// ===========================================================================
+// IoClock — the real monotonic clock for the engine's scheduler
+// ===========================================================================
+
+/// Wraps `std.Io`'s monotonic (`.awake`) clock as an engine `Clock`. The
+/// engine stays Io-agnostic; this is the app-side adapter that supplies real
+/// time. Store one by value and pass `clock()` into the engine/`App`.
+pub const IoClock = struct {
+ io: std.Io,
+
+ pub fn init(io: std.Io) IoClock {
+ return .{ .io = io };
+ }
+
+ fn nowFn(ptr: *anyopaque) i128 {
+ const self: *IoClock = @ptrCast(@alignCast(ptr));
+ return @intCast(std.Io.Clock.now(.awake, self.io).nanoseconds);
+ }
+
+ pub fn clock(self: *IoClock) Clock {
+ return .{ .ptr = self, .nowFn = nowFn };
+ }
+};
+
+// ===========================================================================
+// Transcript
+// ===========================================================================
+
+/// A heap-allocated transcript entry. The engine borrows each entry's
+/// `comp()`; the entry must outlive its time in the engine's list, so the
+/// transcript owns the boxes on the heap and frees them on `deinit`.
+///
+/// `StatusText` reuses `AssistantText` but is styled by the caller via a
+/// leading style escape baked into the text (we keep it as a plain
+/// AssistantText for P1 and prefix a dim/style run in the seeded text).
+const Entry = union(enum) {
+ user: *UserText,
+ /// Assistant message body (streaming text block).
+ assistant: *AssistantText,
+ /// A dim status/thinking/tool/retry line (minimal P1 stand-in; not a full
+ /// component — see module docs).
+ status: *AssistantText,
+
+ fn comp(self: Entry) Component {
+ return switch (self) {
+ .user => |p| p.comp(),
+ .assistant => |p| p.comp(),
+ .status => |p| p.comp(),
+ };
+ }
+
+ fn deinit(self: Entry, alloc: std.mem.Allocator) void {
+ switch (self) {
+ .user => |p| {
+ p.deinit();
+ alloc.destroy(p);
+ },
+ .assistant => |p| {
+ p.deinit();
+ alloc.destroy(p);
+ },
+ .status => |p| {
+ p.deinit();
+ alloc.destroy(p);
+ },
+ }
+ }
+};
+
+// ===========================================================================
+// App
+// ===========================================================================
+
+pub const App = struct {
+ alloc: std.mem.Allocator,
+ engine: *Engine,
+ scheduler: Scheduler,
+ clock: Clock,
+
+ /// Owned transcript entries (boxes the engine borrows). Top-to-bottom.
+ transcript: std.ArrayList(Entry) = .empty,
+
+ /// Pinned, persistent components. Owned here (by value); the engine
+ /// borrows their `comp()`.
+ input_box: *InputBox,
+ footer: *Footer,
+
+ /// Per-turn block routing. Cleared at each turn boundary.
+ router: TurnRouter,
+
+ /// Optional sink flusher. The real terminal's engine writer is a buffered
+ /// file writer that must be flushed after each frame for output to reach
+ /// the tty; tests inject an in-memory writer and leave this null.
+ flush_ctx: ?*anyopaque = null,
+ flush_fn: ?*const fn (ctx: *anyopaque) void = null,
+
+ /// Whether the input box currently participates in the engine list. It is
+ /// removed during an in-flight turn (so streaming output appends below the
+ /// transcript) and re-added when the turn completes. P1 keeps it simple:
+ /// input + footer are always present and pinned at the bottom.
+ pub fn init(
+ alloc: std.mem.Allocator,
+ engine: *Engine,
+ clock: Clock,
+ input_box: *InputBox,
+ footer: *Footer,
+ ) App {
+ return .{
+ .alloc = alloc,
+ .engine = engine,
+ .scheduler = Scheduler.init(8 * std.time.ns_per_ms),
+ .clock = clock,
+ .input_box = input_box,
+ .footer = footer,
+ .router = TurnRouter.init(alloc),
+ };
+ }
+
+ pub fn deinit(self: *App) void {
+ for (self.transcript.items) |e| e.deinit(self.alloc);
+ self.transcript.deinit(self.alloc);
+ self.router.deinit();
+ }
+
+ /// Install a sink flusher (the buffered terminal file writer). Called once
+ /// during real-terminal bring-up; tests leave it unset.
+ pub fn setFlusher(self: *App, ctx: *anyopaque, f: *const fn (ctx: *anyopaque) void) void {
+ self.flush_ctx = ctx;
+ self.flush_fn = f;
+ }
+
+ fn flushSink(self: *App) void {
+ if (self.flush_fn) |f| f(self.flush_ctx.?);
+ }
+
+ // -- transcript spawning ------------------------------------------------
+
+ /// Append a fresh transcript entry and register it with the engine,
+ /// keeping the pinned input box + footer at the very bottom. Returns the
+ /// new entry (still owned by the transcript).
+ fn pushEntry(self: *App, entry: Entry) !void {
+ try self.transcript.append(self.alloc, entry);
+ try self.rebuildEngineList();
+ }
+
+ /// Rebuild the engine's component list: all transcript entries top-to-
+ /// bottom, then the pinned input box, then the footer. Called whenever the
+ /// transcript layout changes (a layout change forces a full redraw inside
+ /// the engine, which is correct here).
+ fn rebuildEngineList(self: *App) !void {
+ // Clear and re-add. `removeComponent` is O(n) per call, so clear by
+ // re-initializing the slot list via repeated pops is awkward; instead
+ // remove the pinned components, then append the new entry, then re-add
+ // the pinned ones. To keep it simple and correct we drain & rebuild.
+ while (self.engine.componentCount() > 0) {
+ const first = self.engine.slots.items[0].comp;
+ _ = self.engine.removeComponent(first);
+ }
+ for (self.transcript.items) |e| try self.engine.addComponent(e.comp());
+ try self.engine.addComponent(self.input_box.comp());
+ try self.engine.addComponent(self.footer.comp());
+ }
+
+ /// Spawn a new assistant-text entry for the given block index and return
+ /// it. Keyed by index in the router so deltas route without an "active
+ /// component" pointer.
+ fn spawnAssistant(self: *App) !*AssistantText {
+ const box = try self.alloc.create(AssistantText);
+ box.* = AssistantText.init(self.alloc);
+ try self.pushEntry(.{ .assistant = box });
+ return box;
+ }
+
+ /// Spawn a dim status line seeded with `text`. Used for thinking blocks,
+ /// tool-call status, retry notices, command output, and errors. Returns
+ /// the box so streaming callers (thinking) can append more.
+ fn spawnStatus(self: *App, text: []const u8) !*AssistantText {
+ const box = try self.alloc.create(AssistantText);
+ box.* = AssistantText.init(self.alloc);
+ // Seed with a dim run so the status reads as chrome, not assistant
+ // prose. The component renders plain assistant style, so we bake the
+ // dim escape into the text itself (a documented P1 minimal stand-in
+ // for a real status component).
+ const dim = theme.default.fg(.dim);
+ const seeded = try std.fmt.allocPrint(self.alloc, "{s}{s}{s}", .{ dim.open(), text, dim.close() });
+ defer self.alloc.free(seeded);
+ try box.setText(seeded);
+ try self.pushEntry(.{ .status = box });
+ return box;
+ }
+
+ /// Spawn a user-message entry seeded with `text`.
+ fn spawnUser(self: *App, text: []const u8) !void {
+ const box = try self.alloc.create(UserText);
+ box.* = UserText.init(self.alloc);
+ try box.setText(text);
+ try self.pushEntry(.{ .user = box });
+ }
+
+ // -- the render pump ----------------------------------------------------
+
+ /// Render a frame if one is pending, feeding the footer the measured
+ /// render time. Returns true if a frame was drawn.
+ pub fn maybeRender(self: *App) !bool {
+ const now = self.clock.now();
+ if (!self.scheduler.shouldRenderNow(now)) return false;
+ const start = self.clock.now();
+ try self.engine.render();
+ self.flushSink();
+ const end = self.clock.now();
+ const ms = @as(f64, @floatFromInt(end - start)) / @as(f64, std.time.ns_per_ms);
+ // Feed the footer the last frame's render time. This dirties the
+ // footer for NEXT frame; we don't recursively render here (the next
+ // pending frame picks it up), keeping the fps readout one frame
+ // behind, which is acceptable for the perf-validation surface.
+ self.footer.setFrameTime(ms);
+ self.scheduler.noteRendered(self.clock.now());
+ return true;
+ }
+
+ /// Force a render now (e.g. after a turn boundary or resize), bypassing
+ /// the coalescing window.
+ pub fn renderNow(self: *App) !void {
+ self.scheduler.requestRender();
+ const start = self.clock.now();
+ try self.engine.render();
+ self.flushSink();
+ const end = self.clock.now();
+ const ms = @as(f64, @floatFromInt(end - start)) / @as(f64, std.time.ns_per_ms);
+ self.footer.setFrameTime(ms);
+ self.scheduler.noteRendered(self.clock.now());
+ }
+
+ // -- event routing ------------------------------------------------------
+
+ /// Route one libpanto `Event` to component state (plan §8). NEVER writes
+ /// to stdout; mutates components and requests a render. Keyed by block
+ /// index via `router` so there is no "active component" pointer.
+ pub fn routeEvent(self: *App, ev: Event) !void {
+ switch (ev) {
+ .message_start => {},
+ .block_start => |b| {
+ switch (b.block_type) {
+ .Text => {
+ const box = try self.spawnAssistant();
+ try self.router.put(b.index, .{ .assistant = box });
+ },
+ .Thinking => {
+ // Minimal P1 stand-in: a dim streaming status line.
+ const box = try self.spawnStatus("[thinking] ");
+ try self.router.put(b.index, .{ .thinking = box });
+ },
+ .ToolUse => {
+ // Minimal P1 stand-in: a dim one-line tool status. The
+ // name is unknown at start (streamed); fill it in at
+ // tool_details / block_complete.
+ const box = try self.spawnStatus("tool: …");
+ try self.router.put(b.index, .{ .tool = box });
+ },
+ .ToolResult => {},
+ }
+ self.scheduler.requestRender();
+ },
+ .tool_details => |d| {
+ if (self.router.get(d.index)) |ref| switch (ref) {
+ .tool => |box| {
+ const dim = theme.default.fg(.dim);
+ const line = try std.fmt.allocPrint(self.alloc, "{s}tool: {s}{s}", .{ dim.open(), d.name, dim.close() });
+ defer self.alloc.free(line);
+ try box.setText(line);
+ self.scheduler.requestRender();
+ },
+ else => {},
+ };
+ },
+ .content_delta => |d| {
+ if (self.router.get(d.index)) |ref| switch (ref) {
+ .assistant => |box| {
+ try box.appendDelta(d.delta);
+ self.scheduler.requestRender();
+ },
+ .thinking => |box| {
+ // Append thinking deltas (still dim — the seed kept the
+ // dim run open; we append raw text, which renders plain
+ // assistant style. Acceptable P1 stand-in).
+ try box.appendDelta(d.delta);
+ self.scheduler.requestRender();
+ },
+ // Tool args stream as deltas too; P1 doesn't display the
+ // streamed JSON args (deferred to the P2 tool component).
+ .tool => {},
+ };
+ },
+ .block_complete => |b| {
+ switch (b.block) {
+ .ToolUse => |tu| {
+ if (self.router.get(b.index)) |ref| switch (ref) {
+ .tool => |box| {
+ const dim = theme.default.fg(.dim);
+ const line = try std.fmt.allocPrint(self.alloc, "{s}tool: {s}{s}", .{ dim.open(), tu.name, dim.close() });
+ defer self.alloc.free(line);
+ try box.setText(line);
+ self.scheduler.requestRender();
+ },
+ else => {},
+ };
+ },
+ else => {},
+ }
+ },
+ .message_complete => {},
+ .provider_retry => |info| {
+ // Preserve the existing dim retry messaging meaning as a status
+ // line in the transcript.
+ if (info.compaction) {
+ _ = try self.spawnStatus("context overflow: compacting and retrying");
+ } else {
+ const secs = @as(f64, @floatFromInt(info.delay_ms)) / 1000.0;
+ const msg = try std.fmt.allocPrint(
+ self.alloc,
+ "provider unavailable ({s}): retrying in {d:.1}s (attempt {d}/{d})",
+ .{ @errorName(info.err), secs, info.attempt + 1, info.max_attempts },
+ );
+ defer self.alloc.free(msg);
+ _ = try self.spawnStatus(msg);
+ }
+ self.scheduler.requestRender();
+ },
+ .tool_dispatch_start, .tool_dispatch_complete, .turn_complete => {},
+ }
+ }
+
+ /// Reset per-turn routing state. The transcript entries persist (they are
+ /// the chat history); only the block-index map is cleared.
+ pub fn beginTurn(self: *App) void {
+ self.router.reset();
+ }
+
+ /// Surface a turn error as a dim status line in the transcript.
+ pub fn routeError(self: *App, err: anyerror) !void {
+ const msg = try std.fmt.allocPrint(self.alloc, "[error: {s}]", .{@errorName(err)});
+ defer self.alloc.free(msg);
+ _ = try self.spawnStatus(msg);
+ self.scheduler.requestRender();
+ }
+};
+
+// ===========================================================================
+// TurnRouter — block-index -> component map (no "active component")
+// ===========================================================================
+
+/// A reference to the transcript component a libpanto block is streaming into.
+/// Keyed by block index in `TurnRouter`. This is the structure that makes the
+/// loop parallel-tool-call ready: each block index has its own sink, so there
+/// is never a single mutable "current" component.
+pub const BlockRef = union(enum) {
+ assistant: *AssistantText,
+ /// Thinking block (dim status stand-in for P1).
+ thinking: *AssistantText,
+ /// Tool-use block (one-line status stand-in for P1).
+ tool: *AssistantText,
+};
+
+pub const TurnRouter = struct {
+ map: std.AutoHashMap(usize, BlockRef),
+
+ pub fn init(alloc: std.mem.Allocator) TurnRouter {
+ return .{ .map = std.AutoHashMap(usize, BlockRef).init(alloc) };
+ }
+
+ pub fn deinit(self: *TurnRouter) void {
+ self.map.deinit();
+ }
+
+ pub fn reset(self: *TurnRouter) void {
+ self.map.clearRetainingCapacity();
+ }
+
+ pub fn put(self: *TurnRouter, index: usize, ref: BlockRef) !void {
+ try self.map.put(index, ref);
+ }
+
+ pub fn get(self: *TurnRouter, index: usize) ?BlockRef {
+ return self.map.get(index);
+ }
+};
+
+// ===========================================================================
+// Driving the loop (real terminal)
+// ===========================================================================
+
+/// Inputs the loop needs from `main.zig` (kept as a struct so the wiring stays
+/// a single call). The agent, command registry, and command context are
+/// borrowed for the loop's lifetime.
+pub const RunOptions = struct {
+ agent: *panto.Agent,
+ cmd_registry: *const command.Registry,
+ cmd_ctx: *command.Context,
+ /// In-memory writer that command handlers write to (their `stdout`). After
+ /// each dispatch the captured text is flushed into the transcript as a dim
+ /// status line, then cleared. See `runLoop` for the rationale.
+ cmd_capture: *std.Io.Writer.Allocating,
+ model_label: []const u8,
+};
+
+/// Run the interactive chat loop against a real terminal until EOF / Ctrl+D /
+/// Ctrl+C. Restores the terminal on every exit path (the `Terminal` installs
+/// signal + the caller installs panic restore).
+///
+/// Loop shape (single-threaded, poll-based):
+/// 1. Render any pending frame (feeding the footer the frame time).
+/// 2. Poll the tty for input with a short timeout (so coalesced renders and
+/// SIGWINCH are serviced promptly even with no keypress).
+/// 3. Decode buffered bytes -> keys -> the focused input box.
+/// 4. On a submitted line: drive a turn (or dispatch a slash command),
+/// pumping the stream's events into component state.
+pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void {
+ // Negotiate bracketed paste (+ opportunistic Kitty). Teardown on exit.
+ term.writeAll(input_mod.negotiate_setup);
+ defer term.writeAll(input_mod.negotiate_teardown);
+ term.hideCursor();
+ defer term.showCursor();
+
+ try app.footer.setModel(opts.model_label);
+ app.input_box.setFocused(true);
+ try app.rebuildEngineList();
+ try app.renderNow();
+
+ var read_buf: [4096]u8 = undefined;
+ // Retained partial-sequence tail across reads (a CSI/UTF-8 split across
+ // read() boundaries).
+ var tail: std.ArrayList(u8) = .empty;
+ defer tail.deinit(app.alloc);
+
+ while (true) {
+ // 1. Service a pending coalesced frame.
+ _ = try app.maybeRender();
+
+ // 1b. SIGWINCH -> resize -> full redraw.
+ if (term.takeResized()) {
+ const size = term.refreshSize();
+ app.engine.resize(size.cols, size.rows);
+ try app.renderNow();
+ }
+
+ // 2. Poll for input (short timeout so renders/resize stay responsive).
+ const ready = pollReadable(term.fd, 16) catch true;
+ if (!ready) continue;
+
+ const n = posix.read(term.fd, &read_buf) catch |err| switch (err) {
+ error.WouldBlock => continue,
+ else => return,
+ };
+ if (n == 0) break; // EOF (Ctrl+D on an empty line closes the tty)
+
+ // 3. Decode. Prepend any retained tail, decode all complete sequences,
+ // retain the unconsumed tail for the next read.
+ try tail.appendSlice(app.alloc, read_buf[0..n]);
+ const consumed = try handleBytes(app, tail.items, opts);
+ // Keep the unconsumed tail.
+ const leftover = tail.items.len - consumed;
+ std.mem.copyForwards(u8, tail.items[0..leftover], tail.items[consumed..]);
+ tail.items.len = leftover;
+
+ // 4. A frame may now be pending (input edited the box / a turn ran).
+ _ = try app.maybeRender();
+ }
+}
+
+/// Decode `bytes` into keys, route control keys (Ctrl+C/Ctrl+D) at the app
+/// level, feed the rest to the focused input box, and act on any submitted
+/// line. Returns the number of bytes consumed (the unconsumed partial tail is
+/// retained by the caller).
+fn handleBytes(app: *App, bytes: []const u8, opts: RunOptions) !usize {
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const step = input_mod.decodeOne(bytes[off..]) orelse break; // partial tail
+ switch (step.decoded) {
+ .key => |k| {
+ // App-level control keys.
+ if (k.isCtrl('c') or k.isCtrl('d')) {
+ // Clean exit: restore handled by deferred teardown + the
+ // terminal's deinit in main. Signal EOF by closing the loop.
+ return error.UserExit;
+ }
+ // Feed the key to the focused input box.
+ app.input_box.comp().handleInput(bytes[off .. off + step.consumed]);
+ },
+ .paste => {
+ app.input_box.comp().handleInput(bytes[off .. off + step.consumed]);
+ },
+ }
+ off += step.consumed;
+ app.scheduler.requestRender();
+
+ // Did the box submit a line?
+ if (app.input_box.takeSubmitted()) |line_borrowed| {
+ // Copy: the box may reuse its buffer.
+ const line = try app.alloc.dupe(u8, line_borrowed);
+ defer app.alloc.free(line);
+ try handleSubmittedLine(app, line, opts);
+ }
+ }
+ return off;
+}
+
+/// Handle a submitted input line: slash command vs. model turn.
+fn handleSubmittedLine(app: *App, line: []const u8, opts: RunOptions) !void {
+ if (line.len == 0) return;
+
+ if (std.mem.startsWith(u8, line, "/")) {
+ // Slash command. Output is captured into `opts.cmd_capture` (the
+ // command Context's stdout) and flushed into the transcript as a dim
+ // status line — TUI-safe (no raw stdout writes during a frame).
+ opts.cmd_capture.clearRetainingCapacity();
+ opts.cmd_registry.dispatch(line, opts.cmd_ctx) catch |err| switch (err) {
+ command.Error.CommandNotFound => {
+ const msg = try std.fmt.allocPrint(app.alloc, "[unknown command: {s}]", .{line});
+ defer app.alloc.free(msg);
+ _ = try app.spawnStatus(msg);
+ },
+ else => {
+ const msg = try std.fmt.allocPrint(app.alloc, "[command error: {s}]", .{@errorName(err)});
+ defer app.alloc.free(msg);
+ _ = try app.spawnStatus(msg);
+ },
+ };
+ // Surface any captured command output.
+ const captured = opts.cmd_capture.written();
+ if (captured.len != 0) {
+ _ = try app.spawnStatus(captured);
+ }
+ try app.renderNow();
+ return;
+ }
+
+ // Model turn. Echo the user message, then pump the stream into components.
+ try app.spawnUser(line);
+ app.beginTurn();
+ try app.renderNow();
+
+ driveTurn(app, opts.agent, .{ .text = line }) catch |err| {
+ try app.routeError(err);
+ };
+ try app.renderNow();
+}
+
+/// Drive one whole turn: open the pull stream, route every event into
+/// component state until it terminates, rendering coalesced frames as deltas
+/// arrive. The stream is always `deinit`ed (persisting the turn tail) on every
+/// exit path — agent persistence is untouched.
+fn driveTurn(app: *App, agent: *panto.Agent, message: panto.UserMessage) !void {
+ var stream = try agent.run(message);
+ defer stream.deinit();
+ while (try stream.next()) |ev| {
+ try app.routeEvent(ev);
+ _ = try app.maybeRender();
+ }
+}
+
+/// Poll the fd for readability with a millisecond timeout. Returns true when
+/// data is available. Uses `poll(2)`.
+fn pollReadable(fd: posix.fd_t, timeout_ms: i32) !bool {
+ var fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }};
+ const n = try posix.poll(&fds, timeout_ms);
+ if (n == 0) return false;
+ return (fds[0].revents & posix.POLL.IN) != 0;
+}
+
+// ===========================================================================
+// Tests
+// ===========================================================================
+
+const testing = std.testing;
+
+/// A test clock that advances by a fixed step each `now()` call so the
+/// scheduler's coalescing logic is deterministic.
+const TestClock = struct {
+ t: i128 = 0,
+ step: i128 = 1,
+
+ fn now(ptr: *anyopaque) i128 {
+ const self: *TestClock = @ptrCast(@alignCast(ptr));
+ const v = self.t;
+ self.t += self.step;
+ return v;
+ }
+
+ fn clock(self: *TestClock) Clock {
+ return .{ .ptr = self, .nowFn = now };
+ }
+};
+
+/// Build an App backed by an in-memory engine writer (no TTY) for routing
+/// tests. Caller owns the returned pieces and must call `teardown`.
+const Harness = struct {
+ buf: std.Io.Writer.Allocating,
+ engine: Engine,
+ input_box: InputBox,
+ footer: Footer,
+ test_clock: TestClock,
+ app: App,
+
+ fn make(alloc: std.mem.Allocator) !*Harness {
+ const h = try alloc.create(Harness);
+ h.buf = std.Io.Writer.Allocating.init(alloc);
+ h.engine = Engine.init(alloc, &h.buf.writer, 80, 24, false);
+ h.input_box = InputBox.init(alloc);
+ h.footer = Footer.init(alloc);
+ h.test_clock = .{ .t = 0, .step = 100 };
+ h.app = App.init(alloc, &h.engine, h.test_clock.clock(), &h.input_box, &h.footer);
+ return h;
+ }
+
+ fn teardown(h: *Harness, alloc: std.mem.Allocator) void {
+ h.app.deinit();
+ h.engine.deinit();
+ h.input_box.deinit();
+ h.footer.deinit();
+ h.buf.deinit();
+ alloc.destroy(h);
+ }
+};
+
+fn delta(index: usize, text: []const u8) Event {
+ return .{ .content_delta = .{ .index = index, .delta = text } };
+}
+
+test "routeEvent: text block + deltas append to an assistant component" {
+ const alloc = testing.allocator;
+ const h = try Harness.make(alloc);
+ defer h.teardown(alloc);
+
+ try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } });
+ try h.app.routeEvent(delta(0, "hello"));
+ try h.app.routeEvent(delta(0, " world"));
+
+ // One transcript entry (assistant), buffer accumulated both deltas.
+ try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len);
+ const ref = h.app.router.get(0).?;
+ try testing.expectEqualStrings("hello world", ref.assistant.buffer.items);
+}
+
+test "routeEvent: two text blocks key by index, no active-component clobber" {
+ const alloc = testing.allocator;
+ const h = try Harness.make(alloc);
+ defer h.teardown(alloc);
+
+ // Two interleaved text blocks (the no-active-component invariant: deltas
+ // for index 0 must NOT land on index 1 even after block 1 opened).
+ try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } });
+ try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 1 } });
+ try h.app.routeEvent(delta(1, "B"));
+ try h.app.routeEvent(delta(0, "A"));
+ try h.app.routeEvent(delta(0, "A2"));
+
+ try testing.expectEqualStrings("AA2", h.app.router.get(0).?.assistant.buffer.items);
+ try testing.expectEqualStrings("B", h.app.router.get(1).?.assistant.buffer.items);
+}
+
+test "routeEvent: thinking deltas do not crash and stream to a status line" {
+ const alloc = testing.allocator;
+ const h = try Harness.make(alloc);
+ defer h.teardown(alloc);
+
+ try h.app.routeEvent(.{ .block_start = .{ .block_type = .Thinking, .index = 0 } });
+ try h.app.routeEvent(delta(0, "reasoning"));
+
+ const ref = h.app.router.get(0).?;
+ try testing.expect(ref == .thinking);
+ // The status line buffer contains the seed + appended delta.
+ try testing.expect(std.mem.indexOf(u8, ref.thinking.buffer.items, "reasoning") != null);
+}
+
+test "routeEvent: tool block renders a minimal tool: <name> status (no crash)" {
+ const alloc = testing.allocator;
+ const h = try Harness.make(alloc);
+ defer h.teardown(alloc);
+
+ try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
+ // Tool args streaming as deltas must be dropped silently, not crash.
+ try h.app.routeEvent(delta(0, "{\"path\":"));
+ try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "t1", .name = "read" } });
+
+ const ref = h.app.router.get(0).?;
+ try testing.expect(ref == .tool);
+ try testing.expect(std.mem.indexOf(u8, ref.tool.buffer.items, "tool: read") != null);
+}
+
+test "routeEvent: provider_retry adds a dim status line" {
+ const alloc = testing.allocator;
+ const h = try Harness.make(alloc);
+ defer h.teardown(alloc);
+
+ try h.app.routeEvent(.{ .provider_retry = .{
+ .err = error.ConnectionResetByPeer,
+ .delay_ms = 1500,
+ .attempt = 0,
+ .max_attempts = 3,
+ .compaction = false,
+ } });
+ try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len);
+ const e = h.app.transcript.items[0];
+ try testing.expect(e == .status);
+ try testing.expect(std.mem.indexOf(u8, e.status.buffer.items, "retrying") != null);
+}
+
+test "routeEvent: full event stream renders through the real engine, no stdout" {
+ const alloc = testing.allocator;
+ const h = try Harness.make(alloc);
+ defer h.teardown(alloc);
+
+ // Pin input + footer like the real loop.
+ h.app.input_box.setFocused(true);
+ try h.app.rebuildEngineList();
+
+ h.app.beginTurn();
+ try h.app.routeEvent(.{ .message_start = .assistant });
+ try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } });
+ try h.app.routeEvent(delta(0, "Hi there"));
+ try h.app.routeEvent(.{ .turn_complete = {} });
+
+ try h.app.renderNow();
+ const out = h.buf.written();
+ // The assistant text reached the engine output (not stdout).
+ try testing.expect(std.mem.indexOf(u8, out, "Hi there") != null);
+}
+
+test "beginTurn clears the block-index map but keeps transcript history" {
+ const alloc = testing.allocator;
+ const h = try Harness.make(alloc);
+ defer h.teardown(alloc);
+
+ try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } });
+ try h.app.routeEvent(delta(0, "first turn"));
+ try testing.expect(h.app.router.get(0) != null);
+
+ h.app.beginTurn();
+ // Router cleared...
+ try testing.expect(h.app.router.get(0) == null);
+ // ...but the transcript entry persists as history.
+ try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len);
+}
+
+test "maybeRender feeds the footer a frame time and respects coalescing" {
+ const alloc = testing.allocator;
+ const h = try Harness.make(alloc);
+ defer h.teardown(alloc);
+ try h.app.rebuildEngineList();
+
+ // No pending frame => no render.
+ try testing.expect(!(try h.app.maybeRender()));
+
+ h.app.scheduler.requestRender();
+ try testing.expect(try h.app.maybeRender()); // idle => renders
+ // Footer received a frame time (>= 0).
+ try testing.expect(h.app.footer.frame_ms != null);
+}
diff --git a/src/tui_component.zig b/src/tui_component.zig
new file mode 100644
index 0000000..a2b0ea4
--- /dev/null
+++ b/src/tui_component.zig
@@ -0,0 +1,337 @@
+//! The core component contract for the TUI.
+//!
+//! Dispatch is a vtable of function pointers over `*anyopaque` (decided in
+//! plan §4.5; a tagged union was rejected so out-of-tree extensions can define
+//! their own components later without editing a central enum).
+//!
+//! The render engine (later sub-phase) holds a list of `Component`s, asks each
+//! to `render(width)` into lines, and uses `firstLineChanged` to do a
+//! differential repaint. This file defines only the interface plus a small
+//! reusable cache/dirty mixin; it implements no concrete components and no
+//! engine.
+
+const std = @import("std");
+
+/// Invisible cursor marker.
+///
+/// A focused `Focusable` component embeds this marker in its rendered output
+/// at the virtual cursor location. It is an APC string (`ESC _ ... ESC \`),
+/// which terminals ignore (zero visible width), so it survives in the line
+/// buffer until the engine scans for it. In P3 the engine will locate this
+/// marker, strip it from the emitted bytes, and position the hardware cursor
+/// there; for P1 the marker simply ships and is treated as zero-width.
+///
+/// The payload `panto-cursor` disambiguates it from any other APC a child
+/// component might legitimately emit.
+pub const CURSOR_MARKER = "\x1b_panto-cursor\x1b\\";
+
+/// The component vtable. Concrete components store their own state behind
+/// `ptr` and provide function pointers that recover the concrete type via
+/// `@ptrCast`/`@alignCast`.
+///
+/// Line / width contract (plan §3.1): every line returned by `render` MUST
+/// have visible width <= the `width` argument. The engine validates this and
+/// treats overflow as a hard error, so components are responsible for
+/// truncating. The returned `[]const []const u8` and its lines are owned by
+/// the component (typically backed by its render cache); they must remain
+/// valid until the next `render`/`invalidate` call on that component.
+pub const Component = struct {
+ ptr: *anyopaque,
+ vtable: *const VTable,
+
+ pub const VTable = struct {
+ /// Render the component at `width` columns, returning one slice per
+ /// visible line. Each line's visible width must be <= `width`.
+ /// `alloc` is the engine's per-frame/render allocator; whether the
+ /// component caches into its own storage or allocates fresh each call
+ /// is up to it, but the returned slices must outlive the call until
+ /// the next render/invalidate.
+ render: *const fn (ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8,
+
+ /// Lowest component-local line index whose rendered output differs
+ /// since the last successful render, or null if nothing changed.
+ ///
+ /// This MUST be derived from the component's render cache (see
+ /// `RenderCache`), not a separately hand-managed integer that can
+ /// drift from reality.
+ firstLineChanged: *const fn (ptr: *anyopaque) ?usize,
+
+ /// Drop cached render state, forcing a full re-render and re-dirtying
+ /// the component.
+ invalidate: *const fn (ptr: *anyopaque) void,
+
+ /// Optional: feed raw input bytes (already routed to this component by
+ /// the engine) for the component to interpret. Null when the component
+ /// does not accept input.
+ handleInput: ?*const fn (ptr: *anyopaque, data: []const u8) void = null,
+
+ /// Capability: when true the engine should deliver key *release*
+ /// events to this component (most components only want press/repeat).
+ wantsKeyRelease: bool = false,
+ };
+
+ pub fn render(self: Component, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
+ return self.vtable.render(self.ptr, width, alloc);
+ }
+
+ pub fn firstLineChanged(self: Component) ?usize {
+ return self.vtable.firstLineChanged(self.ptr);
+ }
+
+ pub fn invalidate(self: Component) void {
+ self.vtable.invalidate(self.ptr);
+ }
+
+ pub fn handleInput(self: Component, data: []const u8) void {
+ if (self.vtable.handleInput) |f| f(self.ptr, data);
+ }
+
+ pub fn wantsKeyRelease(self: Component) bool {
+ return self.vtable.wantsKeyRelease;
+ }
+};
+
+/// The focus contract. A component that can hold focus exposes `focused`,
+/// which the engine flips on focus changes. When focused, the component emits
+/// `CURSOR_MARKER` at its virtual cursor position inside its `render` output.
+///
+/// This is a thin embeddable struct: a focusable component contains one and
+/// the engine sets `.focused` via the component's own setter (the vtable does
+/// not yet carry focus methods — focus routing wiring is part of the engine
+/// sub-phase; what ships in P1 is the flag + the marker constant + the
+/// documented contract).
+pub const Focusable = struct {
+ focused: bool = false,
+
+ pub fn setFocused(self: *Focusable, value: bool) void {
+ self.focused = value;
+ }
+};
+
+/// Reusable cache + dirty bookkeeping for components.
+///
+/// firstLineChanged lifecycle (mirrors pi's `invalidate()` model):
+/// - State mutation calls `markDirty()` (or `invalidate()`), which clears
+/// the cache. While dirty, `firstLineChanged()` reports the recorded
+/// dirty line (0 by default — "everything from the top changed").
+/// - A *successful* render calls `store(lines)`, which:
+/// 1. diffs the new lines against the previously cached lines,
+/// 2. records the lowest differing index as the "changed-from" value,
+/// 3. replaces the cache with a copy of the new lines,
+/// 4. marks the cache clean.
+/// - After a clean render with no diff, `firstLineChanged()` returns null.
+///
+/// Because `firstLineChanged` is computed purely from cache state, it cannot
+/// drift from a hand-managed field. Components embed this struct and route
+/// their vtable through `cacheRender`/`markDirty`.
+///
+/// The cache owns its copies of the line bytes (allocated with the allocator
+/// passed to `init`); call `deinit` to free them.
+pub const RenderCache = struct {
+ alloc: std.mem.Allocator,
+ /// Owned copies of the last rendered lines, or null when never rendered or
+ /// invalidated.
+ lines: ?[][]u8 = null,
+ /// Lowest line index that changed at the last `store`, or 0 when dirty
+ /// with no prior cache. Null only when clean with no change.
+ changed_from: ?usize = null,
+ dirty: bool = true,
+
+ pub fn init(alloc: std.mem.Allocator) RenderCache {
+ return .{ .alloc = alloc };
+ }
+
+ pub fn deinit(self: *RenderCache) void {
+ self.freeLines();
+ }
+
+ fn freeLines(self: *RenderCache) void {
+ if (self.lines) |lines| {
+ for (lines) |line| self.alloc.free(line);
+ self.alloc.free(lines);
+ self.lines = null;
+ }
+ }
+
+ /// Mark the component dirty (e.g. on any state mutation). Drops the cache
+ /// so the next render is a full render starting at `from` (default 0).
+ pub fn markDirty(self: *RenderCache) void {
+ self.markDirtyFrom(0);
+ }
+
+ /// Mark dirty starting at a specific line. The cache is dropped (we no
+ /// longer trust any cached line), and `firstLineChanged` will report
+ /// `from` until the next successful render.
+ pub fn markDirtyFrom(self: *RenderCache, from: usize) void {
+ self.dirty = true;
+ self.changed_from = from;
+ self.freeLines();
+ }
+
+ /// Mark dirty for an APPEND-style mutation that only affects the tail of
+ /// the rendered output (e.g. a streaming content delta appended to the
+ /// end of a buffer).
+ ///
+ /// Unlike `markDirty`/`markDirtyFrom`, this RETAINS the cached baseline
+ /// lines so the next `store` can diff against them and recover the TRUE
+ /// lowest-changed index (which, for an append, is near the tail). While
+ /// dirty, `firstLineChanged` reports a conservative TAIL HINT — the index
+ /// of the last cached line (or 0 when there is no baseline yet) — so the
+ /// engine re-renders and the cut stays near the end rather than rolling to
+ /// line 0. The post-render diff in `store` then replaces the hint with the
+ /// exact change point. `firstLineChanged` therefore remains cache-derived:
+ /// the dirty hint comes from the retained cache's length, and the precise
+ /// value comes from the diff.
+ pub fn markDirtyAppend(self: *RenderCache) void {
+ self.dirty = true;
+ if (self.lines) |lines| {
+ // Tail hint: the last existing line is the lowest line an append
+ // can change. Keep the baseline for the diff in `store`.
+ self.changed_from = if (lines.len == 0) 0 else lines.len - 1;
+ } else {
+ self.changed_from = 0;
+ }
+ }
+
+ /// Equivalent to `markDirty` — full drop + re-dirty. Provided so a
+ /// component's `invalidate` vtable entry can forward here verbatim.
+ pub fn invalidate(self: *RenderCache) void {
+ self.markDirty();
+ }
+
+ /// Derived dirty signal. Returns the lowest changed line index, or null
+ /// when the cache is clean and nothing changed at the last store.
+ pub fn firstLineChanged(self: *const RenderCache) ?usize {
+ if (self.dirty) return self.changed_from orelse 0;
+ return self.changed_from;
+ }
+
+ /// Record a successful render. Diffs `new_lines` against the cache,
+ /// updates `changed_from` to the lowest differing index, copies the new
+ /// lines into the cache, and marks it clean.
+ pub fn store(self: *RenderCache, new_lines: []const []const u8) !void {
+ const diff = self.computeFirstDiff(new_lines);
+
+ // Build the new owned copy first; only swap in on success.
+ var copies = try self.alloc.alloc([]u8, new_lines.len);
+ var made: usize = 0;
+ errdefer {
+ for (copies[0..made]) |c| self.alloc.free(c);
+ self.alloc.free(copies);
+ }
+ for (new_lines, 0..) |line, i| {
+ copies[i] = try self.alloc.dupe(u8, line);
+ made = i + 1;
+ }
+
+ self.freeLines();
+ self.lines = copies;
+ self.changed_from = diff;
+ self.dirty = false;
+ }
+
+ /// Lowest index at which `new_lines` differs from the cached lines.
+ /// Returns null when they are identical. When there is no prior cache (or
+ /// the line counts differ at index 0), returns 0.
+ fn computeFirstDiff(self: *const RenderCache, new_lines: []const []const u8) ?usize {
+ const old = self.lines orelse return 0;
+ const n = @min(old.len, new_lines.len);
+ var i: usize = 0;
+ while (i < n) : (i += 1) {
+ if (!std.mem.eql(u8, old[i], new_lines[i])) return i;
+ }
+ // Common prefix matched; if lengths differ, the first extra/missing
+ // line index is the change point.
+ if (old.len != new_lines.len) return n;
+ return null;
+ }
+};
+
+test "CURSOR_MARKER is an APC string" {
+ try std.testing.expect(std.mem.startsWith(u8, CURSOR_MARKER, "\x1b_"));
+ try std.testing.expect(std.mem.endsWith(u8, CURSOR_MARKER, "\x1b\\"));
+}
+
+test "RenderCache: starts dirty from 0" {
+ var c = RenderCache.init(std.testing.allocator);
+ defer c.deinit();
+ try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged());
+}
+
+test "RenderCache: store cleans, no-change render returns null" {
+ var c = RenderCache.init(std.testing.allocator);
+ defer c.deinit();
+
+ const a = [_][]const u8{ "one", "two" };
+ try c.store(&a);
+ // First store after empty cache => changed from 0.
+ try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged());
+
+ // Re-store identical => no change.
+ try c.store(&a);
+ try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged());
+}
+
+test "RenderCache: store reports lowest differing line" {
+ var c = RenderCache.init(std.testing.allocator);
+ defer c.deinit();
+
+ const a = [_][]const u8{ "one", "two", "three" };
+ try c.store(&a);
+ const b = [_][]const u8{ "one", "TWO", "three" };
+ try c.store(&b);
+ try std.testing.expectEqual(@as(?usize, 1), c.firstLineChanged());
+}
+
+test "RenderCache: line-count change reports the boundary" {
+ var c = RenderCache.init(std.testing.allocator);
+ defer c.deinit();
+
+ const a = [_][]const u8{ "one", "two" };
+ try c.store(&a);
+ const b = [_][]const u8{ "one", "two", "three" };
+ try c.store(&b);
+ try std.testing.expectEqual(@as(?usize, 2), c.firstLineChanged());
+}
+
+test "RenderCache: markDirtyAppend keeps baseline and reports a tail hint, diff recovers exact change" {
+ var c = RenderCache.init(std.testing.allocator);
+ defer c.deinit();
+
+ const a = [_][]const u8{ "l0", "l1", "l2", "l3" };
+ try c.store(&a);
+ // No baseline change yet; append-dirty reports the tail hint (last line).
+ c.markDirtyAppend();
+ try std.testing.expectEqual(@as(?usize, 3), c.firstLineChanged());
+ // The baseline is retained for the diff.
+ try std.testing.expect(c.lines != null);
+
+ // A render that only adds a tail line: diff recovers the exact boundary (4),
+ // NOT 0 — the streaming-tail property.
+ const b = [_][]const u8{ "l0", "l1", "l2", "l3", "l4" };
+ try c.store(&b);
+ try std.testing.expectEqual(@as(?usize, 4), c.firstLineChanged());
+}
+
+test "RenderCache: markDirtyAppend with no baseline reports 0" {
+ var c = RenderCache.init(std.testing.allocator);
+ defer c.deinit();
+ c.markDirtyAppend();
+ try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged());
+}
+
+test "RenderCache: markDirty re-dirties from 0" {
+ var c = RenderCache.init(std.testing.allocator);
+ defer c.deinit();
+ const a = [_][]const u8{"x"};
+ try c.store(&a);
+ c.markDirty();
+ try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged());
+}
+
+test "Focusable flips" {
+ var f: Focusable = .{};
+ try std.testing.expect(!f.focused);
+ f.setFocused(true);
+ try std.testing.expect(f.focused);
+}
diff --git a/src/tui_components.zig b/src/tui_components.zig
new file mode 100644
index 0000000..1832fc2
--- /dev/null
+++ b/src/tui_components.zig
@@ -0,0 +1,1101 @@
+//! Built-in P1 components for the TUI (plan §6).
+//!
+//! Each component satisfies the `Component` vtable (`tui_component.zig`) and
+//! implements the cache-derived dirty model exactly as `RenderCache` defines
+//! it: any state mutation calls `markDirty`/`markDirtyFrom` (drops the cache),
+//! and a successful `render` calls `cache.store(lines)` (diffs the new lines
+//! against the prior cache, records the lowest differing index, re-populates
+//! the cache, and marks it clean). `firstLineChanged` is therefore derived
+//! purely from cache state and never a hand-managed integer that can drift.
+//!
+//! Data-in / lines-out: each component takes STRUCTURED DATA IN via setters or
+//! delta-appenders and produces LINES OUT from `render(width, alloc)`. Every
+//! returned line's visible width is <= `width` (we TRUNCATE; the engine treats
+//! overflow as a hard error per plan §3.1).
+//!
+//! Render storage convention: a component renders into a transient list, calls
+//! `cache.store(lines)` (which dupes the bytes into cache-owned storage), then
+//! returns `cache.lines` re-typed as `[]const []const u8`. The returned slices
+//! are owned by the cache and stay valid until the next `render`/`invalidate`
+//! — satisfying the vtable's lifetime contract.
+
+const std = @import("std");
+const component = @import("tui_component.zig");
+const theme = @import("tui_theme.zig");
+const input = @import("tui_input.zig");
+const key = @import("tui_key.zig");
+
+const Component = component.Component;
+const Focusable = component.Focusable;
+const RenderCache = component.RenderCache;
+const CURSOR_MARKER = component.CURSOR_MARKER;
+const Style = theme.Style;
+const Key = key.Key;
+const KeyCode = key.KeyCode;
+
+// ===========================================================================
+// Shared helpers
+// ===========================================================================
+
+/// Number of display columns occupied by `text`, counted as one column per
+/// UTF-8 codepoint. `text` here is assumed to be PLAIN (no escape sequences);
+/// components wrap on plain text and only add styling escapes afterward, so
+/// this is a faithful visible width. Mirrors the engine's P1 approximation
+/// (1 col per codepoint; wide CJK/emoji width is a deferred refinement).
+pub fn displayWidth(text: []const u8) usize {
+ var cols: usize = 0;
+ var i: usize = 0;
+ while (i < text.len) {
+ const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
+ cols += 1;
+ i += @min(seq_len, text.len - i);
+ }
+ return cols;
+}
+
+/// Truncate `text` to at most `max_cols` display columns, returning a byte
+/// slice of `text` that ends on a codepoint boundary. Never splits a multibyte
+/// codepoint.
+pub fn truncateToCols(text: []const u8, max_cols: usize) []const u8 {
+ var cols: usize = 0;
+ var i: usize = 0;
+ while (i < text.len and cols < max_cols) {
+ const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
+ const adv = @min(seq_len, text.len - i);
+ i += adv;
+ cols += 1;
+ }
+ return text[0..i];
+}
+
+/// Wrap `text` (a single logical paragraph, no embedded newlines) into lines of
+/// at most `width` display columns, appending each produced line to `out`.
+/// Greedy word-wrap on ASCII spaces; a word longer than `width` is hard-split.
+/// An empty paragraph yields one empty line. Lines pushed to `out` are slices
+/// borrowed from `text` (no allocation of line bytes here; `out` only stores
+/// the slice headers).
+fn wrapParagraph(text: []const u8, width: usize, out: *std.ArrayList([]const u8), alloc: std.mem.Allocator) !void {
+ if (width == 0) {
+ try out.append(alloc, "");
+ return;
+ }
+ if (text.len == 0) {
+ try out.append(alloc, "");
+ return;
+ }
+
+ // Greedy word-wrap. We accumulate a line by byte range [line_start, i); on
+ // overflow we break at the last space that fits, or hard-split a word that
+ // is wider than `width`.
+ var line_start: usize = 0;
+ var line_cols: usize = 0;
+ var last_break: ?usize = null; // byte index of the last space on this line
+ var i: usize = 0;
+
+ while (i < text.len) {
+ const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
+ const adv = @min(seq_len, text.len - i);
+ const is_space = adv == 1 and text[i] == ' ';
+
+ if (is_space and line_cols == width) {
+ // The overflowing glyph is the inter-word space itself: break here
+ // and consume the space (standard word-wrap discards it) so the
+ // current word group fills the line exactly.
+ try out.append(alloc, text[line_start..i]);
+ line_start = i + adv;
+ last_break = null;
+ line_cols = 0;
+ i += adv;
+ continue;
+ }
+ if (line_cols + 1 > width) {
+ // Adding this glyph would overflow; break the line first.
+ if (last_break) |brk| {
+ // Break at the last space: emit up to (not including) it, and
+ // start the next line just after it.
+ try out.append(alloc, text[line_start..brk]);
+ line_start = brk + 1;
+ last_break = null;
+ line_cols = displayWidth(text[line_start..i]);
+ } else {
+ // No space on this line: hard-split before the current glyph.
+ try out.append(alloc, text[line_start..i]);
+ line_start = i;
+ line_cols = 0;
+ }
+ }
+
+ if (is_space) last_break = i;
+ line_cols += 1;
+ i += adv;
+ }
+ // Flush the final line (always emit, even if empty/trailing fragment).
+ try out.append(alloc, text[line_start..]);
+}
+
+/// Split `buffer` on newlines into paragraphs and wrap each to `width`,
+/// appending all produced lines to `out`. A trailing newline produces a final
+/// empty line (so a freshly-typed "\n" shows a blank row). An empty buffer
+/// produces no lines.
+fn wrapBuffer(buffer: []const u8, width: usize, out: *std.ArrayList([]const u8), alloc: std.mem.Allocator) !void {
+ if (buffer.len == 0) return;
+ var it = std.mem.splitScalar(u8, buffer, '\n');
+ while (it.next()) |para| {
+ try wrapParagraph(para, width, out, alloc);
+ }
+}
+
+/// Build the cache-owned line set for a styled text block: each wrapped plain
+/// line is wrapped in `style.open()`/`style.close()` and stored via the cache.
+/// Returns the cache's owned lines re-typed for the vtable.
+///
+/// `width` bounds the *visible* width: the plain text is truncated to `width`
+/// columns BEFORE styling escapes are added (escapes are zero visible width).
+fn renderStyledLines(
+ cache: *RenderCache,
+ buffer: []const u8,
+ style: Style,
+ width: usize,
+ alloc: std.mem.Allocator,
+) ![]const []const u8 {
+ // 1. Wrap plain text into borrowed slices.
+ var plain: std.ArrayList([]const u8) = .empty;
+ defer plain.deinit(alloc);
+ try wrapBuffer(buffer, width, &plain, alloc);
+
+ // 2. Style each line into a transient owned buffer.
+ var styled: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (styled.items) |s| alloc.free(s);
+ styled.deinit(alloc);
+ }
+ for (plain.items) |line| {
+ // Defensive truncate (wrap already bounds it, but a hard contract).
+ const vis = truncateToCols(line, width);
+ const composed = try std.fmt.allocPrint(alloc, "{s}{s}{s}", .{ style.open(), vis, style.close() });
+ try styled.append(alloc, composed);
+ }
+
+ // 3. Commit to the cache (dupes), then return the cache's owned copy.
+ try cache.store(styled.items);
+ return cacheLines(cache);
+}
+
+/// Re-type the cache's owned `[][]u8` lines as `[]const []const u8` for the
+/// vtable return. The cache guarantees these outlive the call until the next
+/// render/invalidate.
+fn cacheLines(cache: *RenderCache) []const []const u8 {
+ const owned = cache.lines orelse return &.{};
+ return @ptrCast(owned);
+}
+
+// ===========================================================================
+// AssistantText — streaming assistant message (plan §6, §8)
+// ===========================================================================
+
+/// Accumulates assistant content deltas into an internal buffer and renders
+/// the wrapped text with the theme's assistant style.
+///
+/// Streaming-tail dirty model (plan §3.3): a delta is appended to the buffer
+/// and the cache is marked dirty; the engine then requests a render. Because
+/// appended text only changes the LAST wrapped line(s) and leaves earlier
+/// wrapped lines byte-identical, `RenderCache.store`'s diff naturally reports
+/// `firstLineChanged` near the TAIL, not line 0 — the cut stays near the end
+/// during streaming. (There is no per-delta render method on the interface;
+/// the delta just mutates state + dirties, per plan §8.)
+///
+/// Markdown hosting (plan §8, DEFERRED): the buffer/render are structured so a
+/// later pass can cache finished blocks and only re-render the last open block.
+/// For P1 this is plain text + word wrap; the tail-near firstLineChanged
+/// property already holds via the cache diff, so the markdown upgrade slots in
+/// without changing the dirty model.
+pub const AssistantText = struct {
+ alloc: std.mem.Allocator,
+ buffer: std.ArrayList(u8) = .empty,
+ cache: RenderCache,
+
+ pub fn init(alloc: std.mem.Allocator) AssistantText {
+ return .{ .alloc = alloc, .cache = RenderCache.init(alloc) };
+ }
+
+ pub fn deinit(self: *AssistantText) void {
+ self.buffer.deinit(self.alloc);
+ self.cache.deinit();
+ }
+
+ /// Append a streaming content delta. Mutates the buffer and marks the cache
+ /// dirty (the engine will requestRender). The cache diff keeps
+ /// firstLineChanged near the tail.
+ pub fn appendDelta(self: *AssistantText, delta: []const u8) !void {
+ try self.buffer.appendSlice(self.alloc, delta);
+ // markDirtyAppend RETAINS the baseline so the post-render diff recovers
+ // the true tail change point; while dirty it reports a tail hint, so
+ // the engine's cut stays near the end during streaming (plan §3.3/§8).
+ self.cache.markDirtyAppend();
+ }
+
+ /// Replace the whole buffer (e.g. a non-streaming set). Marks dirty.
+ pub fn setText(self: *AssistantText, text: []const u8) !void {
+ self.buffer.clearRetainingCapacity();
+ try self.buffer.appendSlice(self.alloc, text);
+ self.cache.markDirty();
+ }
+
+ fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
+ _ = alloc;
+ const self: *AssistantText = @ptrCast(@alignCast(ptr));
+ return renderStyledLines(&self.cache, self.buffer.items, theme.default.fg(.assistant), width, self.alloc);
+ }
+
+ fn firstLineChangedImpl(ptr: *anyopaque) ?usize {
+ const self: *AssistantText = @ptrCast(@alignCast(ptr));
+ return self.cache.firstLineChanged();
+ }
+
+ fn invalidateImpl(ptr: *anyopaque) void {
+ const self: *AssistantText = @ptrCast(@alignCast(ptr));
+ self.cache.invalidate();
+ }
+
+ const vtable = Component.VTable{
+ .render = renderImpl,
+ .firstLineChanged = firstLineChangedImpl,
+ .invalidate = invalidateImpl,
+ };
+
+ pub fn comp(self: *AssistantText) Component {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+};
+
+// ===========================================================================
+// UserText — submitted user message (plan §6)
+// ===========================================================================
+
+/// A submitted user message, rendered with the theme's user style. Static once
+/// set; `setText` replaces it and marks dirty.
+pub const UserText = struct {
+ alloc: std.mem.Allocator,
+ buffer: std.ArrayList(u8) = .empty,
+ cache: RenderCache,
+
+ pub fn init(alloc: std.mem.Allocator) UserText {
+ return .{ .alloc = alloc, .cache = RenderCache.init(alloc) };
+ }
+
+ pub fn deinit(self: *UserText) void {
+ self.buffer.deinit(self.alloc);
+ self.cache.deinit();
+ }
+
+ /// Set the (static) message text. Marks dirty.
+ pub fn setText(self: *UserText, text: []const u8) !void {
+ self.buffer.clearRetainingCapacity();
+ try self.buffer.appendSlice(self.alloc, text);
+ self.cache.markDirty();
+ }
+
+ fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
+ _ = alloc;
+ const self: *UserText = @ptrCast(@alignCast(ptr));
+ return renderStyledLines(&self.cache, self.buffer.items, theme.default.fg(.user), width, self.alloc);
+ }
+
+ fn firstLineChangedImpl(ptr: *anyopaque) ?usize {
+ const self: *UserText = @ptrCast(@alignCast(ptr));
+ return self.cache.firstLineChanged();
+ }
+
+ fn invalidateImpl(ptr: *anyopaque) void {
+ const self: *UserText = @ptrCast(@alignCast(ptr));
+ self.cache.invalidate();
+ }
+
+ const vtable = Component.VTable{
+ .render = renderImpl,
+ .firstLineChanged = firstLineChangedImpl,
+ .invalidate = invalidateImpl,
+ };
+
+ pub fn comp(self: *UserText) Component {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+};
+
+// ===========================================================================
+// InputBox — editable single-row+ input (plan §6, §3.5)
+// ===========================================================================
+
+/// A `Focusable` editor. Single row by default; ENTER submits, SHIFT+ENTER
+/// inserts a newline (grows one row per line). Growth is UNBOUNDED in P1 (the
+/// cap + scroll-window is deferred to P2).
+///
+/// Editing (raw keys via `handleInput`, decoded by `tui_input`):
+/// - printable chars (UTF-8) insert at the cursor
+/// - backspace deletes the codepoint before the cursor
+/// - delete removes the codepoint at the cursor
+/// - left/right move by one codepoint; home/end jump to start/end of the
+/// current visual buffer (P1: whole buffer, not per-line)
+/// - ENTER submits the whole buffer (see "Submit mechanism")
+/// - SHIFT+ENTER inserts a '\n'
+///
+/// Cursor (plan §3.5): the box draws its OWN cursor as a reverse-video block
+/// (theme `.cursor` style) over the glyph at the cursor position. When focused
+/// it also emits `CURSOR_MARKER` (zero visible width) at the cursor location in
+/// its render output, so the engine can later position the hardware cursor.
+///
+/// SHIFT+ENTER limitation: on terminals without the Kitty protocol, Enter and
+/// Shift+Enter send identical bytes (`\r`); the decoder cannot distinguish them
+/// and both arrive as `.enter` with no shift modifier, so only plain submit is
+/// possible there. When Kitty IS active, Shift+Enter arrives as CSI-u
+/// (`\x1b[13;2u`) with `mods.shift` set and this box inserts a newline. The
+/// logic works wherever the distinction is available.
+///
+/// Submit mechanism: a POLLABLE buffer. On ENTER the current editor contents
+/// are moved into `submitted` and the editor is cleared. The app calls
+/// `takeSubmitted()` once per frame; it returns the submitted bytes (owned by
+/// the box, valid until the next `takeSubmitted`/edit) and clears the pending
+/// flag, or null if nothing was submitted. This avoids callback re-entrancy
+/// into the render loop.
+pub const InputBox = struct {
+ alloc: std.mem.Allocator,
+ focusable: Focusable = .{},
+ /// Editor contents (may contain '\n' for multi-line input). UTF-8.
+ text: std.ArrayList(u8) = .empty,
+ /// Cursor position as a BYTE offset into `text` (always on a codepoint
+ /// boundary).
+ cursor: usize = 0,
+ /// Pending submitted line, owned by the box. Valid until the next submit
+ /// or `takeSubmitted`.
+ submitted: std.ArrayList(u8) = .empty,
+ has_submitted: bool = false,
+ cache: RenderCache,
+
+ pub fn init(alloc: std.mem.Allocator) InputBox {
+ return .{ .alloc = alloc, .cache = RenderCache.init(alloc) };
+ }
+
+ pub fn deinit(self: *InputBox) void {
+ self.text.deinit(self.alloc);
+ self.submitted.deinit(self.alloc);
+ self.cache.deinit();
+ }
+
+ // -- focus -------------------------------------------------------------
+
+ /// Set focus. Re-dirties because the cursor block + marker only render when
+ /// focused, so focus changes alter the output.
+ pub fn setFocused(self: *InputBox, value: bool) void {
+ if (self.focusable.focused != value) {
+ self.focusable.setFocused(value);
+ self.cache.markDirty();
+ }
+ }
+
+ pub fn isFocused(self: *const InputBox) bool {
+ return self.focusable.focused;
+ }
+
+ // -- submit polling ----------------------------------------------------
+
+ /// Poll the submitted line. Returns the bytes (box-owned) and clears the
+ /// pending flag, or null if nothing was submitted since the last poll.
+ pub fn takeSubmitted(self: *InputBox) ?[]const u8 {
+ if (!self.has_submitted) return null;
+ self.has_submitted = false;
+ return self.submitted.items;
+ }
+
+ // -- editing primitives (also directly unit-testable) ------------------
+
+ fn insertText(self: *InputBox, bytes: []const u8) !void {
+ try self.text.insertSlice(self.alloc, self.cursor, bytes);
+ self.cursor += bytes.len;
+ self.cache.markDirty();
+ }
+
+ fn backspace(self: *InputBox) void {
+ if (self.cursor == 0) return;
+ const start = self.prevBoundary(self.cursor);
+ const removed = self.cursor - start;
+ std.mem.copyForwards(u8, self.text.items[start..], self.text.items[self.cursor..]);
+ self.text.items.len -= removed;
+ self.cursor = start;
+ self.cache.markDirty();
+ }
+
+ fn deleteForward(self: *InputBox) void {
+ if (self.cursor >= self.text.items.len) return;
+ const next = self.nextBoundary(self.cursor);
+ const removed = next - self.cursor;
+ std.mem.copyForwards(u8, self.text.items[self.cursor..], self.text.items[next..]);
+ self.text.items.len -= removed;
+ self.cache.markDirty();
+ }
+
+ fn moveLeft(self: *InputBox) void {
+ if (self.cursor == 0) return;
+ self.cursor = self.prevBoundary(self.cursor);
+ self.cache.markDirty();
+ }
+
+ fn moveRight(self: *InputBox) void {
+ if (self.cursor >= self.text.items.len) return;
+ self.cursor = self.nextBoundary(self.cursor);
+ self.cache.markDirty();
+ }
+
+ fn moveHome(self: *InputBox) void {
+ if (self.cursor == 0) return;
+ self.cursor = 0;
+ self.cache.markDirty();
+ }
+
+ fn moveEnd(self: *InputBox) void {
+ if (self.cursor == self.text.items.len) return;
+ self.cursor = self.text.items.len;
+ self.cache.markDirty();
+ }
+
+ fn submit(self: *InputBox) !void {
+ self.submitted.clearRetainingCapacity();
+ try self.submitted.appendSlice(self.alloc, self.text.items);
+ self.has_submitted = true;
+ self.text.clearRetainingCapacity();
+ self.cursor = 0;
+ self.cache.markDirty();
+ }
+
+ /// Byte index of the codepoint boundary before `i` (i > 0).
+ fn prevBoundary(self: *const InputBox, i: usize) usize {
+ var j = i - 1;
+ while (j > 0 and isContinuation(self.text.items[j])) : (j -= 1) {}
+ return j;
+ }
+
+ /// Byte index of the next codepoint boundary after `i` (i < len).
+ fn nextBoundary(self: *const InputBox, i: usize) usize {
+ const seq_len = std.unicode.utf8ByteSequenceLength(self.text.items[i]) catch 1;
+ return @min(i + seq_len, self.text.items.len);
+ }
+
+ fn isContinuation(b: u8) bool {
+ return (b & 0xc0) == 0x80;
+ }
+
+ // -- input handling ----------------------------------------------------
+
+ /// Apply one decoded key. Split out so tests can drive editing without raw
+ /// byte sequences.
+ pub fn applyKey(self: *InputBox, k: Key) !void {
+ if (k.event == .release) return;
+ switch (k.code) {
+ .char => {
+ if (k.mods.ctrl or k.mods.alt or k.mods.super) return; // not a printable insert
+ if (k.text) |t| {
+ try self.insertText(t);
+ } else {
+ // Encode the codepoint ourselves when no text was carried.
+ var buf: [4]u8 = undefined;
+ const n = std.unicode.utf8Encode(k.code.char, &buf) catch return;
+ try self.insertText(buf[0..n]);
+ }
+ },
+ .enter => {
+ if (k.mods.shift) {
+ try self.insertText("\n"); // shift+enter newline
+ } else {
+ try self.submit();
+ }
+ },
+ .backspace => self.backspace(),
+ .delete => self.deleteForward(),
+ .left => self.moveLeft(),
+ .right => self.moveRight(),
+ .home => self.moveHome(),
+ .end => self.moveEnd(),
+ else => {}, // tab, arrows up/down, fkeys: ignored in P1
+ }
+ }
+
+ fn handleInputImpl(ptr: *anyopaque, data: []const u8) void {
+ const self: *InputBox = @ptrCast(@alignCast(ptr));
+ var off: usize = 0;
+ while (off < data.len) {
+ const step = input.decodeOne(data[off..]) orelse break; // partial tail: drop in P1
+ off += step.consumed;
+ switch (step.decoded) {
+ .key => |k| self.applyKey(k) catch return,
+ .paste => |p| self.insertText(p) catch return,
+ }
+ }
+ }
+
+ // -- render ------------------------------------------------------------
+
+ fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
+ _ = alloc;
+ const self: *InputBox = @ptrCast(@alignCast(ptr));
+ return self.renderLines(width);
+ }
+
+ /// Render the editor: split on '\n' into visual rows, place the styled
+ /// cursor block + CURSOR_MARKER at the cursor row/column when focused.
+ /// Truncates each row to `width` columns.
+ fn renderLines(self: *InputBox, width: usize) ![]const []const u8 {
+ const a = self.alloc;
+ var rows: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (rows.items) |r| a.free(r);
+ rows.deinit(a);
+ }
+
+ const cursor_style = theme.default.fg(.cursor);
+ // Locate the cursor's (row, byte-col-in-row).
+ const focused = self.focusable.focused;
+
+ // Walk lines, tracking byte offset so we know which row holds cursor.
+ var line_byte_start: usize = 0;
+ var produced_any = false;
+ var it = std.mem.splitScalar(u8, self.text.items, '\n');
+ while (it.next()) |line| {
+ const line_start = line_byte_start;
+ const line_end = line_start + line.len;
+ const cursor_in_line = focused and self.cursor >= line_start and self.cursor <= line_end and
+ // The cursor belongs to the FIRST line whose range contains it
+ // (at a '\n' boundary it stays on the line before the break,
+ // i.e. == line_end). Disambiguate the boundary: if cursor ==
+ // line_end and there are more lines, it belongs to the NEXT
+ // line's start unless this is the last line.
+ (self.cursor < line_end or it.peek() == null);
+
+ const row = try self.renderRow(line, if (cursor_in_line) self.cursor - line_start else null, cursor_style, width, focused);
+ try rows.append(a, row);
+ produced_any = true;
+ line_byte_start = line_end + 1; // skip the '\n'
+ }
+ if (!produced_any) {
+ // Empty buffer: a single (possibly cursor-bearing) row.
+ const row = try self.renderRow("", if (focused) @as(?usize, 0) else null, cursor_style, width, focused);
+ try rows.append(a, row);
+ }
+
+ try self.cache.store(rows.items);
+ return cacheLines(&self.cache);
+ }
+
+ /// Render one visual row. `cursor_col` is the byte offset within `line`
+ /// where the cursor sits (null if the cursor isn't on this row). When
+ /// present and focused, draws a reverse-video block over the glyph at the
+ /// cursor (or a space at end-of-line) and emits CURSOR_MARKER there.
+ fn renderRow(self: *InputBox, line: []const u8, cursor_col: ?usize, cursor_style: Style, width: usize, focused: bool) ![]u8 {
+ const a = self.alloc;
+ // The cursor block consumes one visible column, so usable text width
+ // is width-1 when the cursor sits at/after the truncated end and we
+ // must show the block. To keep it simple and always-safe: truncate the
+ // plain line to `width` columns; if a cursor block would push us to
+ // width+1, the block replaces the last column instead.
+ const vis = truncateToCols(line, width);
+
+ var buf: std.ArrayList(u8) = .empty;
+ errdefer buf.deinit(a);
+
+ if (cursor_col == null or !focused) {
+ try buf.appendSlice(a, vis);
+ return buf.toOwnedSlice(a);
+ }
+
+ // Cursor is on this row. Find the byte position within `vis`.
+ const cc = cursor_col.?;
+ const before_cols = displayWidth(line[0..@min(cc, line.len)]);
+
+ if (cc >= line.len) {
+ // Cursor at end-of-line: block over a trailing space. Ensure room:
+ // if the visible text already fills `width`, drop its last column.
+ var head = vis;
+ if (before_cols >= width) {
+ head = truncateToCols(line, width - 1);
+ }
+ try buf.appendSlice(a, head);
+ try buf.appendSlice(a, CURSOR_MARKER);
+ try buf.appendSlice(a, cursor_style.open());
+ try buf.appendSlice(a, " ");
+ try buf.appendSlice(a, cursor_style.close());
+ return buf.toOwnedSlice(a);
+ }
+
+ // Cursor over an interior glyph. Split: head | glyph | tail.
+ const glyph_len = blk: {
+ const sl = std.unicode.utf8ByteSequenceLength(line[cc]) catch 1;
+ break :blk @min(sl, line.len - cc);
+ };
+ const head = line[0..cc];
+ const glyph = line[cc .. cc + glyph_len];
+ const tail = line[cc + glyph_len ..];
+
+ // Compose head + marker + [reverse]glyph[/] + tail, then truncate the
+ // whole visible width to `width` columns (escapes + marker are
+ // zero-width, so truncation acts on glyphs).
+ try buf.appendSlice(a, head);
+ try buf.appendSlice(a, CURSOR_MARKER);
+ try buf.appendSlice(a, cursor_style.open());
+ try buf.appendSlice(a, glyph);
+ try buf.appendSlice(a, cursor_style.close());
+ try buf.appendSlice(a, tail);
+
+ // The composed row's visible width == displayWidth(line). If that
+ // exceeds `width`, rebuild with a width-bounded tail. (Rare: cursor
+ // near a long line's start.) Simpler safe path: if over, truncate the
+ // tail.
+ if (displayWidth(line) > width) {
+ buf.clearRetainingCapacity();
+ // Keep head+glyph; truncate tail to remaining columns.
+ const used = before_cols + 1; // head cols + the glyph
+ try buf.appendSlice(a, head);
+ try buf.appendSlice(a, CURSOR_MARKER);
+ try buf.appendSlice(a, cursor_style.open());
+ try buf.appendSlice(a, glyph);
+ try buf.appendSlice(a, cursor_style.close());
+ if (used < width) {
+ const remaining = width - used;
+ try buf.appendSlice(a, truncateToCols(tail, remaining));
+ }
+ }
+ return buf.toOwnedSlice(a);
+ }
+
+ fn firstLineChangedImpl(ptr: *anyopaque) ?usize {
+ const self: *InputBox = @ptrCast(@alignCast(ptr));
+ return self.cache.firstLineChanged();
+ }
+
+ fn invalidateImpl(ptr: *anyopaque) void {
+ const self: *InputBox = @ptrCast(@alignCast(ptr));
+ self.cache.invalidate();
+ }
+
+ const vtable = Component.VTable{
+ .render = renderImpl,
+ .firstLineChanged = firstLineChangedImpl,
+ .invalidate = invalidateImpl,
+ .handleInput = handleInputImpl,
+ };
+
+ pub fn comp(self: *InputBox) Component {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+};
+
+// ===========================================================================
+// Footer — persistent bottom line with frame-timing element (plan §6)
+// ===========================================================================
+
+/// The persistent bottom line. For P1 it renders a FRAME-TIMING element: the
+/// last frame's render time as a theoretical-max fps (1000/ms), shown inverted
+/// (reverse-video). It optionally shows model info passed in by the app. The
+/// fps element is TEMPORARY (removed after perf validation) but REQUIRED for
+/// P1.
+///
+/// Frame-time input: the app calls `setFrameTime(ms)` after each rendered frame
+/// with the measured render duration in milliseconds; this updates the fps and
+/// marks dirty so the footer repaints. `setModel(name)` sets the model info.
+pub const Footer = struct {
+ alloc: std.mem.Allocator,
+ cache: RenderCache,
+ /// Last frame's render time in milliseconds (null = not measured yet).
+ frame_ms: ?f64 = null,
+ /// Model info string (borrowed; copied into a small owned buffer on set).
+ model: std.ArrayList(u8) = .empty,
+
+ pub fn init(alloc: std.mem.Allocator) Footer {
+ return .{ .alloc = alloc, .cache = RenderCache.init(alloc) };
+ }
+
+ pub fn deinit(self: *Footer) void {
+ self.model.deinit(self.alloc);
+ self.cache.deinit();
+ }
+
+ /// Feed the last frame's render time (milliseconds). Marks dirty so the
+ /// footer's fps element repaints next frame.
+ pub fn setFrameTime(self: *Footer, ms: f64) void {
+ self.frame_ms = ms;
+ self.cache.markDirty();
+ }
+
+ /// Set the model info shown in the footer.
+ pub fn setModel(self: *Footer, name: []const u8) !void {
+ self.model.clearRetainingCapacity();
+ try self.model.appendSlice(self.alloc, name);
+ self.cache.markDirty();
+ }
+
+ /// Format the theoretical-max fps element from the last frame time.
+ /// `fps = 1000 / ms`; a zero/sub-millisecond frame is reported as a capped
+ /// ">9999" sentinel rather than infinity. "--" when unmeasured.
+ fn fpsText(self: *const Footer, buf: []u8) []const u8 {
+ const ms = self.frame_ms orelse return std.fmt.bufPrint(buf, "fps: --", .{}) catch "fps: --";
+ if (ms <= 0.0) return std.fmt.bufPrint(buf, "fps: >9999", .{}) catch "fps: >9999";
+ const fps = 1000.0 / ms;
+ if (fps > 9999.0) return std.fmt.bufPrint(buf, "fps: >9999", .{}) catch "fps: >9999";
+ return std.fmt.bufPrint(buf, "fps: {d:.0} ({d:.2}ms)", .{ fps, ms }) catch "fps: ?";
+ }
+
+ fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
+ _ = alloc;
+ const self: *Footer = @ptrCast(@alignCast(ptr));
+ const a = self.alloc;
+
+ var fps_buf: [48]u8 = undefined;
+ const fps = self.fpsText(&fps_buf);
+
+ // Build the PLAIN content: "<fps> <model>" (model only if set).
+ var plain: std.ArrayList(u8) = .empty;
+ defer plain.deinit(a);
+ try plain.appendSlice(a, fps);
+ if (self.model.items.len != 0) {
+ try plain.appendSlice(a, " ");
+ try plain.appendSlice(a, self.model.items);
+ }
+
+ const vis = truncateToCols(plain.items, width);
+
+ // The fps element is shown INVERTED (reverse video). The whole footer
+ // line uses reverse video so the timing element stands out; the model
+ // rides along in the same inverted run. (Temporary perf chrome.)
+ const cursor_style = theme.default.fg(.cursor); // reverse video
+ const composed = try std.fmt.allocPrint(a, "{s}{s}{s}", .{ cursor_style.open(), vis, cursor_style.close() });
+ defer a.free(composed);
+
+ const lines = [_][]const u8{composed};
+ try self.cache.store(&lines);
+ return cacheLines(&self.cache);
+ }
+
+ fn firstLineChangedImpl(ptr: *anyopaque) ?usize {
+ const self: *Footer = @ptrCast(@alignCast(ptr));
+ return self.cache.firstLineChanged();
+ }
+
+ fn invalidateImpl(ptr: *anyopaque) void {
+ const self: *Footer = @ptrCast(@alignCast(ptr));
+ self.cache.invalidate();
+ }
+
+ const vtable = Component.VTable{
+ .render = renderImpl,
+ .firstLineChanged = firstLineChangedImpl,
+ .invalidate = invalidateImpl,
+ };
+
+ pub fn comp(self: *Footer) Component {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+};
+
+// ===========================================================================
+// Tests
+// ===========================================================================
+
+const testing = std.testing;
+const engine = @import("tui_engine.zig");
+
+/// Visible width of a rendered (possibly styled, possibly marker-bearing) line,
+/// reusing the engine's authoritative measure.
+fn vw(line: []const u8) usize {
+ return engine.visibleWidth(line);
+}
+
+// -- helpers ---------------------------------------------------------------
+
+test "displayWidth counts codepoints; truncateToCols respects boundaries" {
+ try testing.expectEqual(@as(usize, 3), displayWidth("abc"));
+ try testing.expectEqual(@as(usize, 3), displayWidth("aé✓"));
+ try testing.expectEqualStrings("aé", truncateToCols("aé✓", 2));
+ try testing.expectEqualStrings("abc", truncateToCols("abcdef", 3));
+ // Never splits a multibyte codepoint.
+ const t = truncateToCols("é", 1);
+ try testing.expectEqualStrings("é", t);
+}
+
+test "wrapParagraph word-wraps and hard-splits long words" {
+ var out: std.ArrayList([]const u8) = .empty;
+ defer out.deinit(testing.allocator);
+ try wrapParagraph("hello world foo", 7, &out, testing.allocator);
+ try testing.expectEqual(@as(usize, 3), out.items.len);
+ try testing.expectEqualStrings("hello", out.items[0]);
+ try testing.expectEqualStrings("world", out.items[1]);
+ try testing.expectEqualStrings("foo", out.items[2]);
+
+ out.clearRetainingCapacity();
+ try wrapParagraph("abcdefghij", 4, &out, testing.allocator);
+ try testing.expectEqual(@as(usize, 3), out.items.len);
+ try testing.expectEqualStrings("abcd", out.items[0]);
+ try testing.expectEqualStrings("efgh", out.items[1]);
+ try testing.expectEqualStrings("ij", out.items[2]);
+}
+
+// -- AssistantText ---------------------------------------------------------
+
+test "AssistantText: renders wrapped text within width" {
+ var at = AssistantText.init(testing.allocator);
+ defer at.deinit();
+ try at.setText("hello world foo");
+ const lines = try at.comp().render(7, testing.allocator);
+ try testing.expectEqual(@as(usize, 3), lines.len);
+ for (lines) |l| try testing.expect(vw(l) <= 7);
+ // First render after empty cache => changed from 0.
+ try testing.expectEqual(@as(?usize, 0), at.comp().firstLineChanged());
+}
+
+test "AssistantText: streaming keeps firstLineChanged near the tail, not 0" {
+ var at = AssistantText.init(testing.allocator);
+ defer at.deinit();
+ // Seed several wrapped lines.
+ try at.setText("alpha beta gamma delta epsilon");
+ _ = try at.comp().render(11, testing.allocator);
+ const lines1 = at.cache.lines.?.len;
+ try testing.expect(lines1 >= 3);
+
+ // Append a delta to the tail; earlier wrapped lines stay byte-identical.
+ try at.appendDelta(" zeta");
+ // While dirty, firstLineChanged reports 0 (full markDirty). The KEY
+ // property is what the cache reports AFTER the render: the lowest line that
+ // actually changed must be near the tail, not 0.
+ _ = try at.comp().render(11, testing.allocator);
+ const fc = at.comp().firstLineChanged();
+ // The change landed on the last line(s); the cut must be > 0.
+ try testing.expect(fc == null or fc.? > 0);
+ // Stronger: the first changed line should be at the tail region.
+ if (fc) |v| try testing.expect(v >= lines1 - 1);
+}
+
+test "AssistantText: width truncation on a long unbroken word" {
+ var at = AssistantText.init(testing.allocator);
+ defer at.deinit();
+ try at.setText("supercalifragilistic");
+ const lines = try at.comp().render(5, testing.allocator);
+ for (lines) |l| try testing.expect(vw(l) <= 5);
+}
+
+// -- UserText ---------------------------------------------------------------
+
+test "UserText: renders user-styled lines within width" {
+ var ut = UserText.init(testing.allocator);
+ defer ut.deinit();
+ try ut.setText("a user message that wraps");
+ const lines = try ut.comp().render(10, testing.allocator);
+ for (lines) |l| try testing.expect(vw(l) <= 10);
+ // Static: re-render with no change => clean.
+ _ = try ut.comp().render(10, testing.allocator);
+ try testing.expectEqual(@as(?usize, null), ut.comp().firstLineChanged());
+}
+
+// -- InputBox ---------------------------------------------------------------
+
+fn charKey(c: u21, text: []const u8) Key {
+ return .{ .code = .{ .char = c }, .text = text };
+}
+
+test "InputBox: insert printable chars and render with cursor block when focused" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ ib.setFocused(true);
+ try ib.applyKey(charKey('h', "h"));
+ try ib.applyKey(charKey('i', "i"));
+ const lines = try ib.comp().render(20, testing.allocator);
+ try testing.expectEqual(@as(usize, 1), lines.len);
+ try testing.expect(vw(lines[0]) <= 20);
+ // Focused => emits CURSOR_MARKER and reverse-video style.
+ try testing.expect(std.mem.indexOf(u8, lines[0], CURSOR_MARKER) != null);
+ try testing.expect(std.mem.indexOf(u8, lines[0], "\x1b[7m") != null);
+ try testing.expect(std.mem.indexOf(u8, lines[0], "hi") != null);
+}
+
+test "InputBox: not focused emits no cursor marker" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ try ib.applyKey(charKey('x', "x"));
+ const lines = try ib.comp().render(20, testing.allocator);
+ try testing.expect(std.mem.indexOf(u8, lines[0], CURSOR_MARKER) == null);
+}
+
+test "InputBox: backspace, delete, and cursor movement" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ for ("abc") |c| try ib.applyKey(charKey(c, &[_]u8{c}));
+ try testing.expectEqual(@as(usize, 3), ib.cursor);
+ ib.backspace(); // "ab"
+ try testing.expectEqualStrings("ab", ib.text.items);
+ try testing.expectEqual(@as(usize, 2), ib.cursor);
+ ib.moveLeft(); // cursor at 1
+ try testing.expectEqual(@as(usize, 1), ib.cursor);
+ ib.deleteForward(); // delete 'b' -> "a"
+ try testing.expectEqualStrings("a", ib.text.items);
+ ib.moveHome();
+ try testing.expectEqual(@as(usize, 0), ib.cursor);
+ ib.moveEnd();
+ try testing.expectEqual(@as(usize, 1), ib.cursor);
+}
+
+test "InputBox: multibyte backspace removes a whole codepoint" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ try ib.applyKey(charKey('é', "é")); // 2 bytes
+ try testing.expectEqual(@as(usize, 2), ib.cursor);
+ ib.backspace();
+ try testing.expectEqual(@as(usize, 0), ib.text.items.len);
+}
+
+test "InputBox: shift+enter inserts newline, enter submits" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ for ("ab") |c| try ib.applyKey(charKey(c, &[_]u8{c}));
+ // Shift+Enter => newline (grows a row).
+ try ib.applyKey(.{ .code = .enter, .mods = .{ .shift = true } });
+ for ("cd") |c| try ib.applyKey(charKey(c, &[_]u8{c}));
+ try testing.expectEqualStrings("ab\ncd", ib.text.items);
+ const lines = try ib.comp().render(20, testing.allocator);
+ try testing.expectEqual(@as(usize, 2), lines.len);
+
+ // Plain Enter => submit, editor cleared, pollable buffer set.
+ try ib.applyKey(.{ .code = .enter });
+ const got = ib.takeSubmitted();
+ try testing.expect(got != null);
+ try testing.expectEqualStrings("ab\ncd", got.?);
+ try testing.expectEqual(@as(usize, 0), ib.text.items.len);
+ // Second poll returns null.
+ try testing.expect(ib.takeSubmitted() == null);
+}
+
+test "InputBox: handleInput decodes raw bytes (typing + enter)" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ ib.comp().handleInput("hi\r"); // 'h' 'i' Enter
+ const got = ib.takeSubmitted();
+ try testing.expect(got != null);
+ try testing.expectEqualStrings("hi", got.?);
+}
+
+test "InputBox: handleInput kitty shift+enter inserts newline" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ ib.comp().handleInput("a\x1b[13;2ub"); // 'a', shift+enter, 'b'
+ try testing.expectEqualStrings("a\nb", ib.text.items);
+ try testing.expect(ib.takeSubmitted() == null); // no plain enter yet
+}
+
+test "InputBox: firstLineChanged is cache-derived (clean after stable render)" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ ib.setFocused(true);
+ try ib.applyKey(charKey('x', "x"));
+ _ = try ib.comp().render(20, testing.allocator);
+ // Re-render with no state change => clean.
+ _ = try ib.comp().render(20, testing.allocator);
+ try testing.expectEqual(@as(?usize, null), ib.comp().firstLineChanged());
+ // Edit => dirty again.
+ try ib.applyKey(charKey('y', "y"));
+ try testing.expectEqual(@as(?usize, 0), ib.comp().firstLineChanged());
+}
+
+test "InputBox: cursor block fits within width at end of a full line" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ ib.setFocused(true);
+ for ("abcde") |c| try ib.applyKey(charKey(c, &[_]u8{c}));
+ // width 5, cursor at end: the block must not overflow.
+ const lines = try ib.comp().render(5, testing.allocator);
+ try testing.expect(vw(lines[0]) <= 5);
+}
+
+// -- Footer -----------------------------------------------------------------
+
+test "Footer: renders fps from frame time, inverted, within width" {
+ var ft = Footer.init(testing.allocator);
+ defer ft.deinit();
+ ft.setFrameTime(8.0); // 1000/8 = 125 fps
+ const lines = try ft.comp().render(80, testing.allocator);
+ try testing.expectEqual(@as(usize, 1), lines.len);
+ try testing.expect(vw(lines[0]) <= 80);
+ // Inverted (reverse video) styling present.
+ try testing.expect(std.mem.indexOf(u8, lines[0], "\x1b[7m") != null);
+ // fps value 125 present.
+ try testing.expect(std.mem.indexOf(u8, lines[0], "125") != null);
+}
+
+test "Footer: unmeasured frame shows placeholder; submillisecond capped" {
+ var ft = Footer.init(testing.allocator);
+ defer ft.deinit();
+ var buf: [48]u8 = undefined;
+ try testing.expectEqualStrings("fps: --", ft.fpsText(&buf));
+ ft.setFrameTime(0.0);
+ try testing.expectEqualStrings("fps: >9999", ft.fpsText(&buf));
+ ft.setFrameTime(0.05); // 20000 fps -> capped
+ try testing.expectEqualStrings("fps: >9999", ft.fpsText(&buf));
+}
+
+test "Footer: shows model info and truncates to width" {
+ var ft = Footer.init(testing.allocator);
+ defer ft.deinit();
+ ft.setFrameTime(10.0);
+ try ft.setModel("gpt-test-model");
+ const lines = try ft.comp().render(12, testing.allocator);
+ try testing.expect(vw(lines[0]) <= 12);
+}
+
+test "Footer: setFrameTime dirties; stable re-render is clean" {
+ var ft = Footer.init(testing.allocator);
+ defer ft.deinit();
+ ft.setFrameTime(8.0);
+ _ = try ft.comp().render(80, testing.allocator);
+ _ = try ft.comp().render(80, testing.allocator);
+ try testing.expectEqual(@as(?usize, null), ft.comp().firstLineChanged());
+ ft.setFrameTime(16.0);
+ try testing.expectEqual(@as(?usize, 0), ft.comp().firstLineChanged());
+}
+
+// -- Integration with the real Engine (no TTY) ------------------------------
+
+test "components drive the real engine without a TTY" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = engine.Engine.init(testing.allocator, &buf.writer, 40, 24, false);
+ defer eng.deinit();
+
+ var user = UserText.init(testing.allocator);
+ defer user.deinit();
+ var assistant = AssistantText.init(testing.allocator);
+ defer assistant.deinit();
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ var footer = Footer.init(testing.allocator);
+ defer footer.deinit();
+
+ try user.setText("hi there");
+ try assistant.appendDelta("hello");
+ ib.setFocused(true);
+ try ib.applyKey(charKey('q', "q"));
+ footer.setFrameTime(8.0);
+
+ try eng.addComponent(user.comp());
+ try eng.addComponent(assistant.comp());
+ try eng.addComponent(ib.comp());
+ try eng.addComponent(footer.comp());
+
+ try eng.render(); // first paint: must not error (width contract holds)
+ const out = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out, "hi there") != null);
+ try testing.expect(std.mem.indexOf(u8, out, "hello") != null);
+ // Cursor marker is consumed by the engine and recorded as a hint.
+ try testing.expect(eng.cursor_hint != null);
+
+ // Stream another delta -> only the assistant should re-render; the engine
+ // stays on the differential path (no full clear after first paint).
+ try assistant.appendDelta(" world");
+ footer.setFrameTime(9.0);
+ buf.clearRetainingCapacity();
+ try eng.render();
+ const out2 = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out2, "world") != null);
+}
diff --git a/src/tui_engine.zig b/src/tui_engine.zig
new file mode 100644
index 0000000..fbe8474
--- /dev/null
+++ b/src/tui_engine.zig
@@ -0,0 +1,1116 @@
+//! The differential render engine for the TUI (plan §3).
+//!
+//! The engine owns a LIST of live `Component`s (plan invariant: there is NO
+//! single "active component" — multiple tool calls render in parallel later,
+//! so the engine always walks a list, even when the list has one element).
+//! Each frame it walks the list top-to-bottom, asks each component to
+//! `render(width)` into lines, and uses `firstLineChanged` plus an old-vs-new
+//! line diff to repaint only what changed.
+//!
+//! Output is abstracted behind a `*std.Io.Writer` sink so the engine is
+//! unit-testable without a real TTY: tests inject an in-memory writer and
+//! assert on the emitted bytes. The real terminal is one implementation of the
+//! sink (a `Terminal`-backed writer, wired in the app sub-phase).
+//!
+//! ## The render passes (plan §3.3)
+//!
+//! A single top-to-bottom walk:
+//! 1. Render each component (or reuse its cached lines) and accumulate a
+//! global line offset for each.
+//! 2. Compute `cut = min(offset_i + firstLineChanged_i)` across ALL
+//! components whose `firstLineChanged` is non-null. NOT just the first
+//! dirty component: a ticking footer below an otherwise-clean transcript
+//! is the counter-example.
+//! 3. Reprint from `cut` downward. Components fully above the cut are
+//! untouched. The component owning the cut and any dirty component below
+//! re-render from their local `firstLineChanged`. CLEAN components below
+//! the cut reuse their CACHED lines verbatim (reprinted because they sit
+//! below the rolled-back point, but never re-rendered — no component CPU).
+//! 4. Length deltas: when a component returns fewer lines than before, the
+//! orphaned trailing lines are cleared and offsets below recomputed.
+//! 5. Line-diff backstop: from `cut` downward we still diff old-vs-new
+//! lines. `firstLineChanged` decides WHERE re-rendering starts (the fast
+//! path); the diff is the CORRECTNESS FLOOR that defends against an
+//! inaccurate signal and handles length deltas.
+//!
+//! ## Viewport & scrollback (plan §3.2)
+//!
+//! Lines above `viewport_top` have scrolled into the terminal's NATIVE
+//! scrollback and are off-limits to differential updates. If a change lands
+//! above `viewport_top`, the engine falls back to a full redraw. We never
+//! implement our own scrollback — content that grows past the top scrolls up
+//! into the real terminal scrollback.
+//!
+//! ## Output discipline (plan §3.1)
+//!
+//! Every frame is wrapped in synchronized output. A full redraw happens ONLY
+//! when forced (first paint, width change, height change, or a change above
+//! `viewport_top`) and clears scrollback; ordinary frames NEVER clear
+//! scrollback.
+
+const std = @import("std");
+const component = @import("tui_component.zig");
+const terminal = @import("tui_terminal.zig");
+
+const Component = component.Component;
+const CURSOR_MARKER = component.CURSOR_MARKER;
+
+pub const Error = error{
+ /// A component returned a line whose visible width exceeds the render
+ /// width. Components must truncate; the engine treats overflow as a hard
+ /// error (plan §3.1). The caller is expected to restore the terminal and
+ /// surface a diagnostic.
+ LineOverflow,
+} || std.mem.Allocator.Error || std.Io.Writer.Error;
+
+/// Visible (display-column) width of a rendered line, ignoring escape
+/// sequences and the zero-width cursor marker.
+///
+/// P1 scope: strips CSI (`ESC [ ... final`), SS2/SS3 (`ESC N`/`ESC O` + 1
+/// byte), OSC (`ESC ] ... BEL|ST`), and APC/PM/DCS-style strings (`ESC _`/`ESC
+/// ^`/`ESC P` ... `ST`) — the last covers `CURSOR_MARKER`. Remaining bytes are
+/// counted as UTF-8 codepoints, one column each. Wide-character (CJK/emoji)
+/// width is a documented P1 approximation; refining it is deferred.
+pub fn visibleWidth(line: []const u8) usize {
+ var cols: usize = 0;
+ var i: usize = 0;
+ while (i < line.len) {
+ const b = line[i];
+ if (b == 0x1b) {
+ i += skipEscape(line[i..]);
+ continue;
+ }
+ // Count one column per UTF-8 codepoint start byte.
+ const seq_len = std.unicode.utf8ByteSequenceLength(b) catch 1;
+ cols += 1;
+ i += @min(seq_len, line.len - i);
+ }
+ return cols;
+}
+
+/// Returns the number of bytes the escape sequence at the start of `s`
+/// (s[0] == ESC) occupies. `s` is guaranteed non-empty with s[0] == 0x1b.
+fn skipEscape(s: []const u8) usize {
+ if (s.len < 2) return s.len; // lone trailing ESC
+ switch (s[1]) {
+ '[' => {
+ // CSI: params/intermediates until a final byte 0x40..0x7e.
+ var i: usize = 2;
+ while (i < s.len) : (i += 1) {
+ if (s[i] >= 0x40 and s[i] <= 0x7e) return i + 1;
+ }
+ return s.len;
+ },
+ ']' => {
+ // OSC: terminated by BEL or ST (ESC \).
+ var i: usize = 2;
+ while (i < s.len) : (i += 1) {
+ if (s[i] == 0x07) return i + 1;
+ if (s[i] == 0x1b and i + 1 < s.len and s[i + 1] == '\\') return i + 2;
+ }
+ return s.len;
+ },
+ '_', '^', 'P' => {
+ // APC / PM / DCS: terminated by ST (ESC \). Covers CURSOR_MARKER.
+ var i: usize = 2;
+ while (i < s.len) : (i += 1) {
+ if (s[i] == 0x1b and i + 1 < s.len and s[i + 1] == '\\') return i + 2;
+ }
+ return s.len;
+ },
+ 'N', 'O' => {
+ // SS2 / SS3: one following byte.
+ return @min(@as(usize, 3), s.len);
+ },
+ else => {
+ // ESC + single byte (e.g. ESC c) — consume both.
+ return 2;
+ },
+ }
+}
+
+/// Strip the cursor marker from `line` into `out`, returning the written
+/// slice. P1 only removes the marker so it never prints; locating it for
+/// hardware-cursor placement is the P3 hook (see `cursor_hint`).
+fn stripCursorMarker(line: []const u8, out: []u8) []const u8 {
+ if (std.mem.indexOf(u8, line, CURSOR_MARKER)) |idx| {
+ const before = line[0..idx];
+ const after = line[idx + CURSOR_MARKER.len ..];
+ @memcpy(out[0..before.len], before);
+ @memcpy(out[before.len .. before.len + after.len], after);
+ return out[0 .. before.len + after.len];
+ }
+ return line;
+}
+
+/// Per-component bookkeeping carried across frames.
+const Slot = struct {
+ comp: Component,
+ /// Global (engine-wide) line offset of this component's first line at the
+ /// last render.
+ offset: usize = 0,
+ /// Line count this component produced at the last render.
+ line_count: usize = 0,
+ /// The lines this component produced at the last render. These are the
+ /// diff baseline AND the cache the engine reprints verbatim for a clean
+ /// component below the cut. Owned by the engine (duped per render).
+ lines: [][]u8 = &.{},
+};
+
+/// A monotonic clock abstraction so the coalescing scheduler is testable
+/// without a real clock. `now()` returns nanoseconds.
+pub const Clock = struct {
+ ptr: *anyopaque,
+ nowFn: *const fn (ptr: *anyopaque) i128,
+
+ pub fn now(self: Clock) i128 {
+ return self.nowFn(self.ptr);
+ }
+
+ // A real monotonic clock is provided by the app sub-phase as an
+ // `Io`-backed `Clock` (this std's monotonic clock lives behind the `Io`
+ // interface, `std.Io.Clock.now(.awake, io)`, so it can't be a free
+ // function here). The engine stays Io-agnostic: it only consumes the
+ // injected `Clock` vtable. Tests inject a deterministic clock directly.
+};
+
+/// Frame coalescing scheduler (plan §3.4).
+///
+/// `requestRender` records that a frame is wanted. `shouldRenderNow` is the
+/// pure decision function: render immediately when idle (no frame drawn within
+/// the coalescing window), otherwise defer until the window since the last
+/// render elapses. The window is a CEILING so we never redraw faster than the
+/// terminal can drain (~120fps default). The actual sleeping/event-loop
+/// integration belongs to the app sub-phase; this struct only decides.
+pub const Scheduler = struct {
+ /// Coalescing window in nanoseconds (default ~8ms ≈ 120fps).
+ window_ns: i128 = 8 * std.time.ns_per_ms,
+ /// Timestamp of the last render, or null if none yet.
+ last_render: ?i128 = null,
+ /// Whether a render has been requested since the last one ran.
+ pending: bool = false,
+
+ pub fn init(window_ns: i128) Scheduler {
+ return .{ .window_ns = window_ns };
+ }
+
+ /// Record that a frame is wanted.
+ pub fn requestRender(self: *Scheduler) void {
+ self.pending = true;
+ }
+
+ /// Pure decision: given the current time, may we render now? Renders
+ /// immediately when idle; under burst, defers until the window elapses.
+ pub fn shouldRenderNow(self: *const Scheduler, now: i128) bool {
+ if (!self.pending) return false;
+ const last = self.last_render orelse return true; // idle: render now
+ return now - last >= self.window_ns;
+ }
+
+ /// Nanoseconds until the next render is permitted, or 0 if allowed now.
+ /// Useful for an event loop's sleep duration. Returns null when no frame
+ /// is pending.
+ pub fn nextDeadline(self: *const Scheduler, now: i128) ?i128 {
+ if (!self.pending) return null;
+ const last = self.last_render orelse return 0;
+ const elapsed = now - last;
+ if (elapsed >= self.window_ns) return 0;
+ return self.window_ns - elapsed;
+ }
+
+ /// Mark that a render ran at `now`, clearing the pending flag.
+ pub fn noteRendered(self: *Scheduler, now: i128) void {
+ self.last_render = now;
+ self.pending = false;
+ }
+};
+
+/// The differential render engine.
+pub const Engine = struct {
+ alloc: std.mem.Allocator,
+ /// Output sink. The real terminal is one implementation; tests inject an
+ /// in-memory writer.
+ writer: *std.Io.Writer,
+ /// Whether to wrap frames in synchronized-output escapes.
+ synchronized_output: bool,
+
+ /// Live components, top-to-bottom. The engine holds a LIST (never a single
+ /// active component).
+ slots: std.ArrayList(Slot) = .empty,
+
+ /// Render width in columns. Components are asked to render at this width
+ /// and every returned line must fit.
+ width: usize,
+ /// Terminal height in rows.
+ height: usize,
+
+ /// Global line index of the first line still inside the visible viewport.
+ /// Lines below this index are addressable for differential repaint; lines
+ /// above have scrolled into native scrollback and are off-limits.
+ viewport_top: usize = 0,
+ /// Total lines produced by the last render pass.
+ total_lines: usize = 0,
+ /// Highest `total_lines` we've ever rendered (used to clear orphaned
+ /// trailing lines on shrink).
+ max_lines_rendered: usize = 0,
+ /// Forces a full redraw on the next frame (first paint, resize).
+ force_full: bool = true,
+
+ /// P3 hook: the global (line, col) where the focused component drew its
+ /// cursor marker, or null. P1 records it but leaves the hardware cursor at
+ /// end-of-content (the documented safe deferral); P3 wires hardware
+ /// positioning.
+ cursor_hint: ?struct { line: usize, col: usize } = null,
+
+ pub fn init(
+ alloc: std.mem.Allocator,
+ writer: *std.Io.Writer,
+ width: usize,
+ height: usize,
+ synchronized_output: bool,
+ ) Engine {
+ return .{
+ .alloc = alloc,
+ .writer = writer,
+ .synchronized_output = synchronized_output,
+ .width = width,
+ .height = height,
+ };
+ }
+
+ pub fn deinit(self: *Engine) void {
+ for (self.slots.items) |*slot| self.freeSlotLines(slot);
+ self.slots.deinit(self.alloc);
+ }
+
+ fn freeSlotLines(self: *Engine, slot: *Slot) void {
+ for (slot.lines) |line| self.alloc.free(line);
+ if (slot.lines.len != 0) self.alloc.free(slot.lines);
+ slot.lines = &.{};
+ }
+
+ // -- component list management -----------------------------------------
+
+ /// Append a component to the live list. The engine does not take ownership
+ /// of the component's backing state, only of its slot bookkeeping.
+ pub fn addComponent(self: *Engine, comp: Component) !void {
+ try self.slots.append(self.alloc, .{ .comp = comp });
+ self.force_full = true; // layout changed
+ }
+
+ /// Remove the component matching `comp.ptr` from the list. Returns true if
+ /// found. Forces a full redraw (layout changed).
+ pub fn removeComponent(self: *Engine, comp: Component) bool {
+ for (self.slots.items, 0..) |slot, i| {
+ if (slot.comp.ptr == comp.ptr) {
+ var removed = self.slots.orderedRemove(i);
+ self.freeSlotLines(&removed);
+ self.force_full = true;
+ return true;
+ }
+ }
+ return false;
+ }
+
+ pub fn componentCount(self: *const Engine) usize {
+ return self.slots.items.len;
+ }
+
+ // -- resize ------------------------------------------------------------
+
+ /// Apply a new terminal size. A width change (wrapping changes) or height
+ /// change (viewport realignment) forces a full redraw on the next frame.
+ pub fn resize(self: *Engine, width: usize, height: usize) void {
+ if (width != self.width or height != self.height) {
+ self.force_full = true;
+ }
+ self.width = width;
+ self.height = height;
+ }
+
+ /// Force the next frame to be a full redraw (e.g. on unrecoverable doubt).
+ pub fn forceFullRedraw(self: *Engine) void {
+ self.force_full = true;
+ }
+
+ // -- the render --------------------------------------------------------
+
+ /// Render a frame. Walks the component list, computes the differential
+ /// repaint, and writes it (wrapped in synchronized output) to the sink.
+ ///
+ /// Returns `anyerror` because a component's `render` may surface an
+ /// arbitrary error; engine-originated failures are the narrower
+ /// `Engine.Error` (notably `Error.LineOverflow` for the width contract).
+ pub fn render(self: *Engine) anyerror!void {
+ // 1. Collect this frame's lines per component, computing global
+ // offsets. Clean components below the eventual cut keep their cached
+ // lines (no re-render); only dirty/first-render components render.
+ const Frame = struct {
+ new_lines: []const []const u8, // borrowed (component- or cache-owned)
+ first_changed: ?usize, // local first-changed signal
+ old_count: usize,
+ offset: usize,
+ };
+ var frames = try self.alloc.alloc(Frame, self.slots.items.len);
+ defer self.alloc.free(frames);
+
+ var offset: usize = 0;
+ var cut: ?usize = null;
+
+ for (self.slots.items, 0..) |*slot, idx| {
+ const first_changed = slot.comp.firstLineChanged();
+ // Render only when there's a reason to: dirty signal, or first
+ // paint (no cached lines yet), or a forced full redraw.
+ const must_render = first_changed != null or slot.lines.len == 0 or self.force_full;
+
+ var new_lines: []const []const u8 = undefined;
+ if (must_render) {
+ new_lines = try slot.comp.render(self.width, self.alloc);
+ } else {
+ // CLEAN below-cut reuse path: no re-render, reprint the cache.
+ new_lines = slot.lines;
+ }
+
+ // Width contract (plan §3.1): enforce every line fits.
+ for (new_lines) |line| {
+ if (visibleWidth(line) > self.width) return Error.LineOverflow;
+ }
+
+ frames[idx] = .{
+ .new_lines = new_lines,
+ .first_changed = first_changed,
+ .old_count = slot.line_count,
+ .offset = offset,
+ };
+
+ // 2. cut = min across ALL components (not just the first dirty).
+ if (first_changed) |fc| {
+ const global = offset + fc;
+ cut = if (cut) |c| @min(c, global) else global;
+ }
+ // A length delta is itself a change point even when the component
+ // reports firstLineChanged == null (the diff backstop will catch
+ // the content, but the boundary must roll the cut back).
+ if (new_lines.len != slot.line_count) {
+ const boundary = offset + @min(new_lines.len, slot.line_count);
+ cut = if (cut) |c| @min(c, boundary) else boundary;
+ }
+
+ offset += new_lines.len;
+ }
+ const new_total = offset;
+
+ // 2b. Line-diff backstop (plan §3.3 step 5): the CORRECTNESS FLOOR.
+ // `firstLineChanged` is the fast-path *input* deciding where
+ // re-rendering starts, but a component can misreport it. From the
+ // signal-derived cut downward we diff the flattened OLD global lines
+ // against the NEW global lines and lower `cut` to the first true
+ // divergence. For clean (not re-rendered) components new == old, so
+ // they contribute no false divergence; only a component that
+ // actually re-rendered to different bytes (or changed length) can
+ // move the cut here. This both defends a lying signal and is what
+ // ultimately guarantees correctness.
+ if (try self.lineDiffBackstop(frames)) |diff_cut| {
+ cut = if (cut) |c| @min(c, diff_cut) else diff_cut;
+ }
+
+ // 3. Decide full vs differential.
+ // Full redraw is forced by: first paint / resize (`force_full`), or
+ // a change that lands above `viewport_top` (off-limits to diff).
+ var full = self.force_full;
+ if (!full) {
+ if (cut) |c| {
+ if (c < self.viewport_top) full = true;
+ }
+ }
+
+ try self.beginFrame();
+
+ if (full) {
+ try self.fullRedraw(frames, new_total);
+ } else {
+ try self.differential(frames, cut, new_total);
+ }
+
+ try self.endFrame();
+
+ // 4. Commit: store each component's new lines as the next baseline and
+ // record offsets. Clean-reuse slots keep their existing owned copy.
+ var commit_offset: usize = 0;
+ for (self.slots.items, 0..) |*slot, idx| {
+ const f = frames[idx];
+ if (f.new_lines.ptr != slot.lines.ptr or f.new_lines.len != slot.lines.len) {
+ // The component rendered fresh lines; dupe them as the baseline.
+ try self.storeSlotLines(slot, f.new_lines);
+ }
+ slot.offset = commit_offset;
+ slot.line_count = f.new_lines.len;
+ commit_offset += f.new_lines.len;
+ }
+
+ self.total_lines = new_total;
+ if (new_total > self.max_lines_rendered) self.max_lines_rendered = new_total;
+ self.recomputeViewportTop();
+ self.force_full = false;
+ }
+
+ /// Dupe `new_lines` into the slot's owned baseline, freeing the old copy.
+ fn storeSlotLines(self: *Engine, slot: *Slot, new_lines: []const []const u8) !void {
+ var copies = try self.alloc.alloc([]u8, new_lines.len);
+ var made: usize = 0;
+ errdefer {
+ for (copies[0..made]) |c| self.alloc.free(c);
+ self.alloc.free(copies);
+ }
+ for (new_lines, 0..) |line, i| {
+ copies[i] = try self.alloc.dupe(u8, line);
+ made = i + 1;
+ }
+ self.freeSlotLines(slot);
+ slot.lines = copies;
+ }
+
+ /// Line-diff backstop (the CORRECTNESS FLOOR). Flattens the previous
+ /// baseline lines (`slot.lines`) and the new frame lines into global arrays
+ /// and returns the FIRST global index where they actually differ, or null
+ /// if identical.
+ ///
+ /// It scans from index 0 (NOT from the signal cut) precisely because the
+ /// signal can be wrong: a component may claim its change starts lower than
+ /// it really does. The caller takes `min(signal_cut, backstop)`, so this
+ /// can only roll the cut earlier, never later. For clean (not re-rendered)
+ /// components the new slice IS the old baseline, so they contribute no
+ /// spurious divergence; only a component that actually produced different
+ /// bytes — or changed length — moves the cut here.
+ fn lineDiffBackstop(self: *Engine, frames: anytype) Error!?usize {
+ // Build old and new flattened global line arrays.
+ var old_lines: std.ArrayList([]const u8) = .empty;
+ defer old_lines.deinit(self.alloc);
+ var new_lines: std.ArrayList([]const u8) = .empty;
+ defer new_lines.deinit(self.alloc);
+
+ for (self.slots.items) |slot| {
+ for (slot.lines) |l| try old_lines.append(self.alloc, l);
+ }
+ for (frames) |f| {
+ for (f.new_lines) |l| try new_lines.append(self.alloc, l);
+ }
+
+ const n = @min(old_lines.items.len, new_lines.items.len);
+ var i: usize = 0;
+ while (i < n) : (i += 1) {
+ if (!std.mem.eql(u8, old_lines.items[i], new_lines.items[i])) return i;
+ }
+ // Lengths differ beyond the common prefix: first extra/missing line.
+ if (old_lines.items.len != new_lines.items.len) return n;
+ return null;
+ }
+
+ fn beginFrame(self: *Engine) Error!void {
+ if (self.synchronized_output) try self.writer.writeAll(terminal.seq.sync_begin);
+ }
+
+ fn endFrame(self: *Engine) Error!void {
+ if (self.synchronized_output) try self.writer.writeAll(terminal.seq.sync_end);
+ }
+
+ /// Full redraw: clear screen + scrollback, home cursor, print everything
+ /// from the top. Resets the viewport.
+ ///
+ /// Cursor-resting invariant (shared with `differential`): the last line is
+ /// written WITHOUT a trailing newline, so after the frame the hardware
+ /// cursor rests at the end of the last content line (global row
+ /// `total_lines - 1`), not one row below it. `differential` relies on this
+ /// to compute how far to move up.
+ fn fullRedraw(self: *Engine, frames: anytype, new_total: usize) Error!void {
+ try self.writer.writeAll(terminal.seq.full_clear);
+ self.cursor_hint = null;
+ var global_line: usize = 0;
+ var first = true;
+ for (frames) |f| {
+ for (f.new_lines) |line| {
+ if (!first) try self.writer.writeAll("\r\n");
+ try self.writeLineNoNewline(line, global_line);
+ first = false;
+ global_line += 1;
+ }
+ }
+ // After a full redraw nothing is orphaned; the screen is clean.
+ _ = new_total;
+ }
+
+ /// Differential redraw: from `cut` downward, move the cursor up to the cut
+ /// line, clear to end of screen, and reprint every line at/after the cut.
+ /// Components above the cut are untouched. Clean components below the cut
+ /// were not re-rendered (their cached lines flow through `frames`), but are
+ /// reprinted here because they sit below the rolled-back point.
+ fn differential(self: *Engine, frames: anytype, cut: ?usize, new_total: usize) Error!void {
+ const cut_line = cut orelse {
+ // Nothing changed and no length delta: emit an empty (but
+ // synchronized) frame. This is the no-op fast path.
+ return;
+ };
+
+ // Cursor-resting invariant: after the previous frame the hardware
+ // cursor rests at the END of the last content line (global row
+ // `prev_total - 1`), because the last line is written without a
+ // trailing newline. To reach the cut line we move up by
+ // `(prev_total - 1) - cut_line`, then carriage-return to column 0.
+ const prev_total = self.total_lines;
+ const last_row = if (prev_total == 0) 0 else prev_total - 1;
+ const up = last_row - @min(cut_line, last_row);
+ if (up > 0) try self.cursorUp(up);
+ try self.writer.writeAll(terminal.seq.carriage_return);
+ // Clear from the cut downward; this also handles orphaned trailing
+ // lines when the content shrank (plan §3.3 step 4).
+ try self.writer.writeAll(terminal.seq.clear_to_end);
+
+ self.cursor_hint = null;
+ var global_line: usize = 0;
+ var first = true;
+ for (frames) |f| {
+ for (f.new_lines) |line| {
+ if (global_line >= cut_line) {
+ if (!first) try self.writer.writeAll("\r\n");
+ try self.writeLineNoNewline(line, global_line);
+ first = false;
+ }
+ global_line += 1;
+ }
+ }
+ _ = new_total;
+ }
+
+ /// Write a line WITHOUT a trailing newline. Strips the cursor marker (P1)
+ /// and records the cursor hint for P3. Both render paths join lines with an
+ /// explicit `\r\n` between them and omit the trailing newline after the
+ /// last line, to maintain the cursor-resting invariant (see `fullRedraw`).
+ fn writeLineNoNewline(self: *Engine, line: []const u8, global_line: usize) Error!void {
+ if (std.mem.indexOf(u8, line, CURSOR_MARKER)) |idx| {
+ // P1: record where the cursor would go (hook for P3), strip the
+ // marker so it never prints. Column is the visible width of the
+ // text before the marker.
+ self.cursor_hint = .{ .line = global_line, .col = visibleWidth(line[0..idx]) };
+ const buf = try self.alloc.alloc(u8, line.len);
+ defer self.alloc.free(buf);
+ const stripped = stripCursorMarker(line, buf);
+ try self.writer.writeAll(stripped);
+ } else {
+ try self.writer.writeAll(line);
+ }
+ }
+
+ fn cursorUp(self: *Engine, n: usize) Error!void {
+ var buf: [16]u8 = undefined;
+ const s = std.fmt.bufPrint(&buf, "\x1b[{d}A", .{n}) catch return;
+ try self.writer.writeAll(s);
+ }
+
+ /// Recompute `viewport_top`: lines beyond the terminal height have scrolled
+ /// into native scrollback and are off-limits to future differential
+ /// updates. The bottom `height` lines remain addressable.
+ fn recomputeViewportTop(self: *Engine) void {
+ if (self.total_lines > self.height) {
+ self.viewport_top = self.total_lines - self.height;
+ } else {
+ self.viewport_top = 0;
+ }
+ }
+};
+
+// ===========================================================================
+// Tests
+// ===========================================================================
+
+const testing = std.testing;
+
+/// A scripted fake component for tests. Returns a programmed sequence of line
+/// sets across successive renders and a programmed `firstLineChanged`. Counts
+/// render calls to prove the no-re-render-of-clean-below-cut property.
+const FakeComponent = struct {
+ scripts: []const []const []const u8,
+ first_changed: []const ?usize,
+ step: usize = 0,
+ render_calls: usize = 0,
+
+ fn render(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
+ const self: *FakeComponent = @ptrCast(@alignCast(ptr));
+ _ = width;
+ _ = alloc;
+ self.render_calls += 1;
+ const idx = @min(self.step, self.scripts.len - 1);
+ return self.scripts[idx];
+ }
+
+ fn firstLineChanged(ptr: *anyopaque) ?usize {
+ const self: *FakeComponent = @ptrCast(@alignCast(ptr));
+ const idx = @min(self.step, self.first_changed.len - 1);
+ return self.first_changed[idx];
+ }
+
+ fn invalidate(ptr: *anyopaque) void {
+ _ = ptr;
+ }
+
+ const vtable = Component.VTable{
+ .render = render,
+ .firstLineChanged = firstLineChanged,
+ .invalidate = invalidate,
+ };
+
+ fn comp(self: *FakeComponent) Component {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+
+ /// Advance to the next scripted step (simulating the app mutating state +
+ /// the cache recomputing firstLineChanged).
+ fn advance(self: *FakeComponent) void {
+ self.step += 1;
+ }
+};
+
+fn makeEngine(buf: *std.Io.Writer.Allocating, width: usize, height: usize) Engine {
+ return Engine.init(testing.allocator, &buf.writer, width, height, false);
+}
+
+test "visibleWidth strips CSI and counts codepoints" {
+ try testing.expectEqual(@as(usize, 5), visibleWidth("hello"));
+ try testing.expectEqual(@as(usize, 5), visibleWidth("\x1b[2mhello\x1b[0m"));
+ try testing.expectEqual(@as(usize, 0), visibleWidth("\x1b[0m"));
+ // CURSOR_MARKER (APC string) is zero-width.
+ try testing.expectEqual(@as(usize, 2), visibleWidth("a" ++ CURSOR_MARKER ++ "b"));
+ // multibyte UTF-8 counts one column per codepoint.
+ try testing.expectEqual(@as(usize, 3), visibleWidth("aé✓"));
+}
+
+test "first paint is a full redraw clearing scrollback" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 24);
+ defer eng.deinit();
+
+ var fc = FakeComponent{
+ .scripts = &.{&.{ "line one", "line two" }},
+ .first_changed = &.{0},
+ };
+ try eng.addComponent(fc.comp());
+ try eng.render();
+
+ const out = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null);
+ try testing.expect(std.mem.indexOf(u8, out, "line one") != null);
+ try testing.expect(std.mem.indexOf(u8, out, "line two") != null);
+ try testing.expectEqual(@as(usize, 1), fc.render_calls);
+}
+
+test "cut is the min across ALL components (ticking footer below clean body)" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 100); // tall: nothing scrolls off
+ defer eng.deinit();
+
+ // Body: clean after first paint. Footer: dirty at local line 0 each step.
+ var body = FakeComponent{
+ .scripts = &.{ &.{ "b0", "b1" }, &.{ "b0", "b1" } },
+ .first_changed = &.{ 0, null }, // first paint dirty, then clean
+ };
+ var footer = FakeComponent{
+ .scripts = &.{ &.{"f-0"}, &.{"f-1"} },
+ .first_changed = &.{ 0, 0 }, // always dirty at local 0
+ };
+ try eng.addComponent(body.comp());
+ try eng.addComponent(footer.comp());
+
+ try eng.render(); // first paint (full)
+ body.advance();
+ footer.advance();
+ buf.clearRetainingCapacity();
+ try eng.render(); // differential
+
+ // The footer occupies global line 2 (after body's 2 lines). The cut must
+ // be 2 (min across all), NOT 0, and NOT "first dirty component" (body is
+ // clean now). So the body lines must NOT be reprinted.
+ const out = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out, "f-1") != null);
+ try testing.expect(std.mem.indexOf(u8, out, "b0") == null);
+ try testing.expect(std.mem.indexOf(u8, out, "b1") == null);
+ // Body was clean and below... no: body is ABOVE the cut, so untouched and
+ // NOT re-rendered.
+ try testing.expectEqual(@as(usize, 1), body.render_calls); // only first paint
+}
+
+test "clean components below the cut are reprinted but NOT re-rendered" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 100);
+ defer eng.deinit();
+
+ // Header is dirty (forces the cut up to line 0); body is clean but sits
+ // below the cut, so its CACHED lines must be reprinted without re-render.
+ var header = FakeComponent{
+ .scripts = &.{ &.{"h-0"}, &.{"h-1"} },
+ .first_changed = &.{ 0, 0 },
+ };
+ var body = FakeComponent{
+ .scripts = &.{ &.{ "b0", "b1" }, &.{ "b0", "b1" } },
+ .first_changed = &.{ 0, null },
+ };
+ try eng.addComponent(header.comp());
+ try eng.addComponent(body.comp());
+
+ try eng.render(); // first paint
+ header.advance();
+ body.advance();
+ buf.clearRetainingCapacity();
+ try eng.render(); // differential, cut == 0
+
+ const out = buf.written();
+ // Header changed and reprinted.
+ try testing.expect(std.mem.indexOf(u8, out, "h-1") != null);
+ // Body is below the cut => reprinted verbatim from cache...
+ try testing.expect(std.mem.indexOf(u8, out, "b0") != null);
+ try testing.expect(std.mem.indexOf(u8, out, "b1") != null);
+ // ...but NOT re-rendered: render_calls stayed at 1 (the first paint).
+ try testing.expectEqual(@as(usize, 1), body.render_calls);
+}
+
+test "shrinking component clears orphaned trailing lines" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 100);
+ defer eng.deinit();
+
+ // Body collapses from 3 lines to 1. A shrinking component marks dirty
+ // (RenderCache reports the divergence boundary), so it re-renders; the
+ // engine must clear the orphaned trailing lines and recompute totals.
+ var body = FakeComponent{
+ .scripts = &.{ &.{ "x0", "x1", "x2" }, &.{"x0"} },
+ .first_changed = &.{ 0, 1 },
+ };
+ try eng.addComponent(body.comp());
+
+ try eng.render();
+ try testing.expectEqual(@as(usize, 3), eng.total_lines);
+ body.advance();
+ buf.clearRetainingCapacity();
+ try eng.render();
+
+ // Orphans removed: total dropped from 3 to 1.
+ try testing.expectEqual(@as(usize, 1), eng.total_lines);
+ const out = buf.written();
+ // The differential frame must clear to end of screen to erase the two
+ // orphaned trailing lines (x1, x2).
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.clear_to_end) != null);
+ // x1/x2 must NOT be reprinted (they were cleared).
+ try testing.expect(std.mem.indexOf(u8, out, "x1") == null);
+ try testing.expect(std.mem.indexOf(u8, out, "x2") == null);
+ // No full redraw was needed — shrink stays on the differential path.
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null);
+}
+
+test "line-diff backstop corrects an inaccurate firstLineChanged" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 100);
+ defer eng.deinit();
+
+ // The component IS re-rendered (it reports dirty), but its signal LIES
+ // about WHERE the change is: it claims the change starts at line 2 while
+ // the real divergence is at line 0. The signal alone would reprint only
+ // from line 2 down and leave the stale line 0 on screen; the line-diff
+ // backstop must roll the cut back to 0 so "A0" actually paints.
+ var body = FakeComponent{
+ .scripts = &.{ &.{ "a0", "a1", "a2" }, &.{ "A0", "a1", "a2" } },
+ .first_changed = &.{ 0, 2 }, // lie: real change is at line 0
+ };
+ try eng.addComponent(body.comp());
+
+ try eng.render();
+ body.advance();
+ buf.clearRetainingCapacity();
+ try eng.render();
+
+ const out = buf.written();
+ // Backstop rolled the cut from the claimed line 2 back to line 0, so the
+ // new first line is reprinted.
+ try testing.expect(std.mem.indexOf(u8, out, "A0") != null);
+ // And no full redraw was needed — this stayed on the differential path.
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null);
+}
+
+test "consecutive differential frames move the cursor up correctly (streaming append)" {
+ // Regression: the differential path must account for the cursor resting at
+ // the END of the last content line (no trailing newline), not one row
+ // below it. The original code assumed cursor-at-`prev_total`, so the second
+ // (and every subsequent) differential frame moved up one row too few,
+ // clobbering everything but the last line — the streaming bug.
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 100); // tall: nothing scrolls off
+ defer eng.deinit();
+
+ // A footer that ticks at local line 0 on every frame, sitting below a body
+ // that grows by one line each step (the streaming transcript shape).
+ var body = FakeComponent{
+ .scripts = &.{
+ &.{"l0"},
+ &.{ "l0", "l1" },
+ &.{ "l0", "l1", "l2" },
+ },
+ // append-only: each step's first change is the new tail line.
+ .first_changed = &.{ 0, 1, 2 },
+ };
+ try eng.addComponent(body.comp());
+
+ try eng.render(); // first paint (full): l0
+ try testing.expectEqual(@as(usize, 1), eng.total_lines);
+
+ // Frame 2 (differential): append l1. prev_total==1 => last_row==0 => up==0.
+ body.advance();
+ buf.clearRetainingCapacity();
+ try eng.render();
+ {
+ const out = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null);
+ // cut is line 1; cursor was on row 0 (end of l0), so NO up-move at all.
+ try testing.expect(std.mem.indexOf(u8, out, "\x1b[1A") == null);
+ try testing.expect(std.mem.indexOf(u8, out, "l1") != null);
+ }
+ try testing.expectEqual(@as(usize, 2), eng.total_lines);
+
+ // Frame 3 (differential): append l2. prev_total==2 => last_row==1, cut==2.
+ // up == last_row - min(cut,last_row) == 1 - 1 == 0 (cut is below cursor).
+ body.advance();
+ buf.clearRetainingCapacity();
+ try eng.render();
+ {
+ const out = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null);
+ try testing.expect(std.mem.indexOf(u8, out, "l2") != null);
+ // l0 must NOT be reprinted — it is above the cut and untouched. This is
+ // the assertion that fails under the old (over-eager) cursor math,
+ // which clobbered everything above the last line.
+ try testing.expect(std.mem.indexOf(u8, out, "l0") == null);
+ }
+ try testing.expectEqual(@as(usize, 3), eng.total_lines);
+}
+
+test "differential edit of an upper line moves the cursor up the right distance" {
+ // A change at line 0 of a 3-line body, on the SECOND differential frame, so
+ // the cursor starts at row 2 (end of last line) and must climb 2 rows.
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 100);
+ defer eng.deinit();
+
+ var body = FakeComponent{
+ .scripts = &.{
+ &.{ "a0", "a1", "a2" },
+ &.{ "a0", "a1", "A2" }, // first diff frame: change the tail
+ &.{ "X0", "a1", "A2" }, // second diff frame: change the head
+ },
+ .first_changed = &.{ 0, 2, 0 },
+ };
+ try eng.addComponent(body.comp());
+
+ try eng.render(); // full
+ body.advance();
+ buf.clearRetainingCapacity();
+ try eng.render(); // diff: tail change (cursor stays low)
+ body.advance();
+ buf.clearRetainingCapacity();
+ try eng.render(); // diff: head change (must climb to line 0)
+
+ const out = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null);
+ // Cursor rested at end of row 2 (A2); to reach cut line 0 it must move up 2.
+ try testing.expect(std.mem.indexOf(u8, out, "\x1b[2A") != null);
+ try testing.expect(std.mem.indexOf(u8, out, "X0") != null);
+}
+
+test "width change forces a full redraw" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 24);
+ defer eng.deinit();
+
+ var body = FakeComponent{
+ .scripts = &.{ &.{"hi"}, &.{"hi"} },
+ .first_changed = &.{ 0, null },
+ };
+ try eng.addComponent(body.comp());
+ try eng.render();
+
+ body.advance();
+ eng.resize(60, 24); // width change
+ buf.clearRetainingCapacity();
+ try eng.render();
+
+ const out = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null);
+}
+
+test "height change forces a full redraw" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 24);
+ defer eng.deinit();
+
+ var body = FakeComponent{
+ .scripts = &.{ &.{"hi"}, &.{"hi"} },
+ .first_changed = &.{ 0, null },
+ };
+ try eng.addComponent(body.comp());
+ try eng.render();
+
+ body.advance();
+ eng.resize(80, 30); // height change
+ buf.clearRetainingCapacity();
+ try eng.render();
+
+ const out = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null);
+}
+
+test "change above viewport_top forces a full redraw" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ // Short viewport so most content scrolls into native scrollback.
+ var eng = makeEngine(&buf, 80, 2);
+ defer eng.deinit();
+
+ // 5 lines, viewport height 2 => viewport_top becomes 3 after first paint.
+ var body = FakeComponent{
+ .scripts = &.{ &.{ "l0", "l1", "l2", "l3", "l4" }, &.{ "L0", "l1", "l2", "l3", "l4" } },
+ .first_changed = &.{ 0, 0 }, // change lands at line 0, above viewport_top
+ };
+ try eng.addComponent(body.comp());
+ try eng.render();
+ try testing.expectEqual(@as(usize, 3), eng.viewport_top);
+
+ body.advance();
+ buf.clearRetainingCapacity();
+ try eng.render();
+
+ const out = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null);
+}
+
+test "width overflow is a hard error" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 4, 24);
+ defer eng.deinit();
+
+ var body = FakeComponent{
+ .scripts = &.{&.{"way too wide"}},
+ .first_changed = &.{0},
+ };
+ try eng.addComponent(body.comp());
+ try testing.expectError(Error.LineOverflow, eng.render());
+}
+
+test "engine holds a LIST of components" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 24);
+ defer eng.deinit();
+
+ var a = FakeComponent{ .scripts = &.{&.{"a"}}, .first_changed = &.{0} };
+ var b = FakeComponent{ .scripts = &.{&.{"b"}}, .first_changed = &.{0} };
+ try eng.addComponent(a.comp());
+ try eng.addComponent(b.comp());
+ try testing.expectEqual(@as(usize, 2), eng.componentCount());
+
+ try testing.expect(eng.removeComponent(a.comp()));
+ try testing.expectEqual(@as(usize, 1), eng.componentCount());
+ try testing.expect(!eng.removeComponent(a.comp()));
+}
+
+test "cursor marker is stripped from output and recorded as a hint" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 24);
+ defer eng.deinit();
+
+ var body = FakeComponent{
+ .scripts = &.{&.{"ab" ++ CURSOR_MARKER ++ "cd"}},
+ .first_changed = &.{0},
+ };
+ try eng.addComponent(body.comp());
+ try eng.render();
+
+ const out = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out, CURSOR_MARKER) == null); // stripped
+ try testing.expect(std.mem.indexOf(u8, out, "abcd") != null);
+ try testing.expect(eng.cursor_hint != null);
+ try testing.expectEqual(@as(usize, 2), eng.cursor_hint.?.col);
+}
+
+test "every frame is wrapped in synchronized output when enabled" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = Engine.init(testing.allocator, &buf.writer, 80, 24, true);
+ defer eng.deinit();
+
+ var body = FakeComponent{ .scripts = &.{&.{"x"}}, .first_changed = &.{0} };
+ try eng.addComponent(body.comp());
+ try eng.render();
+
+ const out = buf.written();
+ try testing.expect(std.mem.startsWith(u8, out, terminal.seq.sync_begin));
+ try testing.expect(std.mem.endsWith(u8, out, terminal.seq.sync_end));
+ // Ordinary content never clears scrollback on a forced full first paint is
+ // fine, but the sync frame must wrap it.
+}
+
+test "ordinary differential frame never clears scrollback" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 100);
+ defer eng.deinit();
+
+ var body = FakeComponent{
+ .scripts = &.{ &.{ "a", "b" }, &.{ "a", "B" } },
+ .first_changed = &.{ 0, 1 },
+ };
+ try eng.addComponent(body.comp());
+ try eng.render();
+ body.advance();
+ buf.clearRetainingCapacity();
+ try eng.render();
+
+ const out = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null);
+ try testing.expect(std.mem.indexOf(u8, out, "B") != null);
+}
+
+// -- Scheduler tests --------------------------------------------------------
+
+test "scheduler: idle renders immediately" {
+ var s = Scheduler.init(8 * std.time.ns_per_ms);
+ try testing.expect(!s.shouldRenderNow(0)); // nothing pending
+ s.requestRender();
+ try testing.expect(s.shouldRenderNow(0)); // idle => now
+}
+
+test "scheduler: burst coalesces within the window" {
+ var s = Scheduler.init(8 * std.time.ns_per_ms);
+ s.requestRender();
+ s.noteRendered(1000);
+ s.requestRender();
+ // Within the window: defer.
+ try testing.expect(!s.shouldRenderNow(1000 + 4 * std.time.ns_per_ms));
+ // Past the window: render.
+ try testing.expect(s.shouldRenderNow(1000 + 8 * std.time.ns_per_ms));
+}
+
+test "scheduler: nextDeadline reports remaining window" {
+ var s = Scheduler.init(8 * std.time.ns_per_ms);
+ try testing.expectEqual(@as(?i128, null), s.nextDeadline(0)); // not pending
+ s.requestRender();
+ s.noteRendered(0);
+ s.requestRender();
+ try testing.expectEqual(@as(?i128, 8 * std.time.ns_per_ms), s.nextDeadline(0));
+ try testing.expectEqual(@as(?i128, 0), s.nextDeadline(8 * std.time.ns_per_ms));
+}
diff --git a/src/tui_input.zig b/src/tui_input.zig
new file mode 100644
index 0000000..fdd174c
--- /dev/null
+++ b/src/tui_input.zig
@@ -0,0 +1,489 @@
+//! Input layer for the TUI: raw stdin bytes -> `Key` values.
+//!
+//! Scope (P1, intentionally minimal but correct):
+//! - printable chars (UTF-8, multi-byte)
+//! - enter, tab, backspace, delete
+//! - arrows, home, end, page up/down (CSI + a couple legacy forms)
+//! - Esc (standalone), Ctrl+C, Ctrl+D, and other Ctrl+<letter>
+//! - bracketed paste: enable/disable + recognizing begin/end markers and
+//! surfacing pasted bytes as one literal-text `Key` rather than parsing
+//! each byte as a keypress.
+//!
+//! Deferred to a later phase (documented, not done here):
+//! - Full Kitty keyboard-protocol disambiguation (key release, exact
+//! modifier reporting, super/hyper, alternate-key reporting). We *send* a
+//! modest Kitty enable+query during negotiation but degrade gracefully:
+//! nothing here requires a response, and unrecognized sequences are
+//! consumed and dropped rather than mis-decoded.
+//!
+//! Shift+Enter limitation: without the Kitty protocol most terminals send the
+//! exact same bytes for Enter and Shift+Enter (`\r`), so this decoder cannot
+//! distinguish them and reports both as `.enter` with no shift modifier. When
+//! the Kitty protocol IS active, Shift+Enter arrives as a CSI-u sequence
+//! (`\x1b[13;2u`) which we DO decode, setting `mods.shift`. The `Key`/`Mods`
+//! model can therefore represent the distinction even on terminals that can't
+//! produce it; the input-box component (later sub-phase) keys off
+//! `mods.shift` on an `.enter` press. On terminals that can't express it,
+//! Enter submits and a separate binding (e.g. Alt+Enter / a config key) must
+//! insert a newline — that policy is the component's, not ours.
+//!
+//! The splitter (`decodeOne`) takes a byte buffer and returns the next decoded
+//! key plus how many bytes it consumed, so a batched read of several escape
+//! sequences is split into individual `Key`s and a partial trailing sequence
+//! can be retained for the next read.
+
+const std = @import("std");
+const key = @import("tui_key.zig");
+const Key = key.Key;
+const KeyCode = key.KeyCode;
+const Mods = key.Mods;
+
+// ---- Terminal control sequences this layer owns --------------------------
+
+/// Enable bracketed paste.
+pub const enable_bracketed_paste = "\x1b[?2004h";
+/// Disable bracketed paste.
+pub const disable_bracketed_paste = "\x1b[?2004l";
+
+/// Bracketed-paste begin / end markers.
+pub const paste_begin = "\x1b[200~";
+pub const paste_end = "\x1b[201~";
+
+/// Kitty keyboard protocol: push a flags entry that asks for disambiguated
+/// escape codes (flag 1). We push (not set) so teardown can pop cleanly. This
+/// is best-effort; terminals that don't support it ignore it.
+pub const enable_kitty_keyboard = "\x1b[>1u";
+/// Pop the Kitty keyboard flags entry we pushed.
+pub const disable_kitty_keyboard = "\x1b[<u";
+
+/// Modest negotiation setup to write at startup. Bracketed paste is the only
+/// thing P1 strictly needs; the Kitty enable is opportunistic. Order: paste
+/// first, then Kitty.
+pub const negotiate_setup = enable_bracketed_paste ++ enable_kitty_keyboard;
+
+/// Teardown to write on exit; the reverse of `negotiate_setup`.
+pub const negotiate_teardown = disable_kitty_keyboard ++ disable_bracketed_paste;
+
+// ---- Decode results -------------------------------------------------------
+
+/// What a single decode step produced.
+pub const Decoded = union(enum) {
+ /// A decoded key.
+ key: Key,
+ /// A run of pasted text (between bracketed-paste markers), surfaced
+ /// literally. `text` borrows from the input buffer.
+ paste: []const u8,
+};
+
+/// Result of one decode step: what was produced, and how many input bytes it
+/// consumed. `null` from `decodeOne` means "need more bytes" (a partial
+/// sequence at the end of the buffer); the caller should retain the unconsumed
+/// tail and append the next read.
+pub const Step = struct {
+ decoded: Decoded,
+ consumed: usize,
+};
+
+// ---- The splitter / decoder ----------------------------------------------
+
+/// Decode the next key or paste run from the front of `buf`.
+///
+/// Returns:
+/// - `Step` with `consumed > 0` on success.
+/// - `null` when `buf` holds only a partial/incomplete sequence and more
+/// bytes are needed (retain the tail and read more). An empty `buf` also
+/// returns `null`.
+///
+/// Borrowing: any `[]const u8` in the result (printable `Key.text`, paste
+/// text) points into `buf`. Copy before the next read overwrites the buffer.
+pub fn decodeOne(buf: []const u8) ?Step {
+ if (buf.len == 0) return null;
+
+ const b = buf[0];
+
+ // ESC: could be a CSI/SS3 sequence, a bracketed paste, an Alt+<key>, or a
+ // lone Escape.
+ if (b == 0x1b) return decodeEscape(buf);
+
+ // Control bytes (C0).
+ switch (b) {
+ '\r', '\n' => return keyStep(.{ .code = .enter }, 1),
+ '\t' => return keyStep(.{ .code = .tab }, 1),
+ 0x7f, 0x08 => return keyStep(.{ .code = .backspace }, 1),
+ 0x03 => return keyStep(.{ .code = .{ .char = 'c' }, .mods = .{ .ctrl = true } }, 1), // Ctrl+C
+ 0x04 => return keyStep(.{ .code = .{ .char = 'd' }, .mods = .{ .ctrl = true } }, 1), // Ctrl+D
+ else => {},
+ }
+
+ // Other C0 control bytes => Ctrl+<letter>. 0x01..0x1a map to a..z.
+ if (b >= 0x01 and b <= 0x1a) {
+ const letter: u21 = @intCast('a' + (b - 1));
+ return keyStep(.{ .code = .{ .char = letter }, .mods = .{ .ctrl = true } }, 1);
+ }
+
+ // Printable: decode one UTF-8 codepoint.
+ const seq_len = std.unicode.utf8ByteSequenceLength(b) catch {
+ // Invalid lead byte — consume it as a Latin-1-ish fallback so we never
+ // wedge on garbage input.
+ return keyStep(.{ .code = .{ .char = b }, .text = buf[0..1] }, 1);
+ };
+ if (buf.len < seq_len) return null; // partial UTF-8 at buffer end
+ const cp = std.unicode.utf8Decode(buf[0..seq_len]) catch {
+ return keyStep(.{ .code = .{ .char = b }, .text = buf[0..1] }, 1);
+ };
+ return keyStep(.{ .code = .{ .char = cp }, .text = buf[0..seq_len] }, seq_len);
+}
+
+fn keyStep(k: Key, consumed: usize) Step {
+ return .{ .decoded = .{ .key = k }, .consumed = consumed };
+}
+
+/// Decode a sequence beginning with ESC (`buf[0] == 0x1b`).
+fn decodeEscape(buf: []const u8) ?Step {
+ std.debug.assert(buf[0] == 0x1b);
+
+ // Lone ESC with nothing after it: ambiguous (could be the start of a
+ // longer sequence). We only commit to "Escape key" if we can see it's not
+ // the start of CSI/SS3. With just one byte we can't tell -> need more.
+ if (buf.len == 1) return null;
+
+ const c1 = buf[1];
+
+ // CSI: ESC [
+ if (c1 == '[') return decodeCSI(buf);
+ // SS3: ESC O (some terminals send arrows/home/end as SS3 in app mode)
+ if (c1 == 'O') return decodeSS3(buf);
+
+ // ESC followed by anything else: treat as Alt+<that key>. Decode the rest
+ // recursively and OR in alt. (A real lone-Escape keypress is followed by
+ // nothing more, handled below.)
+ if (decodeOne(buf[1..])) |inner| {
+ switch (inner.decoded) {
+ .key => |ik| {
+ var k = ik;
+ k.mods.alt = true;
+ return .{ .decoded = .{ .key = k }, .consumed = inner.consumed + 1 };
+ },
+ // A paste right after ESC is nonsensical; fall through to Escape.
+ .paste => {},
+ }
+ }
+
+ // Couldn't make sense of what follows: report a lone Escape, consuming
+ // just the ESC byte.
+ return keyStep(.{ .code = .escape }, 1);
+}
+
+/// Decode an SS3 sequence: ESC O <final>. Used by some terminals for arrows,
+/// home/end and F1..F4 in application-cursor mode.
+fn decodeSS3(buf: []const u8) ?Step {
+ // buf[0]=ESC buf[1]='O'
+ if (buf.len < 3) return null; // need the final byte
+ const final = buf[2];
+ const code: ?KeyCode = switch (final) {
+ 'A' => .up,
+ 'B' => .down,
+ 'C' => .right,
+ 'D' => .left,
+ 'H' => .home,
+ 'F' => .end,
+ 'P' => .f1,
+ 'Q' => .f2,
+ 'R' => .f3,
+ 'S' => .f4,
+ else => null,
+ };
+ if (code) |c| return keyStep(.{ .code = c }, 3);
+ // Unknown SS3 final: consume the 3 bytes and drop (degrade gracefully).
+ return keyStep(.{ .code = .escape }, 3);
+}
+
+/// Decode a CSI sequence: ESC [ <params> <final>.
+///
+/// Handles:
+/// - arrows / home / end / page up-down (with optional `1;<mods>` modifier)
+/// - the `~`-terminated numeric forms (Home=1~/7~, End=4~/8~, Ins, Del=3~,
+/// PgUp=5~, PgDn=6~, F5..F12)
+/// - the `[A`..`[D` arrows without params
+/// - bracketed paste begin (`200~`) — returns a `.paste` run spanning to the
+/// end marker
+/// - CSI-u (Kitty) for the keys we care about (notably Shift+Enter =
+/// `13;2u`)
+///
+/// Unknown but well-formed CSI sequences are consumed and dropped.
+fn decodeCSI(buf: []const u8) ?Step {
+ // buf[0]=ESC buf[1]='['
+ // Find the final byte: the first byte in 0x40..0x7e after the params.
+ // Params/intermediates are 0x20..0x3f. If we run off the end, need more.
+ var i: usize = 2;
+ while (i < buf.len and isCSIParamOrIntermediate(buf[i])) : (i += 1) {}
+ if (i >= buf.len) return null; // incomplete: no final byte yet
+ const final = buf[i];
+ const params = buf[2..i];
+ const total = i + 1;
+
+ // Bracketed paste begin: CSI 200 ~ -> scan to the end marker.
+ if (final == '~' and std.mem.eql(u8, params, "200")) {
+ return decodePaste(buf, total);
+ }
+
+ // CSI-u (Kitty): final 'u', params = "<codepoint>[;<mods>[:<event>]]".
+ if (final == 'u') return decodeKittyU(params, total);
+
+ // Modifier suffix: many sequences are "1;<mods><final>" or
+ // "<num>;<mods>~". Split params on ';'.
+ var first: []const u8 = params;
+ var mod_field: ?[]const u8 = null;
+ if (std.mem.indexOfScalar(u8, params, ';')) |semi| {
+ first = params[0..semi];
+ mod_field = params[semi + 1 ..];
+ }
+ const mods = parseMods(mod_field);
+
+ // Letter-final forms: A/B/C/D arrows, H home, F end.
+ const letter_code: ?KeyCode = switch (final) {
+ 'A' => .up,
+ 'B' => .down,
+ 'C' => .right,
+ 'D' => .left,
+ 'H' => .home,
+ 'F' => .end,
+ 'P' => .f1,
+ 'Q' => .f2,
+ 'R' => .f3,
+ 'S' => .f4,
+ else => null,
+ };
+ if (letter_code) |c| return keyStep(.{ .code = c, .mods = mods }, total);
+
+ // Tilde-final numeric forms.
+ if (final == '~') {
+ const n = std.fmt.parseInt(u16, first, 10) catch return keyStep(.{ .code = .escape }, total);
+ const code: ?KeyCode = switch (n) {
+ 1, 7 => .home,
+ 2 => null, // Insert — not in our KeyCode set; drop
+ 3 => .delete,
+ 4, 8 => .end,
+ 5 => .page_up,
+ 6 => .page_down,
+ 11 => .f1,
+ 12 => .f2,
+ 13 => .f3,
+ 14 => .f4,
+ 15 => .f5,
+ 17 => .f6,
+ 18 => .f7,
+ 19 => .f8,
+ 20 => .f9,
+ 21 => .f10,
+ 23 => .f11,
+ 24 => .f12,
+ else => null,
+ };
+ if (code) |c| return keyStep(.{ .code = c, .mods = mods }, total);
+ return keyStep(.{ .code = .escape }, total); // unknown ~ form: drop
+ }
+
+ // Well-formed but unhandled CSI: consume and drop.
+ return keyStep(.{ .code = .escape }, total);
+}
+
+/// Scan a bracketed-paste body starting after the `CSI 200~` begin marker
+/// (which spans `[0..begin_len)`), up to the `CSI 201~` end marker. Returns a
+/// `.paste` step covering the text between the markers. If the end marker
+/// isn't present yet, returns null (need more bytes).
+fn decodePaste(buf: []const u8, begin_len: usize) ?Step {
+ const body = buf[begin_len..];
+ const end_idx = std.mem.indexOf(u8, body, paste_end) orelse return null;
+ const text = body[0..end_idx];
+ const consumed = begin_len + end_idx + paste_end.len;
+ return .{ .decoded = .{ .paste = text }, .consumed = consumed };
+}
+
+/// Decode a Kitty CSI-u sequence's params (without the trailing `u`). We only
+/// resolve the subset P1 needs: a printable codepoint, Enter (13), Tab (9),
+/// Backspace (127), and Escape (27), with modifiers and (optionally) the
+/// release event. Anything else is consumed and dropped.
+fn decodeKittyU(params: []const u8, total: usize) ?Step {
+ // params: "<cp>[:<alt-cp>][;<mods>[:<event>]]"
+ var cp_field: []const u8 = params;
+ var rest: ?[]const u8 = null;
+ if (std.mem.indexOfScalar(u8, params, ';')) |semi| {
+ cp_field = params[0..semi];
+ rest = params[semi + 1 ..];
+ }
+ // The codepoint field may carry alternates after ':'; take the first.
+ if (std.mem.indexOfScalar(u8, cp_field, ':')) |colon| cp_field = cp_field[0..colon];
+
+ const cp = std.fmt.parseInt(u21, cp_field, 10) catch return keyStep(.{ .code = .escape }, total);
+
+ // Modifier + event live in `rest` as "<mods>[:<event>]".
+ var mod_field: ?[]const u8 = rest;
+ var event = key.KeyEvent.press;
+ if (rest) |r| {
+ if (std.mem.indexOfScalar(u8, r, ':')) |colon| {
+ mod_field = r[0..colon];
+ event = parseKittyEvent(r[colon + 1 ..]);
+ }
+ }
+ const mods = parseMods(mod_field);
+
+ const code: KeyCode = switch (cp) {
+ 13 => .enter,
+ 9 => .tab,
+ 127 => .backspace,
+ 27 => .escape,
+ else => .{ .char = cp },
+ };
+ return .{ .decoded = .{ .key = .{ .code = code, .mods = mods, .event = event } }, .consumed = total };
+}
+
+fn parseKittyEvent(field: []const u8) key.KeyEvent {
+ const n = std.fmt.parseInt(u8, field, 10) catch return .press;
+ return switch (n) {
+ 1 => .press,
+ 2 => .repeat,
+ 3 => .release,
+ else => .press,
+ };
+}
+
+/// Parse a CSI modifier field. The terminal encoding is `1 + bitmask` where
+/// the bitmask is shift=1, alt=2, ctrl=4, super=8. A null/empty field means no
+/// modifiers.
+fn parseMods(field: ?[]const u8) Mods {
+ const f = field orelse return .{};
+ if (f.len == 0) return .{};
+ const raw = std.fmt.parseInt(u16, f, 10) catch return .{};
+ if (raw == 0) return .{};
+ const bits = raw - 1;
+ return .{
+ .shift = (bits & 1) != 0,
+ .alt = (bits & 2) != 0,
+ .ctrl = (bits & 4) != 0,
+ .super = (bits & 8) != 0,
+ };
+}
+
+fn isCSIParamOrIntermediate(c: u8) bool {
+ return c >= 0x20 and c <= 0x3f;
+}
+
+// ---- Tests ----------------------------------------------------------------
+
+test "printable ascii" {
+ const s = decodeOne("a").?;
+ try std.testing.expectEqual(@as(usize, 1), s.consumed);
+ try std.testing.expectEqual(@as(u21, 'a'), s.decoded.key.code.char);
+ try std.testing.expectEqualStrings("a", s.decoded.key.text.?);
+}
+
+test "multibyte utf8 printable" {
+ // é = U+00E9 = 0xC3 0xA9
+ const s = decodeOne("\xc3\xa9rest").?;
+ try std.testing.expectEqual(@as(usize, 2), s.consumed);
+ try std.testing.expectEqual(@as(u21, 0xe9), s.decoded.key.code.char);
+}
+
+test "partial utf8 needs more bytes" {
+ try std.testing.expect(decodeOne("\xc3") == null);
+}
+
+test "enter, tab, backspace" {
+ try std.testing.expectEqual(KeyCode.enter, decodeOne("\r").?.decoded.key.code);
+ try std.testing.expectEqual(KeyCode.enter, decodeOne("\n").?.decoded.key.code);
+ try std.testing.expectEqual(KeyCode.tab, decodeOne("\t").?.decoded.key.code);
+ try std.testing.expectEqual(KeyCode.backspace, decodeOne("\x7f").?.decoded.key.code);
+}
+
+test "ctrl-c and ctrl-d" {
+ const c = decodeOne("\x03").?.decoded.key;
+ try std.testing.expect(c.isCtrl('c'));
+ const d = decodeOne("\x04").?.decoded.key;
+ try std.testing.expect(d.isCtrl('d'));
+}
+
+test "csi arrows" {
+ try std.testing.expectEqual(KeyCode.up, decodeOne("\x1b[A").?.decoded.key.code);
+ try std.testing.expectEqual(KeyCode.down, decodeOne("\x1b[B").?.decoded.key.code);
+ try std.testing.expectEqual(KeyCode.right, decodeOne("\x1b[C").?.decoded.key.code);
+ try std.testing.expectEqual(KeyCode.left, decodeOne("\x1b[D").?.decoded.key.code);
+}
+
+test "csi home/end and modified arrow" {
+ try std.testing.expectEqual(KeyCode.home, decodeOne("\x1b[H").?.decoded.key.code);
+ try std.testing.expectEqual(KeyCode.end, decodeOne("\x1b[F").?.decoded.key.code);
+ // Ctrl+Right = CSI 1;5C
+ const s = decodeOne("\x1b[1;5C").?.decoded.key;
+ try std.testing.expectEqual(KeyCode.right, s.code);
+ try std.testing.expect(s.mods.ctrl);
+}
+
+test "csi tilde delete / pageup / pagedown" {
+ try std.testing.expectEqual(KeyCode.delete, decodeOne("\x1b[3~").?.decoded.key.code);
+ try std.testing.expectEqual(KeyCode.page_up, decodeOne("\x1b[5~").?.decoded.key.code);
+ try std.testing.expectEqual(KeyCode.page_down, decodeOne("\x1b[6~").?.decoded.key.code);
+}
+
+test "ss3 arrows and f-keys" {
+ try std.testing.expectEqual(KeyCode.up, decodeOne("\x1bOA").?.decoded.key.code);
+ try std.testing.expectEqual(KeyCode.f1, decodeOne("\x1bOP").?.decoded.key.code);
+}
+
+test "lone escape" {
+ // ESC followed by a non-sequence byte: Alt+x is decoded preferentially.
+ const alt = decodeOne("\x1bx").?.decoded.key;
+ try std.testing.expect(alt.mods.alt);
+ try std.testing.expectEqual(@as(u21, 'x'), alt.code.char);
+}
+
+test "incomplete csi needs more bytes" {
+ try std.testing.expect(decodeOne("\x1b[1;5") == null);
+ try std.testing.expect(decodeOne("\x1b[") == null);
+}
+
+test "kitty shift+enter distinguishes from plain enter" {
+ // CSI 13 ; 2 u -> Enter with shift.
+ const s = decodeOne("\x1b[13;2u").?.decoded.key;
+ try std.testing.expectEqual(KeyCode.enter, s.code);
+ try std.testing.expect(s.mods.shift);
+ // Plain enter has no shift.
+ try std.testing.expect(!decodeOne("\r").?.decoded.key.mods.shift);
+}
+
+test "kitty release event" {
+ // CSI 97 ; 1 : 3 u -> 'a' release.
+ const s = decodeOne("\x1b[97;1:3u").?.decoded.key;
+ try std.testing.expectEqual(@as(u21, 'a'), s.code.char);
+ try std.testing.expectEqual(key.KeyEvent.release, s.event);
+}
+
+test "bracketed paste surfaces literal text" {
+ const input = paste_begin ++ "hello\nworld" ++ paste_end ++ "x";
+ const s = decodeOne(input).?;
+ try std.testing.expectEqualStrings("hello\nworld", s.decoded.paste);
+ // Next decode should land on the trailing 'x'.
+ const rest = input[s.consumed..];
+ try std.testing.expectEqual(@as(u21, 'x'), decodeOne(rest).?.decoded.key.code.char);
+}
+
+test "incomplete paste needs more bytes" {
+ const input = paste_begin ++ "partial";
+ try std.testing.expect(decodeOne(input) == null);
+}
+
+test "splitter: batched read yields individual keys" {
+ // Up, Down, 'a' in one buffer.
+ const input = "\x1b[A\x1b[Ba";
+ var off: usize = 0;
+ const s1 = decodeOne(input[off..]).?;
+ try std.testing.expectEqual(KeyCode.up, s1.decoded.key.code);
+ off += s1.consumed;
+ const s2 = decodeOne(input[off..]).?;
+ try std.testing.expectEqual(KeyCode.down, s2.decoded.key.code);
+ off += s2.consumed;
+ const s3 = decodeOne(input[off..]).?;
+ try std.testing.expectEqual(@as(u21, 'a'), s3.decoded.key.code.char);
+ off += s3.consumed;
+ try std.testing.expectEqual(input.len, off);
+}
diff --git a/src/tui_key.zig b/src/tui_key.zig
new file mode 100644
index 0000000..e5056cd
--- /dev/null
+++ b/src/tui_key.zig
@@ -0,0 +1,129 @@
+//! Input value types for the TUI.
+//!
+//! This is the *rich* key model. The P1 decoder (`tui_input.zig`) only
+//! populates a subset of it (printable chars, enter, backspace, arrows,
+//! home/end, escape, Ctrl+C, Ctrl+D), but the model is intentionally complete
+//! so later phases (full Kitty disambiguation, key-release events, super/hyper
+//! modifiers) can populate the rest without changing the type. Components
+//! built on this model will keep compiling.
+
+const std = @import("std");
+
+/// A logical key, independent of the raw bytes that produced it.
+///
+/// `char` carries a Unicode codepoint (the printable case). All other
+/// variants are non-printable named keys. The decoder resolves the *display
+/// text* of a printable key separately into `Key.text` (so e.g. a pasted or
+/// composed grapheme can be carried verbatim), while `char` holds the single
+/// decoded codepoint.
+pub const KeyCode = union(enum) {
+ /// A printable character, as a Unicode codepoint.
+ char: u21,
+ enter,
+ escape,
+ tab,
+ backspace,
+ delete,
+ up,
+ down,
+ left,
+ right,
+ home,
+ end,
+ page_up,
+ page_down,
+ f1,
+ f2,
+ f3,
+ f4,
+ f5,
+ f6,
+ f7,
+ f8,
+ f9,
+ f10,
+ f11,
+ f12,
+};
+
+/// Modifier flags. Packed so it round-trips cheaply and compares by value.
+///
+/// `super` is Cmd/Win, `hyper` is the (rare) Hyper modifier; both are only
+/// expressible under protocols like Kitty's, so the P1 decoder leaves them
+/// false. They exist so the model can represent them later.
+pub const Mods = packed struct {
+ ctrl: bool = false,
+ alt: bool = false,
+ shift: bool = false,
+ super: bool = false,
+ hyper: bool = false,
+
+ pub const none: Mods = .{};
+
+ pub fn eql(a: Mods, b: Mods) bool {
+ return @as(u5, @bitCast(a)) == @as(u5, @bitCast(b));
+ }
+
+ pub fn any(self: Mods) bool {
+ return @as(u5, @bitCast(self)) != 0;
+ }
+};
+
+/// Press / repeat / release. Terminals that don't report key-release (most,
+/// without the Kitty protocol) only ever produce `.press`. A component opts
+/// into receiving `.release` via `Component.wantsKeyRelease`.
+pub const KeyEvent = enum {
+ press,
+ repeat,
+ release,
+};
+
+/// A fully decoded key.
+///
+/// `text` is the resolved text to insert for a printable key (UTF-8 encoding
+/// of `code.char`, or pasted text surfaced as a literal run). It is null for
+/// non-printable keys. `text`, when non-null, is borrowed from the input
+/// buffer the decoder was handed; callers must copy it if they need it to
+/// outlive that buffer.
+pub const Key = struct {
+ code: KeyCode,
+ mods: Mods = .{},
+ event: KeyEvent = .press,
+ text: ?[]const u8 = null,
+
+ /// Convenience: is this a plain (unmodified) press of `code`?
+ pub fn isPlain(self: Key, code: KeyCode) bool {
+ return self.event == .press and !self.mods.any() and std.meta.eql(self.code, code);
+ }
+
+ /// Convenience: Ctrl+<letter> press, e.g. `isCtrl('c')`. `letter` is
+ /// matched case-insensitively against the printable codepoint.
+ pub fn isCtrl(self: Key, letter: u8) bool {
+ if (self.event == .release) return false;
+ if (!self.mods.ctrl) return false;
+ return switch (self.code) {
+ .char => |cp| cp == std.ascii.toLower(@intCast(letter & 0x7f)) or
+ cp == std.ascii.toUpper(@intCast(letter & 0x7f)),
+ else => false,
+ };
+ }
+};
+
+test "Mods eql / any" {
+ try std.testing.expect(Mods.none.eql(.{}));
+ try std.testing.expect(!Mods.none.any());
+ const c: Mods = .{ .ctrl = true };
+ try std.testing.expect(c.any());
+ try std.testing.expect(!c.eql(.{ .shift = true }));
+}
+
+test "Key.isPlain / isCtrl" {
+ const a: Key = .{ .code = .{ .char = 'a' } };
+ try std.testing.expect(a.isPlain(.{ .char = 'a' }));
+ try std.testing.expect(!a.isPlain(.enter));
+
+ const ctrl_c: Key = .{ .code = .{ .char = 'c' }, .mods = .{ .ctrl = true } };
+ try std.testing.expect(ctrl_c.isCtrl('c'));
+ try std.testing.expect(ctrl_c.isCtrl('C'));
+ try std.testing.expect(!ctrl_c.isCtrl('d'));
+}
diff --git a/src/tui_terminal.zig b/src/tui_terminal.zig
new file mode 100644
index 0000000..c4545e9
--- /dev/null
+++ b/src/tui_terminal.zig
@@ -0,0 +1,405 @@
+//! Terminal control for the TUI.
+//!
+//! Responsibilities:
+//! - enter / exit raw mode via termios (Unix only; Windows out of scope)
+//! - restore the original termios on normal exit AND on crash:
+//! * `defer term.deinit()` for the happy path
+//! * SIGINT / SIGTERM / SIGHUP handlers that restore and re-raise
+//! * a panic hook (`installPanicRestore`) the program's panic handler
+//! can call so a Zig panic still restores the terminal
+//! Restore is idempotent — calling it twice is safe and the second call
+//! is a no-op.
+//! - NEVER use the alternate screen buffer (no `?1049h` / `?47h`). We render
+//! inline in the primary buffer.
+//! - synchronized-output framing, cursor movement, line/screen clearing,
+//! cursor show/hide, absolute positioning.
+//! - query the window size (TIOCGWINSZ) and expose it; expose a SIGWINCH
+//! "resized" flag the render engine can poll.
+//! - best-effort capability detection (synchronized output, Kitty keyboard).
+//!
+//! All escape output goes straight to the tty fd via `std.c.write` (libc is
+//! linked). The signal/panic restore path also uses `std.c.write` /
+//! `std.c.tcsetattr`, which are async-signal-safe.
+//!
+//! Threading / global-state note: termios restore from a signal handler needs
+//! the saved state at a fixed address. We keep a single file-scope
+//! `active_restore` pointer to the live `Terminal`'s saved termios + fd. This
+//! means ONE `Terminal` is "active" for signal restore at a time (the TUI only
+//! ever creates one). `init` registers it; `deinit` clears it.
+
+const std = @import("std");
+const builtin = @import("builtin");
+const posix = std.posix;
+
+/// Raw escape sequences. Kept as named constants so the engine and tests can
+/// reference them and so the "no alt screen" rule is auditable in one place.
+pub const seq = struct {
+ /// Begin / end synchronized output (DEC private mode 2026). Wrapping a
+ /// frame in these tells the terminal to buffer and present atomically,
+ /// avoiding tearing.
+ pub const sync_begin = "\x1b[?2026h";
+ pub const sync_end = "\x1b[?2026l";
+
+ /// Full-redraw clear: clear screen, home cursor, AND clear scrollback
+ /// (`3J`). Used on first paint / width change / height change.
+ pub const full_clear = "\x1b[2J\x1b[H\x1b[3J";
+
+ /// Carriage return (column 0, same row).
+ pub const carriage_return = "\r";
+ /// Clear from cursor to end of line.
+ pub const clear_line = "\x1b[K";
+ /// Clear from cursor to end of screen.
+ pub const clear_to_end = "\x1b[J";
+
+ pub const hide_cursor = "\x1b[?25l";
+ pub const show_cursor = "\x1b[?25h";
+};
+
+pub const Size = struct {
+ rows: u16,
+ cols: u16,
+};
+
+pub const Error = error{
+ NotATty,
+ TermiosGetFailed,
+ TermiosSetFailed,
+};
+
+// ---- Global state for signal/panic restore -------------------------------
+
+/// Minimal restore record: the fd whose termios we need to restore plus the
+/// saved original. Pointed to by `active_restore` while a Terminal is live.
+const Restore = struct {
+ fd: posix.fd_t,
+ original: posix.termios,
+ /// Set once restore has run, so it's idempotent across signal + deinit.
+ done: bool = false,
+};
+
+/// The single active restore record (see module note). `null` when no Terminal
+/// is in raw mode.
+var active_restore: ?*Restore = null;
+
+/// Async-signal-safe restore: reset termios and show the cursor. Safe to call
+/// multiple times. Uses only `tcsetattr` + `write`, both signal-safe.
+fn doRestore(r: *Restore) void {
+ if (r.done) return;
+ r.done = true;
+ // Best-effort; ignore errors (we may be unwinding a crash).
+ posix.tcsetattr(r.fd, .FLUSH, r.original) catch {};
+ _ = std.c.write(r.fd, seq.show_cursor.ptr, seq.show_cursor.len);
+}
+
+fn signalRestoreHandler(sig: posix.SIG) callconv(.c) void {
+ if (active_restore) |r| doRestore(r);
+ // Re-raise with the default disposition so the process dies as expected.
+ posix.sigaction(sig, &.{
+ .handler = .{ .handler = posix.SIG.DFL },
+ .mask = posix.sigemptyset(),
+ .flags = 0,
+ }, null);
+ _ = std.c.raise(sig);
+}
+
+/// SIGWINCH flag. Set by the handler, polled (and cleared) by the engine via
+/// `Terminal.takeResized`.
+var winch_flag: std.atomic.Value(bool) = .init(false);
+
+fn winchHandler(sig: posix.SIG) callconv(.c) void {
+ _ = sig;
+ winch_flag.store(true, .seq_cst);
+}
+
+// ---- Capability detection -------------------------------------------------
+
+pub const Capabilities = struct {
+ /// Terminal is believed to support synchronized output (mode 2026).
+ synchronized_output: bool,
+ /// Terminal is believed to support the Kitty keyboard protocol.
+ kitty_keyboard: bool,
+};
+
+/// Best-effort capability detection by environment sniffing.
+///
+/// We do NOT block on a terminal query response here (that risks hanging on
+/// terminals that don't reply). Assumptions:
+/// - Synchronized output is widely supported by modern emulators; we treat
+/// it as available unless `TERM` looks like a dumb/legacy terminal. The
+/// sequences are no-ops on terminals that don't understand them, so a
+/// false positive is harmless.
+/// - Kitty keyboard support is inferred from `TERM`/`TERM_PROGRAM` env hints
+/// (kitty, foot, ghostty, wezterm, recent alacritty). A false negative
+/// just means we fall back to legacy decoding; a false positive is safe
+/// because the enable sequence is ignored by terminals that don't grok it
+/// and our decoder degrades gracefully on unrecognized input.
+pub fn detectCapabilities(env: std.process.Environ.Map) Capabilities {
+ const term = env.get("TERM") orelse "";
+ const term_program = env.get("TERM_PROGRAM") orelse "";
+
+ const dumb = std.mem.eql(u8, term, "") or
+ std.mem.eql(u8, term, "dumb");
+
+ const kitty = containsAny(term, &.{ "kitty", "foot", "ghostty", "wezterm" }) or
+ containsAny(term_program, &.{ "kitty", "foot", "ghostty", "WezTerm", "ghostty" }) or
+ env.get("KITTY_WINDOW_ID") != null;
+
+ return .{
+ .synchronized_output = !dumb,
+ .kitty_keyboard = kitty,
+ };
+}
+
+fn containsAny(haystack: []const u8, needles: []const []const u8) bool {
+ for (needles) |n| {
+ if (std.ascii.indexOfIgnoreCase(haystack, n) != null) return true;
+ }
+ return false;
+}
+
+// ---- The Terminal --------------------------------------------------------
+
+pub const Terminal = struct {
+ fd: posix.fd_t,
+ restore: Restore,
+ /// Whether raw mode is currently engaged.
+ raw_mode: bool = false,
+ size: Size,
+ caps: Capabilities,
+ /// Saved prior SIGWINCH disposition is not restored (handler is process-
+ /// global and harmless); we only track that we installed ours.
+ installed_signals: bool = false,
+
+ /// Open the controlling terminal on `fd` (typically stdin) and capture its
+ /// current termios. Does NOT enter raw mode yet — call `enableRawMode`.
+ pub fn init(fd: posix.fd_t, env: std.process.Environ.Map) Error!Terminal {
+ if (std.c.isatty(fd) == 0) return Error.NotATty;
+ const original = posix.tcgetattr(fd) catch return Error.TermiosGetFailed;
+ const size = querySize(fd) orelse Size{ .rows = 24, .cols = 80 };
+ return .{
+ .fd = fd,
+ .restore = .{ .fd = fd, .original = original },
+ .size = size,
+ .caps = detectCapabilities(env),
+ };
+ }
+
+ /// Enter raw mode and install signal handlers + the global restore record.
+ /// Hides nothing on its own; the engine controls the cursor.
+ pub fn enableRawMode(self: *Terminal) Error!void {
+ if (self.raw_mode) return;
+
+ var raw = self.restore.original;
+ // Input flags: no break->int, no CR->NL, no parity check, no strip,
+ // no flow control.
+ raw.iflag.BRKINT = false;
+ raw.iflag.ICRNL = false;
+ raw.iflag.INPCK = false;
+ raw.iflag.ISTRIP = false;
+ raw.iflag.IXON = false;
+ // Output: keep post-processing OFF so our explicit \r\n is exact.
+ raw.oflag.OPOST = false;
+ // Control: 8-bit chars.
+ raw.cflag.CSIZE = .CS8;
+ // Local flags: no echo, non-canonical, no signals from keys (we read
+ // Ctrl+C ourselves), no extended processing.
+ raw.lflag.ECHO = false;
+ raw.lflag.ICANON = false;
+ raw.lflag.ISIG = false;
+ raw.lflag.IEXTEN = false;
+ // Non-blocking-ish read: return as soon as >=1 byte is available.
+ raw.cc[@intFromEnum(posix.V.MIN)] = 1;
+ raw.cc[@intFromEnum(posix.V.TIME)] = 0;
+
+ posix.tcsetattr(self.fd, .FLUSH, raw) catch return Error.TermiosSetFailed;
+ self.raw_mode = true;
+ self.restore.done = false;
+ active_restore = &self.restore;
+ self.installSignalHandlers();
+ }
+
+ fn installSignalHandlers(self: *Terminal) void {
+ const restore_act = posix.Sigaction{
+ .handler = .{ .handler = signalRestoreHandler },
+ .mask = posix.sigemptyset(),
+ .flags = 0,
+ };
+ posix.sigaction(posix.SIG.INT, &restore_act, null);
+ posix.sigaction(posix.SIG.TERM, &restore_act, null);
+ posix.sigaction(posix.SIG.HUP, &restore_act, null);
+
+ const winch_act = posix.Sigaction{
+ .handler = .{ .handler = winchHandler },
+ .mask = posix.sigemptyset(),
+ .flags = 0,
+ };
+ posix.sigaction(posix.SIG.WINCH, &winch_act, null);
+ self.installed_signals = true;
+ }
+
+ /// Restore the terminal: leave raw mode, show the cursor. Idempotent.
+ /// Safe to call from `defer`. Does not touch the alternate screen (we
+ /// never entered it).
+ pub fn deinit(self: *Terminal) void {
+ doRestore(&self.restore);
+ self.raw_mode = false;
+ if (active_restore == &self.restore) active_restore = null;
+ }
+
+ // -- output helpers -----------------------------------------------------
+
+ /// Raw write of `bytes` to the tty. Returns the count written (short
+ /// writes are possible on a tty but rare for small control sequences;
+ /// callers writing large frames should loop or use a buffered writer).
+ pub fn writeAll(self: *Terminal, bytes: []const u8) void {
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const n = std.c.write(self.fd, bytes.ptr + off, bytes.len - off);
+ if (n <= 0) break;
+ off += @intCast(n);
+ }
+ }
+
+ pub fn beginSync(self: *Terminal) void {
+ if (self.caps.synchronized_output) self.writeAll(seq.sync_begin);
+ }
+
+ pub fn endSync(self: *Terminal) void {
+ if (self.caps.synchronized_output) self.writeAll(seq.sync_end);
+ }
+
+ pub fn hideCursor(self: *Terminal) void {
+ self.writeAll(seq.hide_cursor);
+ }
+
+ pub fn showCursor(self: *Terminal) void {
+ self.writeAll(seq.show_cursor);
+ }
+
+ pub fn carriageReturn(self: *Terminal) void {
+ self.writeAll(seq.carriage_return);
+ }
+
+ pub fn clearLine(self: *Terminal) void {
+ self.writeAll(seq.clear_line);
+ }
+
+ pub fn clearToEndOfScreen(self: *Terminal) void {
+ self.writeAll(seq.clear_to_end);
+ }
+
+ /// Full redraw clear (clears scrollback too). Use only on first paint /
+ /// width change / height change.
+ pub fn fullClear(self: *Terminal) void {
+ self.writeAll(seq.full_clear);
+ }
+
+ /// Move the cursor up `n` rows (no-op when `n == 0`). Writes into `buf`.
+ pub fn cursorUp(self: *Terminal, n: usize) void {
+ if (n == 0) return;
+ var buf: [16]u8 = undefined;
+ const s = std.fmt.bufPrint(&buf, "\x1b[{d}A", .{n}) catch return;
+ self.writeAll(s);
+ }
+
+ /// Move the cursor down `n` rows (no-op when `n == 0`).
+ pub fn cursorDown(self: *Terminal, n: usize) void {
+ if (n == 0) return;
+ var buf: [16]u8 = undefined;
+ const s = std.fmt.bufPrint(&buf, "\x1b[{d}B", .{n}) catch return;
+ self.writeAll(s);
+ }
+
+ /// Move to absolute (row, col), 1-based (CUP).
+ pub fn moveTo(self: *Terminal, row: usize, col: usize) void {
+ var buf: [32]u8 = undefined;
+ const s = std.fmt.bufPrint(&buf, "\x1b[{d};{d}H", .{ row, col }) catch return;
+ self.writeAll(s);
+ }
+
+ // -- size / resize ------------------------------------------------------
+
+ /// Re-query the window size from the kernel and update `self.size`.
+ /// Returns the new size. Falls back to the previous size on failure.
+ pub fn refreshSize(self: *Terminal) Size {
+ if (querySize(self.fd)) |s| self.size = s;
+ return self.size;
+ }
+
+ /// Poll-and-clear the SIGWINCH flag. Returns true exactly once per resize
+ /// burst the engine hasn't yet observed.
+ pub fn takeResized(self: *Terminal) bool {
+ _ = self;
+ return winch_flag.swap(false, .seq_cst);
+ }
+
+ /// Install a panic-time restore. Call this from the program's panic
+ /// handler (or once at startup if your panic handler is the default and
+ /// you want best effort). It restores the currently-active terminal.
+ pub fn installPanicRestore() void {
+ if (active_restore) |r| doRestore(r);
+ }
+};
+
+/// Query the terminal size via TIOCGWINSZ. Returns null on failure.
+fn querySize(fd: posix.fd_t) ?Size {
+ var ws: posix.winsize = undefined;
+ const rc = std.c.ioctl(fd, std.c.T.IOCGWINSZ, @intFromPtr(&ws));
+ if (rc != 0) return null;
+ if (ws.row == 0 or ws.col == 0) return null;
+ return .{ .rows = ws.row, .cols = ws.col };
+}
+
+// ---- Tests ----------------------------------------------------------------
+
+test "no alt screen sequences anywhere in seq" {
+ // Guardrail: the alternate-screen private modes must never appear.
+ inline for (.{
+ seq.sync_begin, seq.sync_end, seq.full_clear, seq.clear_line,
+ seq.clear_to_end, seq.hide_cursor, seq.show_cursor,
+ }) |s| {
+ try std.testing.expect(std.mem.indexOf(u8, s, "?1049") == null);
+ try std.testing.expect(std.mem.indexOf(u8, s, "?47") == null);
+ }
+}
+
+test "full clear includes scrollback (3J)" {
+ try std.testing.expect(std.mem.indexOf(u8, seq.full_clear, "\x1b[3J") != null);
+}
+
+test "sync framing values" {
+ try std.testing.expectEqualStrings("\x1b[?2026h", seq.sync_begin);
+ try std.testing.expectEqualStrings("\x1b[?2026l", seq.sync_end);
+}
+
+test "capability detection: dumb terminal" {
+ var env = std.process.Environ.Map.init(std.testing.allocator);
+ defer env.deinit();
+ try env.put("TERM", "dumb");
+ const caps = detectCapabilities(env);
+ try std.testing.expect(!caps.synchronized_output);
+ try std.testing.expect(!caps.kitty_keyboard);
+}
+
+test "capability detection: kitty" {
+ var env = std.process.Environ.Map.init(std.testing.allocator);
+ defer env.deinit();
+ try env.put("TERM", "xterm-kitty");
+ const caps = detectCapabilities(env);
+ try std.testing.expect(caps.synchronized_output);
+ try std.testing.expect(caps.kitty_keyboard);
+}
+
+test "resize flag round-trips" {
+ winch_flag.store(false, .seq_cst);
+ var t: Terminal = .{
+ .fd = 0,
+ .restore = .{ .fd = 0, .original = undefined },
+ .size = .{ .rows = 24, .cols = 80 },
+ .caps = .{ .synchronized_output = true, .kitty_keyboard = false },
+ };
+ try std.testing.expect(!t.takeResized());
+ winch_flag.store(true, .seq_cst);
+ try std.testing.expect(t.takeResized());
+ try std.testing.expect(!t.takeResized());
+}
diff --git a/src/tui_theme.zig b/src/tui_theme.zig
new file mode 100644
index 0000000..1acbca0
--- /dev/null
+++ b/src/tui_theme.zig
@@ -0,0 +1,136 @@
+//! Centralized terminal color / style helpers for the TUI.
+//!
+//! Today the CLI emits raw ANSI escapes inline (e.g. `\x1b[2m` for dim,
+//! `\x1b[36m` for tool/cyan). This module collects those into one named
+//! palette so components don't hand-roll escapes. This is *centralization*,
+//! not a configurable theming system — the palette is fixed at comptime.
+//!
+//! Ergonomic shape (mirrors pi's `fg(name, text)` / `bg(name, text)`):
+//! - `Theme.fg(name)` / `Theme.bg(name)` return a `Style` value.
+//! - `Style.open()` / `Style.close()` return the raw escape strings, for
+//! callers that build lines manually (the common TUI case, where a line is
+//! assembled into a buffer).
+//! - `Style.wrap(alloc, text)` returns an allocator-owned
+//! `open ++ text ++ close` string, for convenience.
+//! - `Style.write(writer, text)` writes `open ++ text ++ close` into a
+//! `*std.Io.Writer`, for streaming callers.
+//!
+//! All escapes are static strings, so `open()`/`close()` never allocate and
+//! never fail. Only `wrap` allocates.
+
+const std = @import("std");
+
+/// Reset-all sequence. Closing any style emits this.
+pub const reset = "\x1b[0m";
+
+/// Named styles the P1 components need. Each maps to a fixed ANSI open
+/// sequence; the close is always `reset`.
+pub const StyleName = enum {
+ /// Dimmed text — used for thinking blocks and status/retry lines.
+ dim,
+ /// Assistant message body. Plain (no escape); present so call sites can
+ /// be explicit rather than emitting nothing.
+ assistant,
+ /// User-entered text.
+ user,
+ /// Tool invocation prefix / cyan accent.
+ tool,
+ /// Footer / chrome line.
+ footer,
+ /// The virtual cursor: reverse video block.
+ cursor,
+ /// Error / retry text.
+ err,
+};
+
+/// A resolved style: the opening escape and (implicitly) the `reset` close.
+pub const Style = struct {
+ open_seq: []const u8,
+ /// When true the style is "empty" (no visible escape); `open()` returns
+ /// "" and `close()` returns "" so wrapping plain text is a no-op.
+ is_plain: bool = false,
+
+ pub fn open(self: Style) []const u8 {
+ return self.open_seq;
+ }
+
+ pub fn close(self: Style) []const u8 {
+ return if (self.is_plain) "" else reset;
+ }
+
+ /// Allocate `open ++ text ++ close`. Caller owns the returned slice.
+ pub fn wrap(self: Style, alloc: std.mem.Allocator, text: []const u8) ![]u8 {
+ return std.fmt.allocPrint(alloc, "{s}{s}{s}", .{ self.open(), text, self.close() });
+ }
+
+ /// Write `open ++ text ++ close` into `writer`.
+ pub fn write(self: Style, writer: *std.Io.Writer, text: []const u8) !void {
+ try writer.writeAll(self.open());
+ try writer.writeAll(text);
+ try writer.writeAll(self.close());
+ }
+};
+
+/// The (fixed) theme. A value type so the engine can hold one by value.
+pub const Theme = struct {
+ /// Resolve a foreground style by name.
+ pub fn fg(self: Theme, name: StyleName) Style {
+ _ = self;
+ return styleFor(name);
+ }
+
+ /// Resolve a background-oriented style by name. For this fixed palette the
+ /// only background-style use is `cursor` (reverse video already covers
+ /// fg+bg); other names fall back to the same escape as `fg`. Kept as a
+ /// distinct entry point so future call sites read clearly and a richer
+ /// palette can diverge later without churn.
+ pub fn bg(self: Theme, name: StyleName) Style {
+ _ = self;
+ return styleFor(name);
+ }
+};
+
+/// The single shared theme instance.
+pub const default: Theme = .{};
+
+fn styleFor(name: StyleName) Style {
+ return switch (name) {
+ .dim => .{ .open_seq = "\x1b[2m" },
+ // Plain assistant text: no escape, no reset.
+ .assistant => .{ .open_seq = "", .is_plain = true },
+ .user => .{ .open_seq = "\x1b[39m" },
+ .tool => .{ .open_seq = "\x1b[36m" },
+ .footer => .{ .open_seq = "\x1b[2m" },
+ // Reverse video — the virtual cursor block.
+ .cursor => .{ .open_seq = "\x1b[7m" },
+ .err => .{ .open_seq = "\x1b[31m" },
+ };
+}
+
+test "open/close are static and reversible" {
+ const t = default;
+ const dim = t.fg(.dim);
+ try std.testing.expectEqualStrings("\x1b[2m", dim.open());
+ try std.testing.expectEqualStrings(reset, dim.close());
+}
+
+test "plain assistant style emits nothing" {
+ const t = default;
+ const a = t.fg(.assistant);
+ try std.testing.expectEqualStrings("", a.open());
+ try std.testing.expectEqualStrings("", a.close());
+}
+
+test "wrap allocates open++text++close" {
+ const t = default;
+ const s = try t.fg(.tool).wrap(std.testing.allocator, "read");
+ defer std.testing.allocator.free(s);
+ try std.testing.expectEqualStrings("\x1b[36mread\x1b[0m", s);
+}
+
+test "write streams open++text++close" {
+ var buf: [64]u8 = undefined;
+ var w = std.Io.Writer.fixed(&buf);
+ try default.fg(.cursor).write(&w, "X");
+ try std.testing.expectEqualStrings("\x1b[7mX\x1b[0m", w.buffered());
+}