diff options
Diffstat (limited to 'src/tui_app.zig')
| -rw-r--r-- | src/tui_app.zig | 1103 |
1 files changed, 1041 insertions, 62 deletions
diff --git a/src/tui_app.zig b/src/tui_app.zig index a9d9a42..f2a205b 100644 --- a/src/tui_app.zig +++ b/src/tui_app.zig @@ -61,6 +61,7 @@ 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; @@ -76,6 +77,9 @@ 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; @@ -107,14 +111,15 @@ pub const IoClock = struct { // 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`. +/// 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 for P1 and prefix a dim/style run in the seeded text). -const Entry = union(enum) { +/// 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, @@ -129,7 +134,8 @@ const Entry = union(enum) { /// A compaction summary. compaction: *CompactionSummary, - fn comp(self: Entry) Component { + /// 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(), @@ -141,33 +147,9 @@ const Entry = union(enum) { }; } - fn deinit(self: Entry, alloc: std.mem.Allocator) void { + fn deinit(self: EntryKind, alloc: std.mem.Allocator) void { switch (self) { - .welcome => |p| { - p.deinit(); - alloc.destroy(p); - }, - .thinking => |p| { - p.deinit(); - alloc.destroy(p); - }, - .tool => |p| { - p.deinit(); - alloc.destroy(p); - }, - .compaction => |p| { - p.deinit(); - alloc.destroy(p); - }, - .user => |p| { - p.deinit(); - alloc.destroy(p); - }, - .assistant => |p| { - p.deinit(); - alloc.destroy(p); - }, - .status => |p| { + inline else => |p| { p.deinit(); alloc.destroy(p); }, @@ -175,6 +157,86 @@ const Entry = union(enum) { } }; +/// 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.<box>`, +/// 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 // =========================================================================== @@ -196,11 +258,31 @@ pub const App = struct { /// 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. @@ -226,6 +308,7 @@ pub const App = struct { .input_box = input_box, .footer = footer, .router = TurnRouter.init(alloc), + .bus = EventBus.init(alloc), .tools_collapsed = true, }; } @@ -234,6 +317,26 @@ pub const App = struct { 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 @@ -250,13 +353,106 @@ pub const App = struct { // -- 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). + /// 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 @@ -278,10 +474,15 @@ pub const App = struct { /// 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 { + fn spawnAssistant(self: *App, index: usize) !*AssistantText { const box = try self.alloc.create(AssistantText); box.* = AssistantText.init(self.alloc); - try self.pushEntry(.{ .assistant = box }); + try self.pushEntryFired( + .{ .assistant = box }, + .assistant_text, + "assistant_text", + .{ .assistant_text = .{ .index = index } }, + ); return box; } @@ -299,51 +500,152 @@ pub const App = struct { 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 }); + // 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`. + /// 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.pushEntry(.{ .user = box }); + try self.pushEntryFired( + .{ .user = box }, + .user_message, + "user_message", + .{ .user_message = .{ .text = text } }, + ); } - /// Spawn the session-start welcome banner. Returns it so the caller can set - /// its fields (version / cwd / model). - fn spawnWelcome(self: *App) !*Welcome { + /// 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.pushEntry(.{ .welcome = box }); + 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. - fn spawnThinking(self: *App) !*Thinking { + /// Fires `thinking`. + fn spawnThinking(self: *App, index: usize) !*Thinking { const box = try self.alloc.create(Thinking); box.* = Thinking.init(self.alloc); - try self.pushEntry(.{ .thinking = box }); + try self.pushEntryFired( + .{ .thinking = box }, + .thinking, + "thinking", + .{ .thinking = .{ .index = index } }, + ); return box; } - /// Spawn a tool-use entry. Inherits the app's current global collapse state - /// so a tool opened while everything is collapsed starts collapsed too. - fn spawnTool(self: *App) !*ToolUse { + /// 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); - try self.pushEntry(.{ .tool = box }); + // Fire `tool` at the creation boundary (name unknown => `tool (?)`). + try self.pushEntryFired( + .{ .tool = box }, + .tool, + "tool", + .{ .tool = .{ .index = index } }, + ); return box; } - /// Spawn a compaction-summary entry seeded with `summary`. + /// 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.pushEntry(.{ .compaction = box }); + try self.pushEntryFired( + .{ .compaction = box }, + .compaction, + "compaction", + .{ .compaction = .{ .summary = summary } }, + ); return box; } @@ -353,7 +655,7 @@ pub const App = struct { pub fn toggleToolCollapse(self: *App) void { self.tools_collapsed = !self.tools_collapsed; for (self.transcript.items) |e| { - if (e == .tool) e.tool.setCollapsed(self.tools_collapsed); + if (e.kind == .tool) e.kind.tool.setCollapsed(self.tools_collapsed); } self.scheduler.requestRender(); } @@ -403,17 +705,20 @@ pub const App = struct { .block_start => |b| { switch (b.block_type) { .Text => { - const box = try self.spawnAssistant(); + const box = try self.spawnAssistant(b.index); try self.router.put(b.index, .{ .assistant = box }); }, .Thinking => { - const box = try self.spawnThinking(); + 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. - const box = try self.spawnTool(); + // 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 => {}, @@ -423,7 +728,15 @@ pub const App = struct { .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. @@ -437,22 +750,55 @@ pub const App = struct { 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. + // 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| { @@ -463,6 +809,14 @@ pub const App = struct { 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 => {}, @@ -526,6 +880,13 @@ pub const App = struct { 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 => {}, @@ -692,7 +1053,11 @@ pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void { // 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(); + 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); @@ -1128,8 +1493,8 @@ test "routeEvent: provider_retry adds a dim status line" { } }); 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); + 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" { @@ -1348,10 +1713,10 @@ test "spawnWelcome shows a session-start banner entry" { const h = try Harness.make(alloc); defer h.teardown(alloc); - const w = try h.app.spawnWelcome(); + 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] == .welcome); + try testing.expect(h.app.transcript.items[0].kind == .welcome); } test "routeEvent: compaction summary block spawns a compaction entry" { @@ -1368,7 +1733,621 @@ test "routeEvent: compaction summary block spawns a compaction entry" { } }); try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len); - try testing.expect(h.app.transcript.items[0] == .compaction); + 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" { |
