summaryrefslogtreecommitdiff
path: root/src/tui_app.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tui_app.zig')
-rw-r--r--src/tui_app.zig1103
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" {