//! The extension UI event system (plan §7): ONE string-keyed mechanism for all //! extension UI. //! //! ## The model (§7) //! //! There is exactly one way for a component to get on screen: pick an event //! string, register a handler that sets a component for it, then emit the event //! at the component's creation boundary. Built-in events (`session_start`, //! `user_message`, `thinking`, `assistant_text`, `tool`, `compaction`) are just //! event strings panto emits itself; extension events are mechanically //! identical. There is no separate `addComponent` API — additions are always //! tied to an event firing. //! //! A handler receives an `*Event` carrying: //! - the event NAME, //! - the CURRENT chosen `Component` (the built-in default at first, or //! whatever a prior handler set) via `getComponent()` / `setComponent()`, //! - structured per-event DATA (e.g. `tool_name`, `args`) via `payload`. //! //! ### Precedence (§7.3) //! //! Handlers run in REGISTRATION ORDER. Precedence is last-`setComponent`-wins //! ("last-wins-blind"): the final component set is used. There is no merge. The //! documented, expected pattern is to WRAP — read the current component, deco- //! rate/replace it, set it back: //! //! bus.on("tool", myHandler); // myHandler: get -> wrap -> set //! //! A handler that clobbers without reading the current component is at fault, //! not the framework. //! //! ### Streaming lifecycle & mid-stream swaps (§7.4, revised) //! //! The original §7.4 said an event fires ONCE at creation, before first paint. //! That was a simplification. The streaming block types now emit a UNIFORM //! LIFECYCLE of events, and `setComponent` works at ANY of them — not just the //! creation boundary: //! //! - thinking: `thinking` -> `thinking_delta`* -> `thinking_complete` //! - assistant text: `assistant_text` -> `assistant_text_delta`* -> //! `assistant_text_complete` //! - tool: `tool` (name UNKNOWN, component shows `tool (?)`) -> //! `tool_details` (name resolved) -> `tool_delta`* (args //! JSON streaming) -> `tool_call_complete` (full args) -> //! `tool_result` (the atomic result block lands) //! - user/session/compaction: fire once (no streaming). //! //! (`*` = fires per streaming chunk.) `tool_call_complete` is the end of the //! tool CALL, NOT the end of all `tool_*` events: the result arrives afterward //! as `tool_result` (tool results are atomic, delivered out-of-band). //! //! A handler may `setComponent` at any of these. When it sets a component that //! differs from the slot's current one, the call site SWAPS it in mid-stream //! (see the app's `fireForEntry`): the new component takes over the rendered //! region (full repaint from line 0, orphaned lines from a taller predecessor //! cleared) while panto KEEPS DRIVING the structured deltas into the slot's //! typed default box. The documented wrap pattern (`getComponent` -> wrap -> //! `setComponent`) makes this transparent: the wrapper forwards drive calls to //! the inner default box and renders through it. //! //! Why per-chunk delta events at all (they fire alongside an existing render): //! the chosen component already re-renders on every delta, so a per-delta //! handler hook is marginal cost on top of work panto already does — and Lua //! (the extension language) was chosen for exactly that efficiency. The delta //! events fire at the SAME boundary the component re-renders; they add no new //! render cadence. //! //! ### No "active component" (§6) //! //! Each streamable event yields its OWN component instance, keyed by //! call-id/block-index at the call site. The bus itself holds no per-event //! component state across emits: every `emit` is seeded with that boundary's //! own default and returns that boundary's own chosen component. Parallel tool //! calls each get their own. //! const std = @import("std"); const component = @import("tui_component.zig"); const Component = component.Component; // =========================================================================== // Handler // =========================================================================== /// A registered event handler. Vtable-style: a `callback` function pointer over /// an opaque `ctx`. /// /// The callback receives the live `*Event`; it inspects `payload`, reads the /// current component with `event.getComponent()`, and optionally replaces it /// with `event.setComponent()`. Its return is void — the chosen component is /// communicated through the event, not the return value (so the wrap pattern is /// natural and precedence is last-wins). pub const Handler = struct { ctx: *anyopaque, callback: *const fn (ctx: *anyopaque, event: *Event) void, pub fn call(self: Handler, event: *Event) void { self.callback(self.ctx, event); } }; // =========================================================================== // Payload — structured per-event data (§7.2) // =========================================================================== /// Structured data carried by an event, surfaced to handlers as typed fields. /// Borrowed slices are valid only for the duration of the `emit` call. pub const Payload = union(enum) { /// `session_start`: the welcome/banner boundary. session_start: SessionStart, /// `user_message`: a submitted user message. user_message: UserMessage, /// `thinking` / `thinking_delta` / `thinking_complete`: a streaming /// thinking block's lifecycle. The shared `Thinking` payload carries the /// block index plus the streaming `delta` (empty at start/complete) and /// the accumulated `text` (empty until a delta/complete carries it). thinking: Thinking, /// `assistant_text` / `assistant_text_delta` / `assistant_text_complete`: /// a streaming assistant text block's lifecycle. Same shape as `Thinking`. assistant_text: AssistantText, /// `tool` / `tool_details` / `tool_delta` / `tool_call_complete` / /// `tool_result`: the tool-use lifecycle. The shared `Tool` payload /// carries the block index, the resolved name (empty until `tool_details`, /// e.g. at the `tool` start boundary where the component shows `tool (?)`), /// the streaming args `delta`, the accumulated args `input`, the result /// `output`, and the tool-call `id` (set once resolved). tool: Tool, /// `compaction`: a compaction-summary boundary. compaction: Compaction, /// An extension-defined event with no structured payload. custom: void, pub const SessionStart = struct { version: []const u8 = "", cwd: []const u8 = "", model: []const u8 = "", }; pub const UserMessage = struct { text: []const u8 = "", }; /// Lifecycle payload shared by `thinking`, `thinking_delta`, and /// `thinking_complete`. Which fields are populated depends on the event: /// - `thinking` (start): only `index`. /// - `thinking_delta`: `index`, `delta` (this chunk), `text` (the /// accumulated buffer so far, including this chunk). /// - `thinking_complete`: `index`, `text` (the final buffer); `delta` /// empty. pub const Thinking = struct { /// libpanto block index for this thinking block. index: usize = 0, /// The streaming chunk for a `*_delta` event; empty otherwise. delta: []const u8 = "", /// The accumulated text so far (delta) or the final text (complete); /// empty at the start boundary. text: []const u8 = "", }; /// Lifecycle payload shared by `assistant_text`, `assistant_text_delta`, /// and `assistant_text_complete`. Same field semantics as `Thinking`. pub const AssistantText = struct { /// libpanto block index for this text block. index: usize = 0, /// The streaming chunk for a `*_delta` event; empty otherwise. delta: []const u8 = "", /// The accumulated text so far (delta) or the final text (complete); /// empty at the start boundary. text: []const u8 = "", }; /// Lifecycle payload shared by all `tool*` events. Which fields are /// populated depends on the event: /// - `tool` (start): `index`; `tool_name` empty (`tool (?)`). /// - `tool_details`: `index`, `tool_name`, `id`. /// - `tool_delta`: `index`, `tool_name` (if known), `delta` (this args /// chunk), `input` (accumulated args so far). /// - `tool_call_complete`: `index`, `tool_name`, `id`, `input` (final /// args). /// - `tool_result`: `index` (best-effort), `tool_name`, `id`, `output` /// (the result text). pub const Tool = struct { /// libpanto block index for this tool-use block. index: usize = 0, /// Tool name if known at the boundary, else empty (the `tool` start /// event fires before the streamed name resolves; the component shows /// `tool (?)` until `tool_details`). tool_name: []const u8 = "", /// Tool-call id, once resolved (from `tool_details`/`tool_call_complete` /// /`tool_result`); empty at the start boundary. id: []const u8 = "", /// The streaming args chunk for `tool_delta`; empty otherwise. delta: []const u8 = "", /// Accumulated args JSON (delta/complete), or empty. input: []const u8 = "", /// Tool result text for `tool_result`; empty otherwise. output: []const u8 = "", /// Whether tool output should render collapsed at this boundary. collapsed: bool = true, }; pub const Compaction = struct { summary: []const u8 = "", }; }; // =========================================================================== // Event // =========================================================================== /// The live object a handler receives. Holds the event name, the current /// chosen component, and the structured payload. /// /// Lifecycle: the emitter constructs an `Event` seeded with the built-in /// default component (or null when there is no default), runs every handler in /// registration order, and then reads `current` as the final chosen component. /// `getComponent` returns whatever is current — the default before any handler /// runs, then whatever the most recent `setComponent` installed (§7.2). It is /// not a frozen "default". pub const Event = struct { name: []const u8, /// The currently chosen component for this event: the seeded default first, /// then whatever a handler last set. Null is legal (an event with no /// default and no handler that sets one). current: ?Component, payload: Payload, /// Construct an event seeded with `default` as the initial component. pub fn init(name: []const u8, default: ?Component, payload: Payload) Event { return .{ .name = name, .current = default, .payload = payload }; } /// The component currently chosen for this event (§7.2). Returns the /// running current value — the default until a handler changes it, then the /// last-set component. pub fn getComponent(self: *const Event) ?Component { return self.current; } /// Set/replace the chosen component (§7.2). Last writer wins (§7.3). pub fn setComponent(self: *Event, c: Component) void { self.current = c; } }; // =========================================================================== // EventBus // =========================================================================== /// The registry of event-name -> ordered handler list, plus the emit walk. /// /// `on` appends a handler under an event name (creating the bucket on first /// use), preserving registration order. `emit` seeds an `Event` with the /// caller's default component, runs every handler for that name in order, and /// returns the final chosen component. /// /// Ownership: the bus owns its name-keyed buckets and the handler arrays; it /// does NOT own handler `ctx` pointers or any component (those are owned by /// their registrant / the transcript). `deinit` frees only the bus's own /// bookkeeping. pub const EventBus = struct { alloc: std.mem.Allocator, /// event name -> ordered list of handlers (registration order). handlers: std.StringHashMapUnmanaged(std.ArrayListUnmanaged(Handler)) = .empty, /// Owned copies of the event-name keys (the map borrows these). keys: std.ArrayListUnmanaged([]u8) = .empty, pub fn init(alloc: std.mem.Allocator) EventBus { return .{ .alloc = alloc }; } pub fn deinit(self: *EventBus) void { var it = self.handlers.valueIterator(); while (it.next()) |list| list.deinit(self.alloc); self.handlers.deinit(self.alloc); for (self.keys.items) |k| self.alloc.free(k); self.keys.deinit(self.alloc); } /// Register `handler` for `name`. Handlers fire in registration order on /// `emit`. The same name may have many handlers; the same handler may be /// registered more than once (it then fires that many times). `name` is /// copied into bus-owned storage on first use, so the caller need not keep /// it alive. pub fn on(self: *EventBus, name: []const u8, handler: Handler) !void { const gop = try self.handlers.getOrPut(self.alloc, name); if (!gop.found_existing) { // First handler for this name: own a stable copy of the key so the // map's key slice outlives the caller's `name` argument. const key_copy = try self.alloc.dupe(u8, name); errdefer self.alloc.free(key_copy); try self.keys.append(self.alloc, key_copy); gop.key_ptr.* = key_copy; gop.value_ptr.* = .empty; } try gop.value_ptr.append(self.alloc, handler); } /// Fire the event named `event.name`, running every registered handler in /// registration order. The passed `event` is seeded by the caller with its /// boundary-local default component (`Event.init`); each handler may read /// `getComponent()` and replace it with `setComponent()`. Returns the final /// chosen component (the seeded default if no handler changed it, or null /// if there was no default and none was set). /// /// No "active component" (§6): the bus stores no component across emits. /// Each emit operates only on the `event` the caller owns, so two /// concurrent `tool` boundaries each pass their own `event` (with their own /// default) and get back their own chosen component. pub fn emit(self: *EventBus, event: *Event) ?Component { if (self.handlers.getPtr(event.name)) |list| { for (list.items) |h| h.call(event); } return event.current; } /// Convenience: seed an `Event` with `default` + `payload`, emit it, and /// return the chosen component. The transient event lives only for the /// call. Equivalent to constructing an `Event` and calling `emit`. pub fn fire( self: *EventBus, name: []const u8, default: ?Component, payload: Payload, ) ?Component { var ev = Event.init(name, default, payload); return self.emit(&ev); } /// Number of handlers registered for `name` (0 if none). Diagnostic/test /// helper. pub fn handlerCount(self: *const EventBus, name: []const u8) usize { if (self.handlers.getPtr(name)) |list| return list.items.len; return 0; } }; // =========================================================================== // Tests // =========================================================================== const testing = std.testing; /// A trivial test component: renders one fixed line. Identity is its `tag` so /// tests can assert which component came out of an emit. const FakeComponent = struct { tag: u8, line_storage: [1][]const u8 = undefined, fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = width; _ = alloc; const self: *FakeComponent = @ptrCast(@alignCast(ptr)); self.line_storage[0] = "x"; return self.line_storage[0..]; } fn firstLineChangedImpl(ptr: *anyopaque) ?usize { _ = ptr; return 0; } fn invalidateImpl(ptr: *anyopaque) void { _ = ptr; } const vtable = Component.VTable{ .render = renderImpl, .firstLineChanged = firstLineChangedImpl, .invalidate = invalidateImpl, }; fn comp(self: *FakeComponent) Component { return .{ .ptr = self, .vtable = &vtable }; } }; test "emit with zero handlers returns the seeded default unchanged" { var bus = EventBus.init(testing.allocator); defer bus.deinit(); var def = FakeComponent{ .tag = 1 }; const out = bus.fire("tool", def.comp(), .{ .tool = .{ .index = 0 } }); try testing.expect(out != null); try testing.expectEqual(@as(*anyopaque, def.comp().ptr), out.?.ptr); // And a null default passes through as null. const none = bus.fire("nope", null, .{ .custom = {} }); try testing.expect(none == null); } test "getComponent returns the running current (default, then prior handler's)" { var bus = EventBus.init(testing.allocator); defer bus.deinit(); var def = FakeComponent{ .tag = 1 }; var replacement = FakeComponent{ .tag = 2 }; const Ctx = struct { replacement: *FakeComponent, default_ptr: *anyopaque, saw_default_first: bool = false, fn cb(ctx: *anyopaque, ev: *Event) void { const self: *@This() = @ptrCast(@alignCast(ctx)); // Before this handler sets anything, getComponent is the default. if (ev.getComponent()) |cur| { if (cur.ptr == self.default_ptr) self.saw_default_first = true; } ev.setComponent(self.replacement.comp()); } }; var ctx = Ctx{ .replacement = &replacement, .default_ptr = def.comp().ptr }; try bus.on("tool", .{ .ctx = &ctx, .callback = Ctx.cb }); const out = bus.fire("tool", def.comp(), .{ .tool = .{ .index = 0 } }); try testing.expect(ctx.saw_default_first); try testing.expectEqual(@as(*anyopaque, replacement.comp().ptr), out.?.ptr); } test "handlers run in registration order, last setComponent wins" { var bus = EventBus.init(testing.allocator); defer bus.deinit(); var def = FakeComponent{ .tag = 0 }; var a = FakeComponent{ .tag = 1 }; var b = FakeComponent{ .tag = 2 }; // Record the order handlers observed, and have each set its own component. var order: std.ArrayListUnmanaged(u8) = .empty; defer order.deinit(testing.allocator); const Ctx = struct { which: *FakeComponent, order: *std.ArrayListUnmanaged(u8), alloc: std.mem.Allocator, fn cb(ctx: *anyopaque, ev: *Event) void { const self: *@This() = @ptrCast(@alignCast(ctx)); self.order.append(self.alloc, self.which.tag) catch {}; ev.setComponent(self.which.comp()); } }; var ca = Ctx{ .which = &a, .order = &order, .alloc = testing.allocator }; var cb = Ctx{ .which = &b, .order = &order, .alloc = testing.allocator }; try bus.on("tool", .{ .ctx = &ca, .callback = Ctx.cb }); try bus.on("tool", .{ .ctx = &cb, .callback = Ctx.cb }); const out = bus.fire("tool", def.comp(), .{ .tool = .{} }); // Registration order: a then b. try testing.expectEqualSlices(u8, &.{ 1, 2 }, order.items); // Last writer (b) wins. try testing.expectEqual(@as(*anyopaque, b.comp().ptr), out.?.ptr); } test "wrapping pattern: handler reads default, wraps, sets" { var bus = EventBus.init(testing.allocator); defer bus.deinit(); var def = FakeComponent{ .tag = 7 }; // A wrapper component that decorates an inner component (records the inner // ptr so we can assert the handler read the default). const Wrapper = struct { inner: Component, line_storage: [1][]const u8 = undefined, fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { const self: *@This() = @ptrCast(@alignCast(ptr)); return self.inner.render(width, alloc); } fn firstLineChangedImpl(ptr: *anyopaque) ?usize { const self: *@This() = @ptrCast(@alignCast(ptr)); return self.inner.firstLineChanged(); } fn invalidateImpl(ptr: *anyopaque) void { const self: *@This() = @ptrCast(@alignCast(ptr)); self.inner.invalidate(); } const vtable = Component.VTable{ .render = renderImpl, .firstLineChanged = firstLineChangedImpl, .invalidate = invalidateImpl, }; fn comp(self: *@This()) Component { return .{ .ptr = self, .vtable = &vtable }; } }; var wrapper: Wrapper = undefined; const Ctx = struct { wrapper: *Wrapper, fn cb(ctx: *anyopaque, ev: *Event) void { const self: *@This() = @ptrCast(@alignCast(ctx)); const inner = ev.getComponent().?; // the default self.wrapper.* = .{ .inner = inner }; ev.setComponent(self.wrapper.comp()); } }; var ctx = Ctx{ .wrapper = &wrapper }; try bus.on("tool", .{ .ctx = &ctx, .callback = Ctx.cb }); const out = bus.fire("tool", def.comp(), .{ .tool = .{} }); // The chosen component is the wrapper, and it wraps the default. try testing.expectEqual(@as(*anyopaque, wrapper.comp().ptr), out.?.ptr); try testing.expectEqual(@as(*anyopaque, def.comp().ptr), wrapper.inner.ptr); } test "two concurrent tool events get independent components (no active component)" { var bus = EventBus.init(testing.allocator); defer bus.deinit(); // A handler that, for each tool event, mints a distinct component keyed by // the event's block index — proving the bus holds no shared/active state. var comps = [_]FakeComponent{ .{ .tag = 10 }, .{ .tag = 11 }, }; const Ctx = struct { comps: []FakeComponent, fn cb(ctx: *anyopaque, ev: *Event) void { const self: *@This() = @ptrCast(@alignCast(ctx)); const idx = ev.payload.tool.index; ev.setComponent(self.comps[idx].comp()); } }; var ctx = Ctx{ .comps = &comps }; try bus.on("tool", .{ .ctx = &ctx, .callback = Ctx.cb }); // Two separate boundaries, each with its own default + index. var def0 = FakeComponent{ .tag = 0 }; var def1 = FakeComponent{ .tag = 1 }; const out0 = bus.fire("tool", def0.comp(), .{ .tool = .{ .index = 0 } }); const out1 = bus.fire("tool", def1.comp(), .{ .tool = .{ .index = 1 } }); try testing.expectEqual(@as(*anyopaque, comps[0].comp().ptr), out0.?.ptr); try testing.expectEqual(@as(*anyopaque, comps[1].comp().ptr), out1.?.ptr); // Distinct instances. try testing.expect(out0.?.ptr != out1.?.ptr); } test "on copies the event-name key (caller need not keep it alive)" { var bus = EventBus.init(testing.allocator); defer bus.deinit(); var name_buf: [8]u8 = undefined; @memcpy(name_buf[0..4], "tool"); const transient = name_buf[0..4]; var def = FakeComponent{ .tag = 1 }; var replacement = FakeComponent{ .tag = 2 }; const Ctx = struct { replacement: *FakeComponent, fn cb(ctx: *anyopaque, ev: *Event) void { const self: *@This() = @ptrCast(@alignCast(ctx)); ev.setComponent(self.replacement.comp()); } }; var ctx = Ctx{ .replacement = &replacement }; try bus.on(transient, .{ .ctx = &ctx, .callback = Ctx.cb }); // Scribble over the caller's buffer; the bus must have its own copy. @memcpy(name_buf[0..4], "ZZZZ"); const out = bus.fire("tool", def.comp(), .{ .tool = .{} }); try testing.expectEqual(@as(*anyopaque, replacement.comp().ptr), out.?.ptr); try testing.expectEqual(@as(usize, 1), bus.handlerCount("tool")); }