diff options
Diffstat (limited to 'src/tui_event.zig')
| -rw-r--r-- | src/tui_event.zig | 577 |
1 files changed, 577 insertions, 0 deletions
diff --git a/src/tui_event.zig b/src/tui_event.zig new file mode 100644 index 0000000..70d2fca --- /dev/null +++ b/src/tui_event.zig @@ -0,0 +1,577 @@ +//! 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. +//! +//! ## Bridge friendliness (§7.6) +//! +//! Dispatch is a vtable of function pointers over `*anyopaque`, matching the +//! `Component` vtable in `tui_component.zig`. A Lua-backed (or future C-ABI) +//! handler implements the same `Handler` callback shape; a Lua-defined +//! component implements the same `Component` vtable across the bridge. Nothing +//! here knows or cares whether a handler/component is native or bridged. The +//! Lua side is implemented in a LATER sub-phase; this module is Zig-only and +//! must not depend on the Lua machinery. + +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`, so a native closure, a Lua-backed handler, or a future +/// C-ABI handler all plug into the same shape (§7.6). +/// +/// 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 +/// (the §7.2 `event.tool_name`, `event.args`, … shape). A tagged union keeps +/// the per-event fields explicit and bridge-friendly (the Lua bridge maps each +/// variant's fields onto the `event` object's properties). +/// +/// New built-in event types add a variant here; extension-defined events use +/// `.custom` with an opaque pointer the emitter and handler agree on. Borrowed +/// slices are valid only for the duration of the `emit` call (handlers must +/// copy anything they retain), mirroring the streaming-event borrow contract +/// elsewhere in panto. +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. The emitter and handler agree on the + /// meaning of `data`; panto does not interpret it. + custom: Custom, + + 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 = "", + }; + pub const Compaction = struct { + summary: []const u8 = "", + }; + pub const Custom = struct { + data: ?*anyopaque = null, + }; +}; + +// =========================================================================== +// 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")); +} |
