//! 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 / compaction display (P2) //! //! The full built-in component set is wired here: //! - a Thinking block streams its deltas into a dedicated dim `Thinking` //! component, //! - a ToolUse block drives a `ToolUse` component (one per call) through its //! `tool (?)…` -> `tool () ` -> `+ ` progression; //! the component is collapsible via a GLOBAL ctrl+o toggle (default //! collapsed, showing the last 5 output lines), //! - a CompactionSummary block (or a compaction provider-retry) renders a //! `CompactionSummary` component. //! //! ## Tool-result correlation (no "active component") //! //! ToolResult blocks do NOT arrive via `block_start`/`block_complete`; the //! agent assembles them and delivers them together in the //! `tool_dispatch_complete` event's user `Message`. Each `ToolResultBlock` //! carries a `tool_use_id` linking back to its `ToolUseBlock.id`. The router //! therefore keeps a SECOND map, tool-call id -> *ToolUse component, populated //! when the tool name/id resolve; on `tool_dispatch_complete` we walk the //! result blocks and feed each one's text to the matching component by id. //! Nothing is keyed by a single "current" component (plan §6 invariant). 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 ui_event = @import("tui_event.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 Welcome = components.Welcome; const Thinking = components.Thinking; const CompactionSummary = components.CompactionSummary; const ToolUse = components.ToolUse; const Component = component.Component; const EventBus = ui_event.EventBus; const UIEvent = ui_event.Event; const Payload = ui_event.Payload; 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 // =========================================================================== /// The concrete built-in component a transcript entry owns. This is panto's /// DEFAULT component for that boundary; deltas are always driven into this /// typed box regardless of whether an extension handler replaced what the /// engine renders (see `Entry.override`). /// /// `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 and prefix a dim/style run in the seeded text). const EntryKind = union(enum) { user: *UserText, /// Assistant message body (streaming text block). assistant: *AssistantText, /// A dim status/retry line (provider retries, command output, errors). status: *AssistantText, /// Session-start banner. welcome: *Welcome, /// Streaming thinking block (dim). thinking: *Thinking, /// A tool call + result (collapsible). tool: *ToolUse, /// A compaction summary. compaction: *CompactionSummary, /// The default component for this kind (panto's built-in render). fn defaultComp(self: EntryKind) Component { return switch (self) { .user => |p| p.comp(), .assistant => |p| p.comp(), .status => |p| p.comp(), .welcome => |p| p.comp(), .thinking => |p| p.comp(), .tool => |p| p.comp(), .compaction => |p| p.comp(), }; } fn deinit(self: EntryKind, alloc: std.mem.Allocator) void { switch (self) { inline else => |p| { p.deinit(); alloc.destroy(p); }, } } }; /// The distinct lifecycle events a single transcript entry can see, used as a /// per-entry FIRE-ONCE guard set. A given lifecycle event must fire at most /// once per slot even when the underlying libpanto boundary could be hit twice /// (e.g. `tool` is fired at block-start, but `tool_call_complete` and the /// fallback also resolve the name — each named event fires exactly once). /// /// `*_delta` events are intentionally ABSENT: deltas fire repeatedly by design /// (once per streaming chunk), so they are never guarded. /// Which streaming text-block kind a text-lifecycle helper targets. Named (not /// an anonymous enum) so the two helpers that take it share one type. const TextKind = enum { assistant, thinking }; const Lifecycle = enum { session_start, user_message, thinking, thinking_complete, assistant_text, assistant_text_complete, tool, tool_details, tool_call_complete, tool_result, compaction, }; /// 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`. /// /// ## Drive-by-default-box / render-by-override split /// /// `kind` is panto's built-in DEFAULT component for the boundary, and the /// typed box panto always DRIVES (deltas/details/result mutate `kind.`, /// and `TurnRouter` holds the same typed pointer). `override`, when set, is the /// component an extension handler chose for one of this entry's lifecycle /// events (§7): the ENGINE RENDERS the override instead of the default, while /// panto KEEPS DRIVING the default typed box. The override is expected to WRAP /// the default and render through it; a swapped-in component that ignores the /// default simply renders its own content while the default keeps accumulating /// (harmless). With no handler registered, `override` is null and rendering is /// byte-identical to the pre-event-system behavior. /// /// ## Ownership boundary (read before touching `override`) /// /// The App/transcript owns ONLY the `kind` default boxes (heap-allocated here, /// freed on `deinit`). It does NOT own `override` components: an override is /// owned by whoever created it — the registering extension or, in the next /// sub-phase, the Lua bridge. Therefore the App MUST NOT free an override. /// /// When an override is REPLACED by a newer override (a second handler swap on /// the same slot), the previously-overriding component is no longer referenced /// by this entry and its owner needs to release it. In THIS sub-phase all /// overrides are native test/extension components with their own lifetime, so /// the App simply drops the old reference. The release POINT is `setOverride` /// below: when the Lua bridge lands, it registers a release callback there so /// a superseded bridged component's Lua ref/cache is dropped (no per-call /// leak). See `App.override_release` and the TODO at `setOverride`. const Entry = struct { kind: EntryKind, /// Extension-chosen render component for this entry, or null for the /// built-in default. The transcript does NOT own this component's storage /// (the registering extension / Lua bridge does); it owns only the `kind` /// boxes. See the ownership note above. override: ?Component = null, /// Per-entry fire-once guard: which lifecycle events have already fired for /// this slot. `*_delta` events are not tracked (they fire repeatedly). fired: std.EnumSet(Lifecycle) = std.EnumSet(Lifecycle).initEmpty(), /// The component the ENGINE renders: the extension override if present, /// else panto's built-in default. fn comp(self: Entry) Component { return self.override orelse self.kind.defaultComp(); } fn deinit(self: Entry, alloc: std.mem.Allocator) void { self.kind.deinit(alloc); } }; // =========================================================================== // 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, /// The extension UI event bus (plan §7). Built-in events are fired through /// this at each component-creation boundary BEFORE first paint, so a /// registered handler can replace/wrap the chosen component. With no /// handlers registered it is a pure pass-through: every boundary keeps its /// built-in default component and rendering is unchanged. The Lua bridge /// (later sub-phase) registers handlers into this same bus. bus: EventBus, /// Global tool-use collapse state (ctrl+o). Applies to EVERY tool-use /// component at once (plan: collapse is a global toggle). Default true: /// tool output starts collapsed to its last few lines. tools_collapsed: bool = true, /// Optional override-release hook. When a slot's `override` is REPLACED by /// a newer override (a second handler swap on the same slot), the old /// override is no longer referenced by panto and its OWNER must release it. /// The App never owns overrides (see `Entry`'s ownership note), so it /// cannot free them itself. Instead, whoever creates overrides (the Lua /// bridge, in the next sub-phase) installs this callback; the App invokes /// it with the superseded component so the owner can drop its ref/cache. /// Null in this sub-phase (native overrides manage their own lifetime), so /// the swap simply drops the old reference — see `setOverride`. override_release_ctx: ?*anyopaque = null, override_release_fn: ?*const fn (ctx: *anyopaque, old: Component) void = null, /// 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), .bus = EventBus.init(alloc), .tools_collapsed = true, }; } pub fn deinit(self: *App) void { for (self.transcript.items) |e| e.deinit(self.alloc); self.transcript.deinit(self.alloc); self.router.deinit(); self.bus.deinit(); } /// Access the event bus so the embedder (and, later, the Lua bridge) can /// register handlers for built-in or custom events (plan §7). pub fn eventBus(self: *App) *EventBus { return &self.bus; } /// Install the override-release hook (see `App.override_release_fn`). The /// owner of override components (the Lua bridge) calls this so that, when a /// slot's override is replaced by a newer one, the superseded component is /// handed back for release. The App never frees overrides itself. pub fn setOverrideRelease( self: *App, ctx: *anyopaque, f: *const fn (ctx: *anyopaque, old: Component) void, ) void { self.override_release_ctx = ctx; self.override_release_fn = f; } /// 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. fn pushEntry(self: *App, entry: Entry) !void { try self.transcript.append(self.alloc, entry); try self.rebuildEngineList(); } /// Fire the creation-boundary event for a freshly-created component, then /// append the entry using whatever component the handler chain chose. This /// is the CREATION special-case of the general `fireForEntry` lifecycle /// fire: the entry does not exist yet, so we seed the event with the typed /// default, run handlers, and push the entry with the chosen override (if /// any) in one step. /// /// With no handlers registered, `emit` returns the seeded default and the /// override stays null — rendering is byte-identical to the /// pre-event-system behavior. /// /// `kind` is the typed default box (deltas always drive it). `name` + /// `payload` describe the event. The default component seeded into the /// event is `kind.defaultComp()`; the chosen component becomes the entry's /// render override iff a handler replaced it. `lc` is the fire-once tag /// recorded on the new entry. fn pushEntryFired(self: *App, kind: EntryKind, lc: Lifecycle, name: []const u8, payload: Payload) !void { const default = kind.defaultComp(); var ev = UIEvent.init(name, default, payload); const chosen = self.bus.emit(&ev); // Only record an override when a handler actually swapped the // component; equal ptr means the default survived (pass-through). const override: ?Component = blk: { if (chosen) |c| { if (c.ptr != default.ptr) break :blk c; } break :blk null; }; var entry: Entry = .{ .kind = kind, .override = override }; entry.fired.insert(lc); try self.pushEntry(entry); } /// Fire a lifecycle event for an EXISTING transcript entry (the general /// case; creation is the `pushEntryFired` special-case above). /// /// Per §7.2, the event is seeded with the slot's CURRENT rendered component /// (`entry.comp()` — a prior override if one was set, else the default), so /// `getComponent()` returns "whatever is current", not a frozen default. The /// handler chain runs; if the chosen component differs from the current /// one, we SWAP it in via `setOverride` (which records the new override, /// hands the old one back for release, and forces a full-takeover repaint). /// /// `lc`, when non-null, is a fire-once guard: the event fires at most once /// per slot for that tag. Pass null for repeatable events (`*_delta`). /// Returns true if the event actually fired (false when guarded-out). fn fireForEntry(self: *App, entry: *Entry, lc: ?Lifecycle, name: []const u8, payload: Payload) !bool { if (lc) |tag| { if (entry.fired.contains(tag)) return false; entry.fired.insert(tag); } const current = entry.comp(); var ev = UIEvent.init(name, current, payload); const chosen = self.bus.emit(&ev); if (chosen) |c| { if (c.ptr != current.ptr) try self.setOverride(entry, c); } return true; } /// Swap a slot's rendered component to `new` mid-stream (no "active /// component": same entry, same key; only WHICH component the entry renders /// changes). Three responsibilities (plan §7.4 revised): /// /// 1. RELEASE the outgoing override (if any). The App never owns /// overrides; their creator does. If an `override_release_fn` is /// installed (by the Lua bridge), hand the superseded override back so /// its owner drops the ref/cache — the leak-prevention point. With no /// hook installed (this sub-phase: native overrides with their own /// lifetime), we just drop the reference. The outgoing DEFAULT `kind` /// box is never released here — the entry still owns it and panto keeps /// driving it. /// TODO(lua-bridge): the bridge installs `setOverrideRelease` so this /// call site releases a superseded bridged component. /// 2. Record `new` as the entry's override. /// 3. Force the incoming component to FULLY TAKE OVER the rendered region /// (repaint from line 0, clearing orphaned lines from a taller /// predecessor). `rebuildEngineList` re-adds every component, which the /// engine treats as a layout change — it forces a full redraw, so the /// incoming component renders from scratch and stale rows are cleared. /// Native components are also dirty-from-0 on first render via /// `RenderCache`, so the incoming component reports `firstLineChanged /// = 0` regardless. fn setOverride(self: *App, entry: *Entry, new: Component) !void { if (entry.override) |old| { if (old.ptr != new.ptr) { if (self.override_release_fn) |f| f(self.override_release_ctx.?, old); } } entry.override = new; // Layout change => full redraw => full takeover + orphan clearing. 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, index: usize) !*AssistantText { const box = try self.alloc.create(AssistantText); box.* = AssistantText.init(self.alloc); try self.pushEntryFired( .{ .assistant = box }, .assistant_text, "assistant_text", .{ .assistant_text = .{ .index = index } }, ); 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); // Status lines are internal chrome (provider retries, command output, // errors) — NOT one of the §8 built-in events — so no event is fired. try self.pushEntry(.{ .kind = .{ .status = box } }); return box; } /// Spawn a user-message entry seeded with `text`. Fires `user_message`. 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.pushEntryFired( .{ .user = box }, .user_message, "user_message", .{ .user_message = .{ .text = text } }, ); } /// Spawn the session-start welcome banner. Fires `session_start`. Returns /// it so the caller can set its fields (version / cwd / model) afterward. fn spawnWelcome(self: *App, payload: Payload.SessionStart) !*Welcome { const box = try self.alloc.create(Welcome); box.* = Welcome.init(self.alloc); try self.pushEntryFired( .{ .welcome = box }, .session_start, "session_start", .{ .session_start = payload }, ); return box; } /// Spawn a streaming thinking entry. Keyed by block index in the router. /// Fires `thinking`. fn spawnThinking(self: *App, index: usize) !*Thinking { const box = try self.alloc.create(Thinking); box.* = Thinking.init(self.alloc); try self.pushEntryFired( .{ .thinking = box }, .thinking, "thinking", .{ .thinking = .{ .index = index } }, ); return box; } /// Spawn a tool-use entry at the ToolUse block-start boundary and FIRE the /// `tool` event immediately (name UNKNOWN; the component shows `tool (?)`). /// This is the creation boundary for the tool lifecycle: a handler that /// wants to claim a call regardless of name (or set up wrapping early) can /// `setComponent` here, before any content paints. Name-based claiming /// happens at the later `tool_details` event (§7.5), which can swap again. fn spawnTool(self: *App, index: usize) !*ToolUse { const box = try self.alloc.create(ToolUse); box.* = ToolUse.init(self.alloc); box.setCollapsed(self.tools_collapsed); // Fire `tool` at the creation boundary (name unknown => `tool (?)`). try self.pushEntryFired( .{ .tool = box }, .tool, "tool", .{ .tool = .{ .index = index } }, ); return box; } /// Locate the transcript entry whose tool component is `box`, or null. fn findToolEntry(self: *App, box: *ToolUse) ?*Entry { for (self.transcript.items) |*e| { switch (e.kind) { .tool => |p| if (p == box) return e, else => {}, } } return null; } /// Fire a tool-lifecycle event (`tool_details` / `tool_delta` / /// `tool_call_complete` / `tool_result`) for the entry backing `box`, /// driving the mid-stream swap path. `lc` is the fire-once tag (null for /// the repeatable `tool_delta`). A no-op when the box has no entry. fn fireToolLifecycle( self: *App, box: *ToolUse, lc: ?Lifecycle, name: []const u8, payload: Payload, ) !void { const entry = self.findToolEntry(box) orelse return; _ = try self.fireForEntry(entry, lc, name, payload); } /// Fire a thinking/assistant lifecycle event for the entry backing a /// streaming text block, by block index. `which` selects which `EntryKind` /// variant to match. A no-op when no matching entry exists. fn fireTextLifecycle( self: *App, index: usize, comptime which: TextKind, lc: ?Lifecycle, name: []const u8, payload: Payload, ) !void { const entry = self.findTextEntry(index, which) orelse return; _ = try self.fireForEntry(entry, lc, name, payload); } /// Locate the transcript entry for a streaming text block at `index`. fn findTextEntry(self: *App, index: usize, comptime which: TextKind) ?*Entry { const ref = self.router.get(index) orelse return null; switch (which) { .assistant => { const target = switch (ref) { .assistant => |p| p, else => return null, }; for (self.transcript.items) |*e| { if (e.kind == .assistant and e.kind.assistant == target) return e; } }, .thinking => { const target = switch (ref) { .thinking => |p| p, else => return null, }; for (self.transcript.items) |*e| { if (e.kind == .thinking and e.kind.thinking == target) return e; } }, } return null; } /// Spawn a compaction-summary entry seeded with `summary`. Fires /// `compaction`. fn spawnCompaction(self: *App, summary: []const u8) !*CompactionSummary { const box = try self.alloc.create(CompactionSummary); box.* = CompactionSummary.init(self.alloc); try box.setSummary(summary); try self.pushEntryFired( .{ .compaction = box }, .compaction, "compaction", .{ .compaction = .{ .summary = summary } }, ); return box; } /// Toggle the global tool-use collapse state (ctrl+o) and apply it to every /// tool-use component in the transcript. No "active component": we iterate /// the whole list and flip each one. Requests a render. pub fn toggleToolCollapse(self: *App) void { self.tools_collapsed = !self.tools_collapsed; for (self.transcript.items) |e| { if (e.kind == .tool) e.kind.tool.setCollapsed(self.tools_collapsed); } self.scheduler.requestRender(); } // -- 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(b.index); try self.router.put(b.index, .{ .assistant = box }); }, .Thinking => { const box = try self.spawnThinking(b.index); try self.router.put(b.index, .{ .thinking = box }); }, .ToolUse => { // The name is unknown at start (streamed); the component // renders `tool (?)…` until `tool_details` resolves it. // The `tool` event fires NOW (creation boundary, name // unknown); name-based claiming happens at the later // `tool_details` event, which can swap again (§7.5). const box = try self.spawnTool(b.index); 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| { // Set the name first, then fire `tool_details` (§7.5: // the name-based claim point). A handler swap here takes // over before the real content paints further. try box.setName(d.name); try self.fireToolLifecycle(box, .tool_details, "tool_details", .{ .tool = .{ .index = d.index, .tool_name = d.name, .id = d.id, } }); // Register the id -> component mapping so a later // ToolResult (out-of-band, keyed by tool_use_id) finds // this exact component. try self.router.putToolId(d.id, box); self.scheduler.requestRender(); }, else => {}, }; }, .content_delta => |d| { if (self.router.get(d.index)) |ref| switch (ref) { .assistant => |box| { try box.appendDelta(d.delta); // Fire `assistant_text_delta` at the SAME boundary the // component re-renders (no new render cadence). try self.fireTextLifecycle(d.index, .assistant, null, "assistant_text_delta", .{ .assistant_text = .{ .index = d.index, .delta = d.delta, .text = box.buffer.items, } }); self.scheduler.requestRender(); }, .thinking => |box| { try box.appendDelta(d.delta); try self.fireTextLifecycle(d.index, .thinking, null, "thinking_delta", .{ .thinking = .{ .index = d.index, .delta = d.delta, .text = box.buffer.items, } }); self.scheduler.requestRender(); }, .tool => |box| { // Tool args stream as deltas — they ARE the verbatim // JSON input. Accumulate them into the component, then // fire `tool_delta` (repeatable; no fire-once guard). try box.appendInput(d.delta); try self.fireToolLifecycle(box, null, "tool_delta", .{ .tool = .{ .index = d.index, .tool_name = if (box.name) |n| n.items else "", .delta = d.delta, .input = box.input.items, } }); self.scheduler.requestRender(); }, }; }, .block_complete => |b| { switch (b.block) { .Text => { try self.fireTextLifecycle(b.index, .assistant, .assistant_text_complete, "assistant_text_complete", .{ .assistant_text = .{ .index = b.index, .text = if (self.router.get(b.index)) |r| (if (r == .assistant) r.assistant.buffer.items else "") else "", } }); self.scheduler.requestRender(); }, .Thinking => { try self.fireTextLifecycle(b.index, .thinking, .thinking_complete, "thinking_complete", .{ .thinking = .{ .index = b.index, .text = if (self.router.get(b.index)) |r| (if (r == .thinking) r.thinking.buffer.items else "") else "", } }); self.scheduler.requestRender(); }, .ToolUse => |tu| { if (self.router.get(b.index)) |ref| switch (ref) { .tool => |box| { // Final authoritative name + input from the // completed block (covers the case where // tool_details never fired and replaces any // partial streamed args with the final bytes). try box.setName(tu.name); try box.setInput(tu.input.items); try self.router.putToolId(tu.id, box); // Fire `tool_call_complete` (end of the CALL; // the result arrives later as `tool_result`). try self.fireToolLifecycle(box, .tool_call_complete, "tool_call_complete", .{ .tool = .{ .index = b.index, .tool_name = tu.name, .id = tu.id, .input = tu.input.items, } }); self.scheduler.requestRender(); }, else => {}, }; }, .CompactionSummary => |cs| { _ = try self.spawnCompaction(cs.text.items); self.scheduler.requestRender(); }, else => {}, } }, .message_complete => |mc| { // Update the footer's context-window token count with the // LATEST usage (plan §6): input + cache_read + cache_write // (output/reasoning excluded — not "in the window"). Latest // value wins; not accumulated. if (mc.usage) |u| { const ctx = u.input + u.cache_read + u.cache_write; self.footer.setContextTokens(ctx); self.scheduler.requestRender(); } }, .provider_retry => |info| { 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_complete => |info| { // ToolResult blocks are delivered together here as the content // of the appended user message. Correlate each back to its // ToolUse component by tool_use_id and feed it the result text. try self.routeToolResults(info.message); }, .tool_dispatch_start, .turn_complete => {}, } } /// Walk a tool-dispatch-complete user message and feed each `ToolResult` /// block's text to the `ToolUse` component that issued the matching call /// (looked up by `tool_use_id`). Honors the no-active-component invariant: /// the correlation is purely by id. fn routeToolResults(self: *App, message: panto.Message) !void { var any = false; for (message.content.items) |block| { switch (block) { .ToolResult => |tr| { const box = self.router.getToolById(tr.tool_use_id) orelse continue; // Concatenate the textual parts of the result. var text: std.ArrayList(u8) = .empty; defer text.deinit(self.alloc); try tr.appendTextInto(self.alloc, &text); try box.setOutput(text.items); // Fire `tool_result` — the atomic result landed. This is the // terminal tool-lifecycle event (after `tool_call_complete`). try self.fireToolLifecycle(box, .tool_result, "tool_result", .{ .tool = .{ .tool_name = if (box.name) |n| n.items else "", .id = tr.tool_use_id, .output = text.items, } }); any = true; }, else => {}, } } if (any) self.scheduler.requestRender(); } /// 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, /// Streaming thinking block. thinking: *Thinking, /// Tool-use block (drives its own ToolUse component). tool: *ToolUse, }; /// Block-index -> component routing, plus a SECOND map from tool-call id -> /// the owning `ToolUse` component. The id map is what correlates a later /// `ToolResult` (delivered out-of-band in `tool_dispatch_complete`, keyed by /// `tool_use_id`) back to the component that issued the call — without any /// "active component" (plan §6). /// /// The id map borrows transcript-owned `*ToolUse` pointers; both maps are /// cleared at each turn boundary (the transcript entries themselves persist as /// history). String keys are duped into an arena so they outlive the borrowed /// libpanto event slices. pub const TurnRouter = struct { map: std.AutoHashMap(usize, BlockRef), tool_by_id: std.StringHashMap(*ToolUse), id_arena: std.heap.ArenaAllocator, pub fn init(alloc: std.mem.Allocator) TurnRouter { return .{ .map = std.AutoHashMap(usize, BlockRef).init(alloc), .tool_by_id = std.StringHashMap(*ToolUse).init(alloc), .id_arena = std.heap.ArenaAllocator.init(alloc), }; } pub fn deinit(self: *TurnRouter) void { self.map.deinit(); self.tool_by_id.deinit(); self.id_arena.deinit(); } pub fn reset(self: *TurnRouter) void { self.map.clearRetainingCapacity(); self.tool_by_id.clearRetainingCapacity(); _ = self.id_arena.reset(.retain_capacity); } 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); } /// Register a tool-call id -> its `ToolUse` component for result /// correlation. The id is duped into the router arena (the libpanto slice /// is borrowed and transient). pub fn putToolId(self: *TurnRouter, id: []const u8, box: *ToolUse) !void { const key = try self.id_arena.allocator().dupe(u8, id); try self.tool_by_id.put(key, box); } /// Look up the `ToolUse` component that issued the call with this id. pub fn getToolById(self: *TurnRouter, id: []const u8) ?*ToolUse { return self.tool_by_id.get(id); } }; // =========================================================================== // 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, /// Working directory shown in the welcome banner. Borrowed for the loop. cwd: []const u8, /// panto version string for the welcome banner (empty = omit). version: []const u8 = "", /// The std.Io used to spawn `$EDITOR` for the Ctrl+G round-trip. io: std.Io, /// Process environment, used to resolve `$EDITOR` (and `$VISUAL`) for the /// Ctrl+G round-trip. Borrowed for the loop's lifetime. environ: *const std.process.Environ.Map, }; /// 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. /// Keyboard-protocol handshake state for one session. Resolved from the /// terminal's replies to the startup `negotiate_query`. const Handshake = struct { /// Kitty keyboard protocol confirmed active (non-zero flags reply seen). kitty: bool = false, /// We enabled the modifyOtherKeys fallback (must reset it on teardown). mok_enabled: bool = false, /// Handshake has resolved (a DA sentinel reply was seen). resolved: bool = false, }; pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void { var hs: Handshake = .{}; // Start the keyboard-protocol handshake: enable bracketed paste, push the // Kitty flags we want, then query (Kitty flags + DA sentinel). The replies // are consumed in `handleBytes`, which enables the modifyOtherKeys fallback // iff the terminal turns out not to support Kitty. term.writeAll(input_mod.negotiate_query); defer { app.engine.finalizeCursor() catch {}; app.flushSink(); } defer { input_mod.setKittyActive(false); if (hs.mok_enabled) term.writeAll(input_mod.disable_modify_other_keys); term.writeAll(input_mod.negotiate_teardown); } term.hideCursor(); defer term.showCursor(); try app.footer.setModel(opts.model_label); // Session-start welcome banner as the first transcript entry. cwd is read // from the process; the model label comes from the run options. (Version // is not threaded through the run options yet; the banner omits it.) { const welcome = try app.spawnWelcome(.{ .version = opts.version, .cwd = opts.cwd, .model = opts.model_label, }); try welcome.setModel(opts.model_label); if (opts.cwd.len != 0) try welcome.setCwd(opts.cwd); if (opts.version.len != 0) try welcome.setVersion(opts.version); } 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, term, &hs, 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, term: *Terminal, hs: *Handshake, 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; } if (k.isCtrl('o')) { // Global collapse/expand of all tool-use components. Consume // the key (do NOT feed it to the input box) and request a // render. app.toggleToolCollapse(); off += step.consumed; continue; } if (k.isCtrl('g')) { // Punt the editor buffer out to $EDITOR (markdown tempfile), // then read it back. Consume the key; never feed it to the // box. editInExternalEditor(app, term, opts.io, opts.environ) catch |err| { if (std.fmt.allocPrint(app.alloc, "[$EDITOR failed: {s}]", .{@errorName(err)})) |msg| { defer app.alloc.free(msg); _ = app.spawnStatus(msg) catch {}; } else |_| { _ = app.spawnStatus("[$EDITOR failed]") catch {}; } }; off += step.consumed; continue; } // 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]); }, .negotiation => |neg| { handleNegotiation(term, hs, neg); off += step.consumed; continue; // not a keypress; no render / submit check }, } 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; } /// React to a keyboard-protocol negotiation reply from the terminal. /// /// - A non-zero Kitty flags reply confirms the Kitty protocol: mark it active /// and do NOT enable modifyOtherKeys (they can conflict). /// - The DA reply is the handshake sentinel: it always arrives. If we reach it /// without having confirmed Kitty, the terminal lacks Kitty support, so we /// enable the modifyOtherKeys fallback (e.g. for tmux/xterm). fn handleNegotiation(term: *Terminal, hs: *Handshake, neg: input_mod.Negotiation) void { switch (neg) { .kitty_flags => |flags| { if (flags != 0 and !hs.kitty) { hs.kitty = true; input_mod.setKittyActive(true); term.caps.kitty_keyboard = true; } }, .device_attributes => { if (hs.resolved) return; hs.resolved = true; if (!hs.kitty and !hs.mok_enabled) { term.writeAll(input_mod.enable_modify_other_keys); hs.mok_enabled = true; term.caps.kitty_keyboard = false; } }, } } /// Punt the input box's buffer to the user's `$EDITOR` (Ctrl+G), then read it /// back. Mirrors pi's editor escape hatch. /// /// Flow: write the buffer to a `.md` tempfile -> drop the terminal to cooked /// mode + show the cursor -> spawn `$EDITOR ` inheriting our stdio and /// wait -> re-enter raw mode + hide the cursor -> read the file back into the /// box (trimming a single trailing newline most editors add) -> delete the /// tempfile -> force a full engine redraw (the child scribbled all over the /// screen, so the differential baseline is stale). /// /// The terminal's signal/panic restore record stays armed with the ORIGINAL /// (cooked) termios throughout (`suspendRawMode` does not clear it), so a crash /// or signal while the editor is open still leaves a sane terminal. We re-enter /// raw mode on every return path via `defer`. fn editInExternalEditor( app: *App, term: *Terminal, io: std.Io, environ: *const std.process.Environ.Map, ) !void { const editor = environ.get("VISUAL") orelse environ.get("EDITOR") orelse "vi"; // Build a tempfile path: $TMPDIR (or /tmp) + a pid/nanotime-unique name. const tmp_dir = environ.get("TMPDIR") orelse "/tmp"; const pid = std.c.getpid(); const nanos = std.Io.Clock.now(.awake, io).nanoseconds; const path = try std.fmt.allocPrint(app.alloc, "{s}/panto-edit-{d}-{d}.md", .{ std.mem.trimEnd(u8, tmp_dir, "/"), pid, nanos, }); defer app.alloc.free(path); // Write the current buffer out. try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = app.input_box.buffer() }); defer std.Io.Dir.cwd().deleteFile(io, path) catch {}; // Split `$EDITOR` on spaces so commands with flags (e.g. "code -w") work, // then append the file path as the final argv entry. var argv: std.ArrayList([]const u8) = .empty; defer argv.deinit(app.alloc); try splitEditorArgv(app.alloc, editor, path, &argv); // Drop to cooked mode for the child; always re-enter raw mode + force a // full redraw afterward. term.suspendRawMode(); app.flushSink(); defer { term.resumeRawMode() catch {}; app.engine.forceFullRedraw(); app.renderNow() catch {}; } var child = try std.process.spawn(io, .{ .argv = argv.items, .stdin = .inherit, .stdout = .inherit, .stderr = .inherit, }); _ = try child.wait(io); // Read the edited file back. Cap the read so a pathological file can't OOM // us; 16 MiB is far past any reasonable prompt. const edited = std.Io.Dir.cwd().readFileAlloc(io, path, app.alloc, .limited(16 * 1024 * 1024)) catch |err| switch (err) { else => return err, }; defer app.alloc.free(edited); // Trim a single trailing newline (the convention most editors add on save). const trimmed = if (std.mem.endsWith(u8, edited, "\n")) edited[0 .. edited.len - 1] else edited; try app.input_box.setBuffer(trimmed); } /// Build the argv for the `$EDITOR` spawn: split `editor` on spaces (so /// commands with flags like `"code -w"` work), fall back to `vi` when empty, /// then append `path` as the final argument. Split out as a pure helper so the /// arg-splitting seam is unit-testable without a PTY (the spawn + raw-mode /// round-trip itself is interactive-only). fn splitEditorArgv( alloc: std.mem.Allocator, editor: []const u8, path: []const u8, argv: *std.ArrayList([]const u8), ) !void { var it = std.mem.tokenizeScalar(u8, editor, ' '); while (it.next()) |part| try argv.append(alloc, part); if (argv.items.len == 0) try argv.append(alloc, "vi"); try argv.append(alloc, path); } /// 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 stream into a dedicated Thinking component" { 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, "reason")); try h.app.routeEvent(delta(0, "ing")); const ref = h.app.router.get(0).?; try testing.expect(ref == .thinking); try testing.expectEqualStrings("reasoning", ref.thinking.buffer.items); } test "routeEvent: tool block accumulates verbatim args and resolves its name" { 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 stream as deltas and accumulate verbatim into the component. try h.app.routeEvent(delta(0, "{\"path\":")); try h.app.routeEvent(delta(0, "\"x\"}")); 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(ref.tool.name != null); try testing.expectEqualStrings("read", ref.tool.name.?.items); try testing.expectEqualStrings("{\"path\":\"x\"}", ref.tool.input.items); // The id was registered for result correlation. try testing.expect(h.app.router.getToolById("t1") == ref.tool); } 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.kind == .status); try testing.expect(std.mem.indexOf(u8, e.kind.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); } test "routeEvent: tool result correlates to its ToolUse component by id" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); // Open a tool call, resolve its id/name, accumulate args. try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); try h.app.routeEvent(delta(0, "{\"q\":1}")); try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "call-1", .name = "search" } }); // Build a tool_dispatch_complete user message carrying a ToolResult for // call-1 (the out-of-band delivery path). var msg: panto.Message = .{ .role = .user }; defer msg.deinit(alloc); var parts: std.ArrayList(panto.ResultPartStored) = .empty; var text: panto.TextualBlock = .empty; try text.appendSlice(alloc, "the result body"); try parts.append(alloc, .{ .text = text }); const id = try alloc.dupe(u8, "call-1"); try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = id, .parts = parts } }); try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } }); // The matching component received the output. const box = h.app.router.getToolById("call-1").?; try testing.expect(box.output != null); try testing.expectEqualStrings("the result body", box.output.?.items); } test "routeEvent: two concurrent tool calls route results to their OWN component by id" { // The highest-risk no-active-component case (plan §6): with MULTIPLE tool // calls in flight, each ToolResult must land on the component that issued // the matching id — never "the" tool component. We deliberately deliver the // results in the REVERSE order of the calls and assert no cross-talk. const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); // Open two tool calls at distinct block indices; resolve distinct ids. try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); try h.app.routeEvent(delta(0, "{\"a\":1}")); try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "call-A", .name = "read" } }); try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 1 } }); try h.app.routeEvent(delta(1, "{\"b\":2}")); try h.app.routeEvent(.{ .tool_details = .{ .index = 1, .id = "call-B", .name = "write" } }); const box_a = h.app.router.getToolById("call-A").?; const box_b = h.app.router.getToolById("call-B").?; try testing.expect(box_a != box_b); // Deliver BOTH results in ONE tool_dispatch_complete user message, in the // reverse order (B before A), each carrying its own tool_use_id. var msg: panto.Message = .{ .role = .user }; defer msg.deinit(alloc); { var parts_b: std.ArrayList(panto.ResultPartStored) = .empty; var text_b: panto.TextualBlock = .empty; try text_b.appendSlice(alloc, "result for B"); try parts_b.append(alloc, .{ .text = text_b }); try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "call-B"), .parts = parts_b } }); var parts_a: std.ArrayList(panto.ResultPartStored) = .empty; var text_a: panto.TextualBlock = .empty; try text_a.appendSlice(alloc, "result for A"); try parts_a.append(alloc, .{ .text = text_a }); try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "call-A"), .parts = parts_a } }); } try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } }); // Each result landed on its OWN component — no clobber, no cross-talk. try testing.expect(box_a.output != null); try testing.expect(box_b.output != null); try testing.expectEqualStrings("result for A", box_a.output.?.items); try testing.expectEqualStrings("result for B", box_b.output.?.items); // And the inputs were never crossed either. try testing.expectEqualStrings("{\"a\":1}", box_a.input.items); try testing.expectEqualStrings("{\"b\":2}", box_b.input.items); } test "routeEvent: an unmatched tool_use_id is ignored, matched siblings still route" { // A result whose id has no live ToolUse must be skipped (orelse continue), // never crash or smear onto another component. 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 } }); try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "known", .name = "read" } }); const known = h.app.router.getToolById("known").?; var msg: panto.Message = .{ .role = .user }; defer msg.deinit(alloc); { var p_unknown: std.ArrayList(panto.ResultPartStored) = .empty; var t_unknown: panto.TextualBlock = .empty; try t_unknown.appendSlice(alloc, "orphan"); try p_unknown.append(alloc, .{ .text = t_unknown }); try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "ghost"), .parts = p_unknown } }); var p_known: std.ArrayList(panto.ResultPartStored) = .empty; var t_known: panto.TextualBlock = .empty; try t_known.appendSlice(alloc, "real"); try p_known.append(alloc, .{ .text = t_known }); try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "known"), .parts = p_known } }); } try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } }); try testing.expect(known.output != null); try testing.expectEqualStrings("real", known.output.?.items); } test "toggleToolCollapse flips every tool component globally" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); // Two tool calls. Default collapsed == true. try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 1 } }); const a = h.app.router.get(0).?.tool; const b = h.app.router.get(1).?.tool; try testing.expect(a.collapsed and b.collapsed); // ctrl+o equivalent: expand all. h.app.toggleToolCollapse(); try testing.expect(!a.collapsed and !b.collapsed); try testing.expect(!h.app.tools_collapsed); // Toggle again: collapse all. h.app.toggleToolCollapse(); try testing.expect(a.collapsed and b.collapsed); } test "toggleToolCollapse: a tool spawned AFTER the toggle inherits the global state" { // ctrl+o is a GLOBAL mode, not a per-component flip: a tool call that opens // later must adopt whatever the current global collapse state is, so the // whole transcript stays consistent. const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); // Default is collapsed; flip the global mode to EXPANDED before any tool. h.app.toggleToolCollapse(); try testing.expect(!h.app.tools_collapsed); // A tool that opens now must be expanded to match the global mode. try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); const late = h.app.router.get(0).?.tool; try testing.expect(!late.collapsed); // Flip back to collapsed; a still-later tool must open collapsed. h.app.toggleToolCollapse(); try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 1 } }); const later = h.app.router.get(1).?.tool; try testing.expect(later.collapsed); // And the earlier one flipped along with the global toggle. try testing.expect(late.collapsed); } test "spawnWelcome shows a session-start banner entry" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); const w = try h.app.spawnWelcome(.{}); try w.setModel("m"); try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len); try testing.expect(h.app.transcript.items[0].kind == .welcome); } test "routeEvent: compaction summary block spawns a compaction entry" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); var cs: panto.TextualBlock = .empty; defer cs.deinit(alloc); try cs.appendSlice(alloc, "old turns summarized"); try h.app.routeEvent(.{ .block_complete = .{ .index = 0, .block = .{ .CompactionSummary = .{ .text = cs } }, } }); try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len); try testing.expect(h.app.transcript.items[0].kind == .compaction); } // -- event system wiring (plan §7) ------------------------------------------- /// A test component that renders a fixed marker line, used to prove an /// extension handler's chosen component reaches the engine. const MarkerComponent = struct { line: []const u8, cache: component.RenderCache, fn init(alloc: std.mem.Allocator, line: []const u8) MarkerComponent { return .{ .line = line, .cache = component.RenderCache.init(alloc) }; } fn deinit(self: *MarkerComponent) void { self.cache.deinit(); } fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = width; _ = alloc; const self: *MarkerComponent = @ptrCast(@alignCast(ptr)); const lines = [_][]const u8{self.line}; try self.cache.store(&lines); const owned = self.cache.lines orelse return &.{}; return @ptrCast(owned); } fn firstLineChangedImpl(ptr: *anyopaque) ?usize { const self: *MarkerComponent = @ptrCast(@alignCast(ptr)); return self.cache.firstLineChanged(); } fn invalidateImpl(ptr: *anyopaque) void { const self: *MarkerComponent = @ptrCast(@alignCast(ptr)); self.cache.invalidate(); } const vtable = Component.VTable{ .render = renderImpl, .firstLineChanged = firstLineChangedImpl, .invalidate = invalidateImpl, }; fn comp(self: *MarkerComponent) Component { return .{ .ptr = self, .vtable = &vtable }; } }; test "event wiring: no handler => entry keeps the built-in default (override null)" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); // No handlers registered. Spawn one of each event-bearing boundary and // confirm none got an override — i.e. the engine renders the built-in // default, byte-identical to the pre-event-system behavior. try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } }); try h.app.routeEvent(.{ .block_start = .{ .block_type = .Thinking, .index = 1 } }); try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 2 } }); _ = try h.app.spawnWelcome(.{}); try h.app.spawnUser("hi"); try testing.expect(h.app.transcript.items.len == 5); for (h.app.transcript.items) |e| try testing.expect(e.override == null); } test "event wiring: assistant_text default render is identical with vs without a no-op handler" { const alloc = testing.allocator; // Render once with NO handlers. const baseline = blk: { const h = try Harness.make(alloc); defer h.teardown(alloc); h.app.input_box.setFocused(true); try h.app.rebuildEngineList(); try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } }); try h.app.routeEvent(delta(0, "identical body")); try h.app.renderNow(); break :blk try alloc.dupe(u8, h.buf.written()); }; defer alloc.free(baseline); // Render again with a handler that reads the default and sets it back // unchanged (a no-op pass-through). Output must be byte-identical. { const h = try Harness.make(alloc); defer h.teardown(alloc); const NoOp = struct { fn cb(ctx: *anyopaque, ev: *ui_event.Event) void { _ = ctx; if (ev.getComponent()) |c| ev.setComponent(c); // set back unchanged } }; try h.app.bus.on("assistant_text", .{ .ctx = &h.app, .callback = NoOp.cb }); h.app.input_box.setFocused(true); try h.app.rebuildEngineList(); try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } }); try h.app.routeEvent(delta(0, "identical body")); try h.app.renderNow(); try testing.expectEqualStrings(baseline, h.buf.written()); } } test "event wiring: a handler replaces the component; engine renders it, deltas drive the default" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); var marker = MarkerComponent.init(alloc, "REPLACED-BY-EXTENSION"); defer marker.deinit(); const Replace = struct { marker: *MarkerComponent, fn cb(ctx: *anyopaque, ev: *ui_event.Event) void { const self: *@This() = @ptrCast(@alignCast(ctx)); ev.setComponent(self.marker.comp()); } }; var rep = Replace{ .marker = &marker }; try h.app.bus.on("assistant_text", .{ .ctx = &rep, .callback = Replace.cb }); h.app.input_box.setFocused(true); try h.app.rebuildEngineList(); try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } }); // Deltas still drive the DEFAULT typed box (the override would normally // wrap + render it; this stub marker ignores it, which is fine for the // wiring assertion). try h.app.routeEvent(delta(0, "hidden body")); // The entry recorded the override. try testing.expect(h.app.transcript.items[0].override != null); // The default box still received the delta (no-active-component routing). try testing.expectEqualStrings("hidden body", h.app.router.get(0).?.assistant.buffer.items); try h.app.renderNow(); const out = h.buf.written(); // The engine rendered the EXTENSION component, not the default text. try testing.expect(std.mem.indexOf(u8, out, "REPLACED-BY-EXTENSION") != null); try testing.expect(std.mem.indexOf(u8, out, "hidden body") == null); } test "event wiring: two concurrent tool boundaries get independent components" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); // A handler that mints a distinct marker per tool block index, proving the // bus carries no "active component" across emits. var markers = [_]MarkerComponent{ MarkerComponent.init(alloc, "TOOL-0"), MarkerComponent.init(alloc, "TOOL-1"), }; defer for (&markers) |*m| m.deinit(); const Mint = struct { markers: []MarkerComponent, fn cb(ctx: *anyopaque, ev: *ui_event.Event) void { const self: *@This() = @ptrCast(@alignCast(ctx)); const idx = ev.payload.tool.index; if (idx < self.markers.len) ev.setComponent(self.markers[idx].comp()); } }; var mint = Mint{ .markers = &markers }; try h.app.bus.on("tool", .{ .ctx = &mint, .callback = Mint.cb }); // The `tool` event now fires at block_start (name unknown). The index IS // present at start, so the Mint handler (keyed on index) sets each call's // own marker immediately — each tool boundary gets its own component. try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 1 } }); const o0 = h.app.transcript.items[0].override.?; const o1 = h.app.transcript.items[1].override.?; try testing.expect(o0.ptr == markers[0].comp().ptr); try testing.expect(o1.ptr == markers[1].comp().ptr); try testing.expect(o0.ptr != o1.ptr); } test "event wiring: tool lifecycle events each fire EXACTLY ONCE at their boundary" { // The named tool-lifecycle events (`tool`, `tool_details`, // `tool_call_complete`, `tool_result`) each fire once per slot, in order. // `tool_delta` fires per chunk (not guarded). This replaces the old // deferral test: `tool` now fires at block_start (name unknown), and the // later events carry the resolving data. const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); const Counter = struct { tool: usize = 0, details: usize = 0, delta: usize = 0, call_complete: usize = 0, result: usize = 0, last_name: []const u8 = "", // One callback that buckets by the event NAME, so the same ctx tracks // every lifecycle event (the name disambiguates which counter to bump). fn cb(ctx: *anyopaque, ev: *ui_event.Event) void { const c: *@This() = @ptrCast(@alignCast(ctx)); const n = ev.name; if (std.mem.eql(u8, n, "tool")) c.tool += 1 // else if (std.mem.eql(u8, n, "tool_details")) c.details += 1 // else if (std.mem.eql(u8, n, "tool_delta")) c.delta += 1 // else if (std.mem.eql(u8, n, "tool_call_complete")) c.call_complete += 1 // else if (std.mem.eql(u8, n, "tool_result")) c.result += 1; c.last_name = ev.payload.tool.tool_name; } }; var counter = Counter{}; try h.app.bus.on("tool", .{ .ctx = &counter, .callback = Counter.cb }); try h.app.bus.on("tool_details", .{ .ctx = &counter, .callback = Counter.cb }); try h.app.bus.on("tool_delta", .{ .ctx = &counter, .callback = Counter.cb }); try h.app.bus.on("tool_call_complete", .{ .ctx = &counter, .callback = Counter.cb }); try h.app.bus.on("tool_result", .{ .ctx = &counter, .callback = Counter.cb }); // block_start => `tool` (name unknown). try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); try testing.expectEqual(@as(usize, 1), counter.tool); try testing.expectEqualStrings("", counter.last_name); // two args deltas => `tool_delta` twice (repeatable). try h.app.routeEvent(delta(0, "{\"a\":")); try h.app.routeEvent(delta(0, "1}")); try testing.expectEqual(@as(usize, 2), counter.delta); // tool_details => `tool_details` once, with the name. try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "a", .name = "read" } }); try testing.expectEqual(@as(usize, 1), counter.details); try testing.expectEqualStrings("read", counter.last_name); // block_complete => `tool_call_complete` once. var tu = panto.ToolUseBlock{ .id = try alloc.dupe(u8, "a"), .name = try alloc.dupe(u8, "read"), }; defer tu.deinit(alloc); try tu.input.appendSlice(alloc, "{\"a\":1}"); try h.app.routeEvent(.{ .block_complete = .{ .index = 0, .block = .{ .ToolUse = tu } } }); try testing.expectEqual(@as(usize, 1), counter.call_complete); // tool_dispatch_complete carrying the result => `tool_result` once. var msg: panto.Message = .{ .role = .user }; defer msg.deinit(alloc); var parts: std.ArrayList(panto.ResultPartStored) = .empty; var text: panto.TextualBlock = .empty; try text.appendSlice(alloc, "out"); try parts.append(alloc, .{ .text = text }); try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "a"), .parts = parts } }); try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } }); try testing.expectEqual(@as(usize, 1), counter.result); // Each named event fired exactly once (delta is the only repeatable one). try testing.expectEqual(@as(usize, 1), counter.tool); try testing.expectEqual(@as(usize, 1), counter.details); try testing.expectEqual(@as(usize, 1), counter.call_complete); try testing.expectEqual(@as(usize, 1), counter.result); } test "event wiring: thinking lifecycle fires start + per-delta + complete" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); const Rec = struct { start: usize = 0, delta: usize = 0, complete: usize = 0, last_delta: []const u8 = "", last_text: []const u8 = "", fn cb(ctx: *anyopaque, ev: *ui_event.Event) void { const r: *@This() = @ptrCast(@alignCast(ctx)); if (std.mem.eql(u8, ev.name, "thinking")) r.start += 1 // else if (std.mem.eql(u8, ev.name, "thinking_delta")) { r.delta += 1; r.last_delta = ev.payload.thinking.delta; r.last_text = ev.payload.thinking.text; } else if (std.mem.eql(u8, ev.name, "thinking_complete")) { r.complete += 1; r.last_text = ev.payload.thinking.text; } } }; var rec = Rec{}; try h.app.bus.on("thinking", .{ .ctx = &rec, .callback = Rec.cb }); try h.app.bus.on("thinking_delta", .{ .ctx = &rec, .callback = Rec.cb }); try h.app.bus.on("thinking_complete", .{ .ctx = &rec, .callback = Rec.cb }); try h.app.routeEvent(.{ .block_start = .{ .block_type = .Thinking, .index = 0 } }); try h.app.routeEvent(delta(0, "hmm")); try h.app.routeEvent(delta(0, " ok")); var th = panto.ThinkingBlock{}; defer th.deinit(alloc); try th.text.appendSlice(alloc, "hmm ok"); try h.app.routeEvent(.{ .block_complete = .{ .index = 0, .block = .{ .Thinking = th } } }); try testing.expectEqual(@as(usize, 1), rec.start); try testing.expectEqual(@as(usize, 2), rec.delta); try testing.expectEqual(@as(usize, 1), rec.complete); // The last delta carried the chunk + accumulated text; complete carried // the final text. try testing.expectEqualStrings("hmm ok", rec.last_text); } test "event wiring: assistant_text lifecycle fires start + per-delta + complete" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); const Rec = struct { start: usize = 0, delta: usize = 0, complete: usize = 0, last_text: []const u8 = "", fn cb(ctx: *anyopaque, ev: *ui_event.Event) void { const r: *@This() = @ptrCast(@alignCast(ctx)); if (std.mem.eql(u8, ev.name, "assistant_text")) r.start += 1 // else if (std.mem.eql(u8, ev.name, "assistant_text_delta")) { r.delta += 1; r.last_text = ev.payload.assistant_text.text; } else if (std.mem.eql(u8, ev.name, "assistant_text_complete")) { r.complete += 1; r.last_text = ev.payload.assistant_text.text; } } }; var rec = Rec{}; try h.app.bus.on("assistant_text", .{ .ctx = &rec, .callback = Rec.cb }); try h.app.bus.on("assistant_text_delta", .{ .ctx = &rec, .callback = Rec.cb }); try h.app.bus.on("assistant_text_complete", .{ .ctx = &rec, .callback = Rec.cb }); try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } }); try h.app.routeEvent(delta(0, "Hel")); try h.app.routeEvent(delta(0, "lo")); var tb: panto.TextualBlock = .empty; defer tb.deinit(alloc); try tb.appendSlice(alloc, "Hello"); try h.app.routeEvent(.{ .block_complete = .{ .index = 0, .block = .{ .Text = tb } } }); try testing.expectEqual(@as(usize, 1), rec.start); try testing.expectEqual(@as(usize, 2), rec.delta); try testing.expectEqual(@as(usize, 1), rec.complete); try testing.expectEqualStrings("Hello", rec.last_text); } test "event wiring: mid-stream swap at tool_details takes over and keeps driving the default box" { // A handler ignores the `tool` start (name unknown) and only swaps at // `tool_details` when the name is "read". The swap must (a) replace the // rendered component, (b) fully take over the region (the swapped-in // component renders, the default's taller `tool (?)`/args content is NOT // visible), and (c) panto keeps driving args/result into the DEFAULT box. const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); var marker = MarkerComponent.init(alloc, "SWAPPED-AT-DETAILS"); defer marker.deinit(); const Claim = struct { marker: *MarkerComponent, fn cb(ctx: *anyopaque, ev: *ui_event.Event) void { const self: *@This() = @ptrCast(@alignCast(ctx)); if (std.mem.eql(u8, ev.payload.tool.tool_name, "read")) { ev.setComponent(self.marker.comp()); } } }; var claim = Claim{ .marker = &marker }; try h.app.bus.on("tool_details", .{ .ctx = &claim, .callback = Claim.cb }); h.app.input_box.setFocused(true); try h.app.rebuildEngineList(); try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); // No swap yet (only `tool` fired, name unknown). try testing.expect(h.app.transcript.items[0].override == null); // Stream some args so the default box has multi-line content (a taller // predecessor than the single-line marker). try h.app.routeEvent(delta(0, "{\"path\":\"a\",\n\"mode\":\"r\"}")); try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "a", .name = "read" } }); // The override was installed at tool_details. try testing.expect(h.app.transcript.items[0].override != null); try testing.expect(h.app.transcript.items[0].override.?.ptr == marker.comp().ptr); try h.app.renderNow(); var out = h.buf.written(); // Full takeover: the swapped-in component is visible; the default's args // content is not. try testing.expect(std.mem.indexOf(u8, out, "SWAPPED-AT-DETAILS") != null); try testing.expect(std.mem.indexOf(u8, out, "mode") == null); // panto KEEPS DRIVING the default box: deliver a result and confirm the // DEFAULT ToolUse box received it (even though the override renders). const box = h.app.router.getToolById("a").?; var msg: panto.Message = .{ .role = .user }; defer msg.deinit(alloc); var parts: std.ArrayList(panto.ResultPartStored) = .empty; var text: panto.TextualBlock = .empty; try text.appendSlice(alloc, "the-output"); try parts.append(alloc, .{ .text = text }); try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "a"), .parts = parts } }); try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } }); try testing.expect(box.output != null); try testing.expectEqualStrings("the-output", box.output.?.items); // Args were driven into the default box too. try testing.expect(std.mem.indexOf(u8, box.input.items, "path") != null); // The override still renders (not the default), even after the result // drove the default box. h.buf.clearRetainingCapacity(); try h.app.renderNow(); out = h.buf.written(); try testing.expect(std.mem.indexOf(u8, out, "SWAPPED-AT-DETAILS") != null); try testing.expect(std.mem.indexOf(u8, out, "the-output") == null); } test "event wiring: a replaced override is handed back to the release hook" { // Two handlers swap the same slot in turn (at `tool` then `tool_details`). // The App owns neither override; when the second replaces the first, the // App must hand the FIRST one back to the installed release hook so its // owner can drop it (the Lua-bridge leak-prevention point). const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); var first = MarkerComponent.init(alloc, "FIRST"); defer first.deinit(); var second = MarkerComponent.init(alloc, "SECOND"); defer second.deinit(); const Swap = struct { first: *MarkerComponent, second: *MarkerComponent, fn at_tool(ctx: *anyopaque, ev: *ui_event.Event) void { const self: *@This() = @ptrCast(@alignCast(ctx)); ev.setComponent(self.first.comp()); } fn at_details(ctx: *anyopaque, ev: *ui_event.Event) void { const self: *@This() = @ptrCast(@alignCast(ctx)); ev.setComponent(self.second.comp()); } }; var swap = Swap{ .first = &first, .second = &second }; try h.app.bus.on("tool", .{ .ctx = &swap, .callback = Swap.at_tool }); try h.app.bus.on("tool_details", .{ .ctx = &swap, .callback = Swap.at_details }); const Released = struct { ptr: ?*anyopaque = null, count: usize = 0, fn rel(ctx: *anyopaque, old: Component) void { const r: *@This() = @ptrCast(@alignCast(ctx)); r.ptr = old.ptr; r.count += 1; } }; var released = Released{}; h.app.setOverrideRelease(&released, Released.rel); try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); // First override installed at `tool`; no release yet. try testing.expectEqual(@as(usize, 0), released.count); try testing.expect(h.app.transcript.items[0].override.?.ptr == first.comp().ptr); try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "a", .name = "read" } }); // Second override replaced the first; the FIRST was handed to the hook. try testing.expect(h.app.transcript.items[0].override.?.ptr == second.comp().ptr); try testing.expectEqual(@as(usize, 1), released.count); try testing.expect(released.ptr == first.comp().ptr); } test "event wiring: an idempotent same-ptr swap does NOT release (no release-then-use)" { // A handler that sets the SAME component again (same ptr) at a later // lifecycle event must NOT trigger the release hook: there is no // superseded component, so releasing would free a component the slot // still renders (a release-then-use). `setOverride` guards this with // `old.ptr != new.ptr`. This also covers a handler that re-affirms its // own component across `tool` -> `tool_details` -> `tool_call_complete`. const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); var only = MarkerComponent.init(alloc, "ONLY"); defer only.deinit(); // The same handler fires on every tool lifecycle event and always sets the // SAME component instance. const Same = struct { only: *MarkerComponent, fn cb(ctx: *anyopaque, ev: *ui_event.Event) void { const self: *@This() = @ptrCast(@alignCast(ctx)); ev.setComponent(self.only.comp()); } }; var same = Same{ .only = &only }; try h.app.bus.on("tool", .{ .ctx = &same, .callback = Same.cb }); try h.app.bus.on("tool_details", .{ .ctx = &same, .callback = Same.cb }); try h.app.bus.on("tool_call_complete", .{ .ctx = &same, .callback = Same.cb }); const Released = struct { count: usize = 0, fn rel(ctx: *anyopaque, old: Component) void { _ = old; const r: *@This() = @ptrCast(@alignCast(ctx)); r.count += 1; } }; var released = Released{}; h.app.setOverrideRelease(&released, Released.rel); // block_start: the `tool` handler sets `only` (installed via pushEntryFired, // which does not call the release hook on the first set). try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); try testing.expect(h.app.transcript.items[0].override.?.ptr == only.comp().ptr); try testing.expectEqual(@as(usize, 0), released.count); // tool_details: the handler sets `only` AGAIN (same ptr) => no release. try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "a", .name = "read" } }); try testing.expectEqual(@as(usize, 0), released.count); // tool_call_complete: same ptr once more => still no release. var tu = panto.ToolUseBlock{ .id = try alloc.dupe(u8, "a"), .name = try alloc.dupe(u8, "read"), .input = .empty, }; defer tu.deinit(alloc); try h.app.routeEvent(.{ .block_complete = .{ .index = 0, .block = .{ .ToolUse = tu } } }); try testing.expectEqual(@as(usize, 0), released.count); // The slot still renders the same component, untouched. try testing.expect(h.app.transcript.items[0].override.?.ptr == only.comp().ptr); } test "event wiring: two concurrent tool calls each get + release their own override independently" { // No "active component": two ToolUse blocks are live at once, each keyed by // its own index/id. A handler swaps a per-call override on EACH at // `tool`, then swaps AGAIN on EACH at `tool_details`. The two slots must // release independently and with no cross-talk: slot 0's first override is // released when slot 0's second replaces it, and likewise for slot 1 — // never one slot releasing the other's component. const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); // Per-slot first/second markers (4 total). var a0 = MarkerComponent.init(alloc, "A0"); defer a0.deinit(); var a1 = MarkerComponent.init(alloc, "A1"); defer a1.deinit(); var b0 = MarkerComponent.init(alloc, "B0"); defer b0.deinit(); var b1 = MarkerComponent.init(alloc, "B1"); defer b1.deinit(); // `tool` (start) sets the FIRST per-slot marker (a0 for index 0, b0 for 1). // `tool_details` sets the SECOND (a1 / b1), superseding the first. const Swap = struct { a0: *MarkerComponent, a1: *MarkerComponent, b0: *MarkerComponent, b1: *MarkerComponent, fn at_tool(ctx: *anyopaque, ev: *ui_event.Event) void { const self: *@This() = @ptrCast(@alignCast(ctx)); switch (ev.payload.tool.index) { 0 => ev.setComponent(self.a0.comp()), 1 => ev.setComponent(self.b0.comp()), else => {}, } } fn at_details(ctx: *anyopaque, ev: *ui_event.Event) void { const self: *@This() = @ptrCast(@alignCast(ctx)); switch (ev.payload.tool.index) { 0 => ev.setComponent(self.a1.comp()), 1 => ev.setComponent(self.b1.comp()), else => {}, } } }; var swap = Swap{ .a0 = &a0, .a1 = &a1, .b0 = &b0, .b1 = &b1 }; try h.app.bus.on("tool", .{ .ctx = &swap, .callback = Swap.at_tool }); try h.app.bus.on("tool_details", .{ .ctx = &swap, .callback = Swap.at_details }); // Record every released component ptr. const Released = struct { ptrs: [8]?*anyopaque = .{null} ** 8, n: usize = 0, fn rel(ctx: *anyopaque, old: Component) void { const r: *@This() = @ptrCast(@alignCast(ctx)); if (r.n < r.ptrs.len) r.ptrs[r.n] = old.ptr; r.n += 1; } }; var released = Released{}; h.app.setOverrideRelease(&released, Released.rel); // Both calls start; each gets its FIRST override. No releases yet. try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 1 } }); try testing.expectEqual(@as(usize, 0), released.n); try testing.expect(h.app.transcript.items[0].override.?.ptr == a0.comp().ptr); try testing.expect(h.app.transcript.items[1].override.?.ptr == b0.comp().ptr); // Slot 1 resolves first: b1 supersedes b0 => exactly b0 released. try h.app.routeEvent(.{ .tool_details = .{ .index = 1, .id = "B", .name = "write" } }); try testing.expectEqual(@as(usize, 1), released.n); try testing.expect(released.ptrs[0] == b0.comp().ptr); // Slot 0 is untouched (no cross-talk). try testing.expect(h.app.transcript.items[0].override.?.ptr == a0.comp().ptr); try testing.expect(h.app.transcript.items[1].override.?.ptr == b1.comp().ptr); // Slot 0 resolves: a1 supersedes a0 => exactly a0 released. try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "A", .name = "read" } }); try testing.expectEqual(@as(usize, 2), released.n); try testing.expect(released.ptrs[1] == a0.comp().ptr); try testing.expect(h.app.transcript.items[0].override.?.ptr == a1.comp().ptr); try testing.expect(h.app.transcript.items[1].override.?.ptr == b1.comp().ptr); // Only the two FIRST overrides were ever released; the two SECOND ones // remain live and owned by the test markers (no spurious cross-release). try testing.expectEqual(@as(usize, 2), released.n); } test "splitEditorArgv: splits flags, appends the path, and falls back to vi" { const alloc = testing.allocator; // Bare editor name: [editor, path]. { var argv: std.ArrayList([]const u8) = .empty; defer argv.deinit(alloc); try splitEditorArgv(alloc, "nvim", "/tmp/panto-edit-1.md", &argv); try testing.expectEqual(@as(usize, 2), argv.items.len); try testing.expectEqualStrings("nvim", argv.items[0]); try testing.expectEqualStrings("/tmp/panto-edit-1.md", argv.items[1]); } // Editor with flags: each space-delimited token is its own argv entry, // then the path is last (e.g. "code -w" -> [code, -w, path]). { var argv: std.ArrayList([]const u8) = .empty; defer argv.deinit(alloc); try splitEditorArgv(alloc, "code -w", "/tmp/x.md", &argv); try testing.expectEqual(@as(usize, 3), argv.items.len); try testing.expectEqualStrings("code", argv.items[0]); try testing.expectEqualStrings("-w", argv.items[1]); try testing.expectEqualStrings("/tmp/x.md", argv.items[2]); } // Empty editor string: falls back to vi, then the path. { var argv: std.ArrayList([]const u8) = .empty; defer argv.deinit(alloc); try splitEditorArgv(alloc, "", "/tmp/y.md", &argv); try testing.expectEqual(@as(usize, 2), argv.items.len); try testing.expectEqualStrings("vi", argv.items[0]); try testing.expectEqualStrings("/tmp/y.md", argv.items[1]); } }