//! 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: ` 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: 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); }