diff options
Diffstat (limited to 'src/tui_app.zig')
| -rw-r--r-- | src/tui_app.zig | 824 |
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); +} |
