summaryrefslogtreecommitdiff
path: root/src/tui_app.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tui_app.zig')
-rw-r--r--src/tui_app.zig824
1 files changed, 824 insertions, 0 deletions
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);
+}