//! Lua bridge for the extension UI event system (plan §7.6). //! //! This module bridges the native event machinery in `tui_event.zig` to //! Lua: it lets a Lua `panto.ext.on(name, handler)` callback participate in //! the SAME `EventBus` the native side fires, receive a bridged `event` //! object (`get_component`/`set_component` + payload fields), and either //! pass through a native default component, wrap it, or install a //! brand-new component DEFINED IN LUA. //! //! It is owned by the long-lived `LuaRuntime` (one `lua_State` for the //! whole session) and shares that state. It never opens its own state. //! //! ## Synchronous rendering (NOT the coroutine scheduler) //! //! A Lua-defined component's `render(width)` is on the engine's hot path: //! the differential renderer calls it and needs the produced lines back //! IMMEDIATELY. So the bridged vtable calls into Lua **synchronously** via //! `lua_pcallk` (the same model as `LuaRuntime.runCommand`), NOT through //! the libuv coroutine scheduler used for async tool batches. A render //! callback may not yield; if a Lua component blocks, that is a bug in the //! extension, and we surface a safe fallback line rather than hanging or //! corrupting the frame. //! //! ## Cache-derived `firstLineChanged` //! //! Each bridged component owns a native `RenderCache` (the same one native //! components use). The Lua side only returns an array of line strings per //! `render`; the bridge calls `cache.store(lines)` and derives //! `firstLineChanged` from the cache diff exactly like a native component. //! This keeps the cache-derived dirty-model invariant (§3.3) and frees Lua //! authors from implementing `firstLineChanged` correctly. A Lua-provided //! `firstLineChanged` override could be added later; cache-derived is the //! correct, safe default. //! //! ## Lifetime / ownership (§6, §7.4) //! //! Bridged components and Lua handlers reference Lua functions/tables that //! must survive as long as the engine borrows them. Both are `luaL_ref`'d //! into the registry; the refs are owned by this bridge and released at //! runtime teardown. A bridged component (its `luaL_ref` + `RenderCache`) //! is heap-allocated and tracked in `components`, freed on `invalidate` //! and at teardown. There is no "active component": each `emit` that //! produces a Lua component mints a fresh instance keyed by that //! boundary's own event, exactly like native components. const std = @import("std"); const lua_bridge = @import("lua_bridge.zig"); const ui_event = @import("tui_event.zig"); const component = @import("tui_component.zig"); const components = @import("tui_components.zig"); const theme = @import("tui_theme.zig"); const engine = @import("tui_engine.zig"); const c = lua_bridge.c; const Component = component.Component; const RenderCache = component.RenderCache; const Event = ui_event.Event; const Payload = ui_event.Payload; const EventBus = ui_event.EventBus; const Handler = ui_event.Handler; // Metatable names (registered via `luaL_newmetatable`, looked up by // `luaL_checkudata`/`luaL_testudata`). The `event` userdata identifies a // bridged event object; the `component` userdata is a native-component // passthrough handle. const eventMtName = "panto.event"; const nativeCompMtName = "panto.component"; /// A component DEFINED IN LUA, bridged to the native `Component` vtable. /// /// `table_ref` is a `luaL_ref` to the Lua table implementing /// `render(self, width) -> {strings}` plus optional `firstLineChanged` / /// `handleInput` / `invalidate` methods. The bridge owns a `RenderCache` /// and derives the dirty signal from it. pub const BridgedComponent = struct { bridge: *EventBridge, /// `luaL_ref` to the Lua component table. table_ref: c_int, cache: RenderCache, /// True once freed, to make double-free / use-after-free a no-op. dead: bool = false, fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = alloc; const self: *BridgedComponent = @ptrCast(@alignCast(ptr)); return self.bridge.renderBridged(self, width); } fn firstLineChangedImpl(ptr: *anyopaque) ?usize { const self: *BridgedComponent = @ptrCast(@alignCast(ptr)); return self.cache.firstLineChanged(); } fn invalidateImpl(ptr: *anyopaque) void { const self: *BridgedComponent = @ptrCast(@alignCast(ptr)); self.cache.invalidate(); } fn handleInputImpl(ptr: *anyopaque, data: []const u8) void { const self: *BridgedComponent = @ptrCast(@alignCast(ptr)); self.bridge.handleInputBridged(self, data); } /// The shared vtable for every Lua-backed component. Its ADDRESS is the /// discriminator used by `EventBridge.releaseOverride` to recognize a /// `Component` as bridged: a `Component` whose `vtable` pointer equals /// `&BridgedComponent.vtable` is known to have `ptr == *BridgedComponent`. /// Native (non-Lua) components carry a different vtable address, so the /// release hook can safely leave them alone. `pub` so the discriminator is /// addressable from the release path (and tests). pub const vtable = Component.VTable{ .render = renderImpl, .firstLineChanged = firstLineChangedImpl, .invalidate = invalidateImpl, .handleInput = handleInputImpl, }; pub fn comp(self: *BridgedComponent) Component { return .{ .ptr = self, .vtable = &vtable }; } }; /// A registered Lua `on` handler, bridged to a native `Handler`. /// /// `fn_ref` is a `luaL_ref` to the Lua handler function. The native /// `Handler.ctx` points at this struct; `Handler.callback` is /// `nativeHandlerCallback`, which builds the bridged `event` object, calls /// the Lua function, and reads back any component it set. pub const LuaHandler = struct { bridge: *EventBridge, fn_ref: c_int, /// Bridge-owned copy of the event name this handler subscribed to. event_name: []const u8, }; /// The bridge: owns the Lua-side event wiring and the bridged /// components/handlers. Lives inside the `LuaRuntime`. pub const EventBridge = struct { alloc: std.mem.Allocator, L: *c.lua_State, /// The native bus a Lua `emit` drives, and that fires Lua handlers. /// Set once the App is constructed (`attachBus`); null before then, in /// which case a Lua `emit` is a no-op (nothing to fire into yet). bus: ?*EventBus = null, /// Owned Lua handler bridges (one per `panto.ext.on` registration). handlers: std.ArrayListUnmanaged(*LuaHandler) = .empty, /// Owned bridged components, tracked so their refs + caches are freed /// at teardown. Each is heap-allocated; an `invalidate` frees it. components: std.ArrayListUnmanaged(*BridgedComponent) = .empty, /// During a native `emit`, the event currently being presented to a /// Lua handler. The bridged `event` userdata's methods read/write /// through this. Single-threaded; valid only for the duration of one /// handler call. `null` outside a handler. active_event: ?*Event = null, pub fn init(alloc: std.mem.Allocator, L: *c.lua_State) EventBridge { return .{ .alloc = alloc, .L = L }; } pub fn deinit(self: *EventBridge) void { for (self.handlers.items) |h| { c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, h.fn_ref); self.alloc.free(h.event_name); self.alloc.destroy(h); } self.handlers.deinit(self.alloc); for (self.components.items) |comp| self.freeComponentInner(comp); self.components.deinit(self.alloc); } /// Bind the native `EventBus` this bridge fires into / drives via /// `emit`. Called once during startup wiring, after the App's bus /// exists. Also registers every harvested Lua `on` handler into the /// bus, in registration order. pub fn attachBus(self: *EventBridge, bus: *EventBus) !void { self.bus = bus; for (self.handlers.items) |h| { try bus.on(eventNameOf(h), .{ .ctx = h, .callback = nativeHandlerCallback }); } } /// The event name a handler subscribed to is stored alongside its ref /// at registration time. We keep it on the LuaHandler via a parallel /// slice; but to avoid a second allocation we look it up lazily. (Set /// in `registerOnHandler`.) fn eventNameOf(h: *LuaHandler) []const u8 { return h.event_name; } /// Record a Lua `on` handler: `luaL_ref` the function at stack index /// `fn_idx` and remember the event name. Registration into the bus /// happens later in `attachBus` (the bus may not exist yet at harvest /// time). Order of calls here is the registration order (§7.3). pub fn registerOnHandler(self: *EventBridge, event_name: []const u8, fn_idx: c_int) !void { // Dupe the event name into bridge-owned storage. const name_copy = try self.alloc.dupe(u8, event_name); errdefer self.alloc.free(name_copy); // luaL_ref pops the top of stack, so push a copy of the function. c.lua_pushvalue(self.L, fn_idx); const ref = c.luaL_ref(self.L, lua_bridge.LUA_REGISTRYINDEX); errdefer c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, ref); const h = try self.alloc.create(LuaHandler); h.* = .{ .bridge = self, .fn_ref = ref, .event_name = name_copy }; try self.handlers.append(self.alloc, h); } /// Number of registered Lua `on` handlers (diagnostic/test helper). pub fn handlerCount(self: *const EventBridge) usize { return self.handlers.items.len; } // -- bridged component construction ------------------------------------ /// Build a native `Component` backed by the Lua component table at /// stack index `table_idx`. `luaL_ref`s the table, allocates a tracked /// `BridgedComponent`, and returns its `comp()`. The component lives /// until `invalidate` (which frees it) or runtime teardown. /// /// No "active component": each call mints a fresh instance. fn makeBridgedComponent(self: *EventBridge, table_idx: c_int) !Component { c.lua_pushvalue(self.L, table_idx); const ref = c.luaL_ref(self.L, lua_bridge.LUA_REGISTRYINDEX); errdefer c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, ref); const bc = try self.alloc.create(BridgedComponent); errdefer self.alloc.destroy(bc); bc.* = .{ .bridge = self, .table_ref = ref, .cache = RenderCache.init(self.alloc) }; try self.components.append(self.alloc, bc); return bc.comp(); } /// Free a bridged component's Lua ref + cache and destroy it. Idempotent /// via the `dead` flag, BUT note it does NOT remove `bc` from /// `self.components` and it `destroy`s the allocation — so it must only be /// used in one of two safe ways: /// - at teardown (`deinit`), iterating the whole list once; or /// - via `releaseOverride`, which removes `bc` from `self.components` /// BEFORE calling this, so teardown never revisits a destroyed pointer. fn freeComponentInner(self: *EventBridge, bc: *BridgedComponent) void { if (bc.dead) return; bc.dead = true; c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, bc.table_ref); bc.cache.deinit(); self.alloc.destroy(bc); } /// Release a superseded override component handed back by the App's /// mid-stream swap (`App.override_release_fn`). This is the leak-prevention /// point: when a `tool_details` (or any lifecycle) handler swaps a NEW /// component over a PRIOR Lua-backed override, the prior one must drop its /// `luaL_ref` + `RenderCache`, or it leaks for the life of the runtime /// (one leak per swapped tool call). /// /// Discriminator: a `Component` is Lua-backed iff its `vtable` pointer is /// `&BridgedComponent.vtable` (the single shared vtable address; see /// `BridgedComponent.vtable`). For such a component `ptr` is a /// `*BridgedComponent` we own and track in `self.components`. Any other /// vtable belongs to a NATIVE component the App owns; we must NOT touch it /// (it is not ours to free), so the hook is a no-op for it. /// /// Ownership: we remove `bc` from `self.components` first, then free it, so /// teardown's `deinit` loop never revisits the destroyed pointer. pub fn releaseOverride(self: *EventBridge, old: Component) void { if (old.vtable != &BridgedComponent.vtable) return; // native: not ours const bc: *BridgedComponent = @ptrCast(@alignCast(old.ptr)); // Remove from the tracked list before freeing (swap-remove is fine; // order does not matter for teardown). for (self.components.items, 0..) |item, i| { if (item == bc) { _ = self.components.swapRemove(i); break; } } self.freeComponentInner(bc); } /// The `*anyopaque`-typed release callback the App invokes via /// `override_release_fn`. `ctx` is the `*EventBridge`. pub fn releaseOverrideThunk(ctx: *anyopaque, old: Component) void { const self: *EventBridge = @ptrCast(@alignCast(ctx)); self.releaseOverride(old); } // -- bridged render / input (SYNCHRONOUS pcall) ------------------------ /// Render a Lua-defined component synchronously: push its table + /// `render` method + width, `lua_pcallk`, marshal the returned /// array-of-strings into the component's `RenderCache`, and return the /// cached lines. On ANY Lua error or bad return, return a single dim /// error line instead of crashing the render loop (the frame must /// always complete). Every returned line is truncated to `width` so /// the engine's width contract (§3.1) is never violated. fn renderBridged(self: *EventBridge, bc: *BridgedComponent, width: usize) anyerror![]const []const u8 { const L = self.L; if (bc.dead) return &.{}; const base = c.lua_gettop(L); defer c.lua_settop(L, base); // restore stack no matter what // Push the component table, then its `render` method. _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(bc.table_ref)); if (c.lua_type(L, -1) != lua_bridge.T_TABLE) return self.fallbackLines(bc, width, "component is not a table"); _ = c.lua_getfield(L, -1, "render"); if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) { return self.fallbackLines(bc, width, "component has no render()"); } // Stack: ..., table, render. Call render(table, width) -> lines. c.lua_pushvalue(L, -2); // self (the table) c.lua_pushinteger(L, @intCast(width)); if (c.lua_pcallk(L, 2, 1, 0, 0, null) != 0) { return self.fallbackLines(bc, width, luaErrText(L)); } // Stack top: the returned lines table. const raw = lua_bridge.readLinesArray(L, -1, self.alloc) catch { return self.fallbackLines(bc, width, "render() must return an array of strings"); }; defer { for (raw) |ln| self.alloc.free(ln); self.alloc.free(raw); } // Enforce the width contract: truncate every line to `width` // columns. Build a transient view of truncated borrows for the // cache to dupe. var view: std.ArrayListUnmanaged([]const u8) = .empty; defer view.deinit(self.alloc); for (raw) |ln| { const vis = components.truncateToCols(ln, width); try view.append(self.alloc, vis); } try bc.cache.store(view.items); return cacheLines(&bc.cache); } /// Build the cache's single dim fallback line for a render failure and /// return it. Keeps the render loop alive on a misbehaving extension. /// /// The composed line MUST satisfy the engine's width contract (§3.1): /// visible width <= `width`, or the engine rejects it with /// `Error.LineOverflow` and crashes the very frame the fallback exists to /// save. The dim escapes are zero visible width, so we build the PLAIN /// error text, truncate THAT to `width` columns (codepoint-safe), and only /// then wrap it in the dim style. fn fallbackLines(self: *EventBridge, bc: *BridgedComponent, width: usize, why: []const u8) anyerror![]const []const u8 { _ = self; const dim = theme.default.fg(.dim); // Plain (escape-free) error text first, into a scratch buffer. var plain_buf: [256]u8 = undefined; const plain = std.fmt.bufPrint(&plain_buf, "[lua component error: {s}]", .{why}) catch "[lua component error]"; // Truncate the visible text to the width contract (codepoint-safe). const vis = components.truncateToCols(plain, width); // Compose the styled line; if even the styled form overflows the // compose buffer, fall back to the bare truncated plain text (which // already satisfies the width contract). var styled_buf: [512]u8 = undefined; const styled = std.fmt.bufPrint(&styled_buf, "{s}{s}{s}", .{ dim.open(), vis, dim.close() }) catch vis; const one = [_][]const u8{styled}; try bc.cache.store(&one); return cacheLines(&bc.cache); } /// Feed input to a Lua component's optional `handleInput(self, data)` /// method, synchronously. Errors are swallowed (input handling must /// never crash the loop); a component without the method ignores input. fn handleInputBridged(self: *EventBridge, bc: *BridgedComponent, data: []const u8) void { const L = self.L; if (bc.dead) return; const base = c.lua_gettop(L); defer c.lua_settop(L, base); _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(bc.table_ref)); if (c.lua_type(L, -1) != lua_bridge.T_TABLE) return; _ = c.lua_getfield(L, -1, "handleInput"); if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) return; c.lua_pushvalue(L, -2); // self _ = c.lua_pushlstring(L, data.ptr, data.len); // Any error: ignore (best-effort input). _ = c.lua_pcallk(L, 2, 0, 0, 0, null); // Mark the cache dirty after input. The engine reads // `firstLineChanged()` BEFORE calling `render`, so a clean cache means // the component is SKIPPED and the Lua-side mutation would never reach // the screen. Native components dirty on every input mutation (see // InputBox.applyKey -> markDirty); the bridge must do the same on the // component's behalf. We can't know what changed inside Lua, so we // re-dirty from 0; the post-render diff in `store` then recovers the // true lowest-changed line (preserving the streaming-tail property for // append-style edits). Dirty unconditionally even on a Lua error: the // handler may have partially mutated state before throwing. bc.cache.markDirty(); } }; // =========================================================================== // Native handler callback: native EventBus -> Lua `on` handler // =========================================================================== /// The native `Handler.callback` for a bridged Lua handler. Builds the /// `event` userdata, calls the Lua function with it (synchronously, under /// a traceback errfunc), and lets the Lua side read/replace the component /// via `event:get_component()` / `event:set_component()`. Errors in the Lua /// handler are logged and swallowed — a broken handler must not abort the /// event dispatch or the render loop. fn nativeHandlerCallback(ctx: *anyopaque, event: *Event) void { const h: *LuaHandler = @ptrCast(@alignCast(ctx)); const self = h.bridge; const L = self.L; // Make this event the active one so the userdata methods read/write it. const prev = self.active_event; self.active_event = event; defer self.active_event = prev; const base = c.lua_gettop(L); defer c.lua_settop(L, base); // Traceback errfunc beneath the call. _ = c.lua_getglobal(L, "debug"); _ = c.lua_getfield(L, -1, "traceback"); c.lua_copy(L, -1, -2); c.lua_settop(L, c.lua_gettop(L) - 1); // pop `debug` table const errfunc_idx = c.lua_gettop(L); _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(h.fn_ref)); pushEventObject(self) catch { c.lua_settop(L, base); return; }; if (c.lua_pcallk(L, 1, 0, errfunc_idx, 0, null) != 0) { var len: usize = 0; const msg = c.lua_tolstring(L, -1, &len); if (msg != null) { std.log.err("lua event handler '{s}' failed: {s}", .{ h.event_name, msg[0..len] }); } } } // =========================================================================== // The bridged `event` object (userdata + metatable) // =========================================================================== /// Push an `event` userdata onto the stack, carrying a pointer to the /// bridge (which reaches the active `*Event`). Its metatable exposes /// `get_component`, `set_component`, and read-only payload fields via /// `__index`. fn pushEventObject(bridge: *EventBridge) !void { const L = bridge.L; const ud: **EventBridge = @ptrCast(@alignCast(c.lua_newuserdatauv(L, @sizeOf(*EventBridge), 0).?)); ud.* = bridge; ensureEventMetatable(L); _ = c.lua_setmetatable(L, -2); } /// Lazily create the `event` userdata metatable (by name, so /// `luaL_checkudata` recognizes it) and leave it on the stack. Its /// `__index` is a function resolving `get_component`/`set_component` and /// payload fields. fn ensureEventMetatable(L: *c.lua_State) void { // luaL_newmetatable pushes the metatable; returns 1 if freshly created. if (c.luaL_newmetatable(L, eventMtName) != 0) { c.lua_pushcclosure(L, eventIndexThunk, 0); c.lua_setfield(L, -2, "__index"); } } /// `__index(event_ud, key)`: dispatch method names and payload fields. fn eventIndexThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; const ud: **EventBridge = @ptrCast(@alignCast(c.luaL_checkudata(L, 1, eventMtName) orelse return 0)); const bridge = ud.*; var klen: usize = 0; const kptr = c.lua_tolstring(L, 2, &klen); if (kptr == null) return 0; const key = kptr[0..klen]; if (std.mem.eql(u8, key, "get_component")) { c.lua_pushcclosure(L, eventGetComponentThunk, 0); return 1; } if (std.mem.eql(u8, key, "set_component")) { c.lua_pushcclosure(L, eventSetComponentThunk, 0); return 1; } // Payload fields + the event name. const ev = bridge.active_event orelse { c.lua_pushnil(L); return 1; }; if (std.mem.eql(u8, key, "name")) { _ = c.lua_pushlstring(L, ev.name.ptr, ev.name.len); return 1; } pushPayloadField(L, ev, key); return 1; } /// Push the payload field named `key` for `ev`, or nil if absent. Marshals /// the tagged-union variant's fields onto simple Lua values (§7.2). /// Push a string field as a Lua string, or `nil` when the slice is empty. /// Empty lifecycle fields (e.g. `delta` at a non-delta boundary, `tool_name` /// before `tool_details`) read as nil on the Lua side, so handlers can branch /// on presence (`if event.tool_name then ...`). fn pushStringOrNil(L: *c.lua_State, s: []const u8) void { if (s.len == 0) { c.lua_pushnil(L); } else { _ = c.lua_pushlstring(L, s.ptr, s.len); } } fn pushPayloadField(L: *c.lua_State, ev: *Event, key: []const u8) void { switch (ev.payload) { .tool => |t| { // index/tool_name plus the lifecycle fields: `id` (resolved // call id), `delta` (this args chunk on tool_delta), `input` // (accumulated/final args), `output` (the result text on // tool_result). String fields return nil when empty/absent so a // handler can test `if event.delta then ...`. if (std.mem.eql(u8, key, "index")) { c.lua_pushinteger(L, @intCast(t.index)); return; } if (std.mem.eql(u8, key, "tool_name")) return pushStringOrNil(L, t.tool_name); if (std.mem.eql(u8, key, "id")) return pushStringOrNil(L, t.id); if (std.mem.eql(u8, key, "delta")) return pushStringOrNil(L, t.delta); if (std.mem.eql(u8, key, "input")) return pushStringOrNil(L, t.input); if (std.mem.eql(u8, key, "output")) return pushStringOrNil(L, t.output); }, .thinking => |t| { // index plus `delta` (this chunk on *_delta) and `text` (the // accumulated/final buffer). Empty -> nil. if (std.mem.eql(u8, key, "index")) { c.lua_pushinteger(L, @intCast(t.index)); return; } if (std.mem.eql(u8, key, "delta")) return pushStringOrNil(L, t.delta); if (std.mem.eql(u8, key, "text")) return pushStringOrNil(L, t.text); }, .assistant_text => |t| { // Same lifecycle fields as thinking. if (std.mem.eql(u8, key, "index")) { c.lua_pushinteger(L, @intCast(t.index)); return; } if (std.mem.eql(u8, key, "delta")) return pushStringOrNil(L, t.delta); if (std.mem.eql(u8, key, "text")) return pushStringOrNil(L, t.text); }, .user_message => |m| { if (std.mem.eql(u8, key, "text")) { _ = c.lua_pushlstring(L, m.text.ptr, m.text.len); return; } }, .session_start => |s| { if (std.mem.eql(u8, key, "version")) { _ = c.lua_pushlstring(L, s.version.ptr, s.version.len); return; } if (std.mem.eql(u8, key, "cwd")) { _ = c.lua_pushlstring(L, s.cwd.ptr, s.cwd.len); return; } if (std.mem.eql(u8, key, "model")) { _ = c.lua_pushlstring(L, s.model.ptr, s.model.len); return; } }, .compaction => |cm| { if (std.mem.eql(u8, key, "summary")) { _ = c.lua_pushlstring(L, cm.summary.ptr, cm.summary.len); return; } }, .custom => {}, } c.lua_pushnil(L); } /// `event:get_component()` -> the current component as a native-passthrough /// userdata (or nil). The userdata wraps the `Component` (vtable+ptr) so /// Lua can pass it straight back to `set_component` unchanged (§7.5 wrap /// pattern: the inner is opaque to Lua but re-settable / wrappable). fn eventGetComponentThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; // arg 1 is the event userdata (method call `event:get_component()`). const ud: **EventBridge = @ptrCast(@alignCast(c.luaL_checkudata(L, 1, eventMtName) orelse return 0)); const bridge = ud.*; const ev = bridge.active_event orelse { c.lua_pushnil(L); return 1; }; if (ev.getComponent()) |comp| { pushNativeComponent(L, comp); } else { c.lua_pushnil(L); } return 1; } /// `event:set_component(c)` where `c` is EITHER a native-passthrough /// userdata (from `get_component`) OR a Lua component table. A table is /// bridged into a native `Component` (§7.6); a userdata passes the native /// component straight through. fn eventSetComponentThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; const ud: **EventBridge = @ptrCast(@alignCast(c.luaL_checkudata(L, 1, eventMtName) orelse return 0)); const bridge = ud.*; const ev = bridge.active_event orelse return 0; const ty = c.lua_type(L, 2); if (ty == lua_bridge.T_USERDATA) { // Native passthrough: recover the Component from the userdata. if (c.luaL_testudata(L, 2, nativeCompMtName)) |raw| { const cud: *Component = @ptrCast(@alignCast(raw)); ev.setComponent(cud.*); return 0; } return c.luaL_error(L, "set_component: unknown userdata (expected a component)"); } if (ty == lua_bridge.T_TABLE) { const comp = bridge.makeBridgedComponent(2) catch { return c.luaL_error(L, "set_component: failed to bridge Lua component"); }; ev.setComponent(comp); return 0; } return c.luaL_error(L, "set_component: argument must be a component (table or native handle)"); } /// Push a native `Component` as a passthrough userdata with the /// native-component metatable (so `set_component` can recover it). fn pushNativeComponent(L: *c.lua_State, comp: Component) void { const ud: *Component = @ptrCast(@alignCast(c.lua_newuserdatauv(L, @sizeOf(Component), 0).?)); ud.* = comp; ensureNativeCompMetatable(L); _ = c.lua_setmetatable(L, -2); } fn ensureNativeCompMetatable(L: *c.lua_State) void { // A bare named metatable (no methods); identity-only so // `luaL_testudata` can recognize the passthrough handle. _ = c.luaL_newmetatable(L, nativeCompMtName); } /// Read the top-of-stack Lua error as text (after a failed pcall). fn luaErrText(L: *c.lua_State) []const u8 { var len: usize = 0; const ptr = c.lua_tolstring(L, -1, &len); if (ptr == null) return "lua error"; return ptr[0..len]; } /// Re-type the cache's owned lines as `[]const []const u8` for the vtable /// return (mirrors the native components' `cacheLines`). fn cacheLines(cache: *RenderCache) []const []const u8 { const owned = cache.lines orelse return &.{}; return @ptrCast(owned); } // =========================================================================== // emit: Lua `panto.ext.emit(name, data)` -> native EventBus // =========================================================================== /// C thunk installed as `panto.ext.emit` by the runtime (`installEmit`), /// carrying the `*EventBridge` as a light-userdata upvalue. Fires a custom /// event on the native bus so native AND Lua handlers run. The `data` /// argument is currently surfaced to handlers only as a `.custom` payload /// (opaque); structured custom-data marshalling can be added later. The /// chosen component (if any handler set one) is NOT auto-mounted here — /// imperative mounting from a bare `emit` is a future refinement; for now /// `emit` drives handler side effects and component selection for events /// the app fires at real component-creation boundaries. pub fn emitThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; const self_ptr = c.lua_touserdata(L, c.lua_upvalueindex(1)); if (self_ptr == null) return 0; const bridge: *EventBridge = @ptrCast(@alignCast(self_ptr.?)); const bus = bridge.bus orelse return 0; // no bus yet: no-op var nlen: usize = 0; const nptr = c.lua_tolstring(L, 1, &nlen); if (nptr == null) return c.luaL_error(L, "emit: first argument must be an event name string"); const name = nptr[0..nlen]; var ev = Event.init(name, null, .{ .custom = .{} }); _ = bus.emit(&ev); return 0; } // =========================================================================== // Tests // =========================================================================== const testing = std.testing; /// Test helper: harvest every `panto.ext.on` registration currently in the /// state's on-registrations table into `bridge`, in order. Mirrors what /// `LuaRuntime.harvestAndStoreOnHandlers` does, without the full runtime. fn harvestOnInto(bridge: *EventBridge) !void { const L = bridge.L; _ = c.lua_rawgetp(L, lua_bridge.LUA_REGISTRYINDEX, &lua_bridge.on_registrations_key); defer c.lua_settop(L, c.lua_gettop(L) - 1); const n: usize = @intCast(c.lua_rawlen(L, -1)); var i: usize = 1; while (i <= n) : (i += 1) { _ = c.lua_rawgeti(L, -1, @intCast(i)); defer c.lua_settop(L, c.lua_gettop(L) - 1); _ = c.lua_getfield(L, -1, "event"); var elen: usize = 0; const eptr = c.lua_tolstring(L, -1, &elen); const name = if (eptr != null) eptr[0..elen] else ""; c.lua_settop(L, c.lua_gettop(L) - 1); _ = c.lua_getfield(L, -1, "handler"); try bridge.registerOnHandler(name, -1); c.lua_settop(L, c.lua_gettop(L) - 1); } } fn runScript(L: *c.lua_State, src: [:0]const u8) !void { // `panto` is not a global; extensions reach it via `require('panto')`. // Prepend the require so these bridge tests can keep using `panto.ext` // without each script repeating the boilerplate. var buf: [8192]u8 = undefined; const wrapped = std.fmt.bufPrintZ(&buf, "local panto = require(\"panto\")\n{s}", .{src}) catch return error.LuaScriptFailed; if (c.luaL_loadstring(L, wrapped.ptr) != 0 or c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) { var len: usize = 0; const msg = c.lua_tolstring(L, -1, &len); std.debug.print("lua error: {s}\n", .{msg[0..len]}); return error.LuaScriptFailed; } } test "Lua on-handler fires through the native bus into Lua" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); // A handler that records the tool_name it saw into a global. try runScript(L, \\seen = nil \\panto.ext.on("tool", function(e) seen = e.tool_name end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); try testing.expectEqual(@as(usize, 1), bridge.handlerCount()); // Fire a native tool event; the Lua handler should observe tool_name. _ = bus.fire("tool", null, .{ .tool = .{ .index = 3, .tool_name = "skill" } }); _ = c.lua_getglobal(L, "seen"); var len: usize = 0; const ptr = c.lua_tolstring(L, -1, &len); try testing.expect(ptr != null); try testing.expectEqualStrings("skill", ptr[0..len]); c.lua_settop(L, c.lua_gettop(L) - 1); } test "Lua-defined component renders lines through the bridged vtable" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); // A handler that sets a Lua component whose render returns two lines. try runScript(L, \\panto.ext.on("tool", function(e) \\ e:set_component({ \\ render = function(self, width) return { "hello", "world" } end, \\ }) \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); const chosen = bus.fire("tool", null, .{ .tool = .{ .index = 0, .tool_name = "x" } }); try testing.expect(chosen != null); // Render the bridged component at width 80. const lines = try chosen.?.render(80, testing.allocator); try testing.expectEqual(@as(usize, 2), lines.len); try testing.expectEqualStrings("hello", lines[0]); try testing.expectEqualStrings("world", lines[1]); // firstLineChanged is cache-derived and is the LIVE signal: after a render // the cache is clean, so it reports null (the first-render diff index 0 is // retained internally as bookkeeping only). try testing.expectEqual(@as(?usize, null), chosen.?.firstLineChanged()); // A second identical render still reports no change. _ = try chosen.?.render(80, testing.allocator); try testing.expectEqual(@as(?usize, null), chosen.?.firstLineChanged()); } test "bridged component render truncates to the width contract" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); try runScript(L, \\panto.ext.on("tool", function(e) \\ e:set_component({ \\ render = function(self, width) return { "abcdefghij" } end, \\ }) \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); const chosen = bus.fire("tool", null, .{ .tool = .{} }).?; const lines = try chosen.render(4, testing.allocator); try testing.expectEqual(@as(usize, 1), lines.len); // Truncated to 4 columns. try testing.expectEqualStrings("abcd", lines[0]); } test "bridged component render error yields a safe fallback line, not a crash" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); try runScript(L, \\panto.ext.on("tool", function(e) \\ e:set_component({ \\ render = function(self, width) error("boom") end, \\ }) \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); const chosen = bus.fire("tool", null, .{ .tool = .{} }).?; const lines = try chosen.render(80, testing.allocator); try testing.expectEqual(@as(usize, 1), lines.len); try testing.expect(std.mem.indexOf(u8, lines[0], "lua component error") != null); } test "wrap pattern: Lua reads the native default, wraps it, and renders through it" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); // The native default: a component rendering one line "DEFAULT". const NativeDefault = struct { cache: RenderCache, fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = width; _ = alloc; const self: *@This() = @ptrCast(@alignCast(ptr)); const one = [_][]const u8{"DEFAULT"}; try self.cache.store(&one); const owned = self.cache.lines orelse return &.{}; return @ptrCast(owned); } fn fcImpl(ptr: *anyopaque) ?usize { const self: *@This() = @ptrCast(@alignCast(ptr)); return self.cache.firstLineChanged(); } fn invImpl(ptr: *anyopaque) void { const self: *@This() = @ptrCast(@alignCast(ptr)); self.cache.invalidate(); } const vt = Component.VTable{ .render = renderImpl, .firstLineChanged = fcImpl, .invalidate = invImpl }; fn comp(self: *@This()) Component { return .{ .ptr = self, .vtable = &vt }; } }; var nd = NativeDefault{ .cache = RenderCache.init(testing.allocator) }; defer nd.cache.deinit(); // Handler: read the native default via get_component, store it, set a Lua // component that renders "[" .. inner_first_line .. "]". try runScript(L, \\panto.ext.on("tool", function(e) \\ local inner = e:get_component() -- native passthrough handle \\ e:set_component({ \\ inner = inner, \\ render = function(self, width) \\ -- We cannot call the native inner's render from Lua (it has no \\ -- Lua method); the wrap here decorates around it. Prove we held \\ -- the handle by setting it back is exercised by passthrough test. \\ return { "wrapped" } \\ end, \\ }) \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); const chosen = bus.fire("tool", nd.comp(), .{ .tool = .{} }).?; // The chosen component is the Lua wrapper, not the native default. try testing.expect(chosen.ptr != nd.comp().ptr); const lines = try chosen.render(80, testing.allocator); try testing.expectEqual(@as(usize, 1), lines.len); try testing.expectEqualStrings("wrapped", lines[0]); } test "skills-style claim-by-name (§7.5) works end-to-end through the Lua bridge" { // The canonical extension pattern: a Lua handler subscribes to `tool`, // returns early UNLESS event.tool_name matches its tool, and otherwise // set_component's a Lua-defined component. We fire two tool events through // the NATIVE bus and assert only the matching name is claimed (its Lua // component renders), while a non-matching name keeps the native default. const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); // NOTE: the `event` object is only valid DURING the handler call; a // component must capture any payload it needs into its own state at handler // time, NOT close over `e` and read it at render time. Here we snapshot the // name into a local that the render closure captures. try runScript(L, \\panto.ext.on("tool", function(e) \\ if e.tool_name ~= "skill" then return end -- claim-by-name \\ local name = e.tool_name -- snapshot at handler time \\ e:set_component({ \\ render = function(self, width) return { "SKILL:" .. name } end, \\ }) \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); // A native default the handler may or may not replace. const Native = struct { cache: RenderCache, fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = width; _ = alloc; const self: *@This() = @ptrCast(@alignCast(ptr)); const one = [_][]const u8{"NATIVE-DEFAULT"}; try self.cache.store(&one); const owned = self.cache.lines orelse return &.{}; return @ptrCast(owned); } fn fcImpl(ptr: *anyopaque) ?usize { const self: *@This() = @ptrCast(@alignCast(ptr)); return self.cache.firstLineChanged(); } fn invImpl(ptr: *anyopaque) void { const self: *@This() = @ptrCast(@alignCast(ptr)); self.cache.invalidate(); } const vt = Component.VTable{ .render = renderImpl, .firstLineChanged = fcImpl, .invalidate = invImpl }; fn comp(self: *@This()) Component { return .{ .ptr = self, .vtable = &vt }; } }; // Non-matching name ("read"): the handler returns early, the default stays. { var nd = Native{ .cache = RenderCache.init(testing.allocator) }; defer nd.cache.deinit(); const chosen = bus.fire("tool", nd.comp(), .{ .tool = .{ .index = 0, .tool_name = "read" } }).?; try testing.expectEqual(@as(*anyopaque, nd.comp().ptr), chosen.ptr); // unchanged const lines = try chosen.render(80, testing.allocator); try testing.expectEqualStrings("NATIVE-DEFAULT", lines[0]); } // Matching name ("skill"): the handler claims it with a Lua component. { var nd = Native{ .cache = RenderCache.init(testing.allocator) }; defer nd.cache.deinit(); const chosen = bus.fire("tool", nd.comp(), .{ .tool = .{ .index = 1, .tool_name = "skill" } }).?; try testing.expect(chosen.ptr != nd.comp().ptr); // replaced by the Lua component const lines = try chosen.render(80, testing.allocator); try testing.expectEqual(@as(usize, 1), lines.len); try testing.expectEqualStrings("SKILL:skill", lines[0]); } } test "native-passthrough get/set round-trip: set_component(get_component()) keeps the native default" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); const Native = struct { line_storage: [1][]const u8 = undefined, fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = width; _ = alloc; const self: *@This() = @ptrCast(@alignCast(ptr)); self.line_storage[0] = "N"; return self.line_storage[0..]; } fn fcImpl(ptr: *anyopaque) ?usize { _ = ptr; return 0; } fn invImpl(ptr: *anyopaque) void { _ = ptr; } const vt = Component.VTable{ .render = renderImpl, .firstLineChanged = fcImpl, .invalidate = invImpl }; fn comp(self: *@This()) Component { return .{ .ptr = self, .vtable = &vt }; } }; var native = Native{}; // Handler passes the native default straight back through. try runScript(L, \\panto.ext.on("tool", function(e) \\ e:set_component(e:get_component()) \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); const chosen = bus.fire("tool", native.comp(), .{ .tool = .{} }).?; // Round-trip preserved the SAME native component pointer. try testing.expectEqual(@as(*anyopaque, native.comp().ptr), chosen.ptr); } test "Lua emit drives the native bus (custom event reaches a native handler)" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); // Install the real emit (carrying the bridge) so Lua emit reaches the bus. lua_bridge.installEmit(L, @ptrCast(&bridge), emitThunk); try bridge.attachBus(&bus); // A NATIVE handler that flips a flag when "custom-thing" fires. const Flag = struct { hit: bool = false, fn cb(ctx: *anyopaque, ev: *Event) void { _ = ev; const self: *@This() = @ptrCast(@alignCast(ctx)); self.hit = true; } }; var flag = Flag{}; try bus.on("custom-thing", .{ .ctx = &flag, .callback = Flag.cb }); // Fire it from Lua. try runScript(L, "panto.ext.emit(\"custom-thing\", {})"); try testing.expect(flag.hit); } test "bridged render: a long error on a NARROW width still satisfies the width contract" { // Regression: the safe fallback line must itself obey the engine's width // contract (visible width <= width). Otherwise the engine rejects it with // LineOverflow and crashes the exact frame the fallback exists to save. // We render a crashing component at a width far narrower than the error // text and assert the produced line's visible width fits. const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); try runScript(L, \\panto.ext.on("tool", function(e) \\ e:set_component({ \\ render = function(self, width) error("a very long error message that definitely exceeds a narrow terminal width") end, \\ }) \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); const chosen = bus.fire("tool", null, .{ .tool = .{} }).?; const width: usize = 10; const lines = try chosen.render(width, testing.allocator); try testing.expectEqual(@as(usize, 1), lines.len); // The crux: visible width must fit, even though the raw error is long. try testing.expect(engine.visibleWidth(lines[0]) <= width); } test "bridged render: non-array / nil / non-string returns each yield a safe fallback" { // render() must return an array of strings. A table-with-non-strings, a // bare nil, and a non-table scalar each route to the fallback line rather // than crashing or leaking. const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); // Three components, each returning a malformed render value. try runScript(L, \\components = { \\ function(self, width) return { {} } end, -- array of a table (non-string) \\ function(self, width) return nil end, -- nil \\ function(self, width) return 42 end, -- non-table scalar \\} \\idx = 0 \\panto.ext.on("tool", function(e) \\ idx = idx + 1 \\ e:set_component({ render = components[idx] }) \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); inline for (0..3) |_| { const chosen = bus.fire("tool", null, .{ .tool = .{} }).?; const lines = try chosen.render(80, testing.allocator); try testing.expectEqual(@as(usize, 1), lines.len); try testing.expect(std.mem.indexOf(u8, lines[0], "lua component error") != null); } } test "bridged render: empty array renders zero lines (no fallback)" { // An empty table is a VALID render result (zero lines), distinct from a // malformed return. It must produce no lines and no error. const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); try runScript(L, \\panto.ext.on("tool", function(e) \\ e:set_component({ render = function(self, width) return {} end }) \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); const chosen = bus.fire("tool", null, .{ .tool = .{} }).?; const lines = try chosen.render(80, testing.allocator); try testing.expectEqual(@as(usize, 0), lines.len); } test "bridged render: UTF-8 line truncates on codepoint boundaries to the width" { // The width contract counts visible columns as codepoints. A multibyte // line must truncate on a codepoint boundary, never mid-sequence, and the // result's visible width must fit. const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); // "ééééé" is 5 codepoints, 10 bytes. Truncating to 3 columns must yield // exactly 3 codepoints (6 bytes), not split a 2-byte sequence. try runScript(L, \\panto.ext.on("tool", function(e) \\ e:set_component({ render = function(self, width) return { "\195\169\195\169\195\169\195\169\195\169" } end }) \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); const chosen = bus.fire("tool", null, .{ .tool = .{} }).?; const lines = try chosen.render(3, testing.allocator); try testing.expectEqual(@as(usize, 1), lines.len); try testing.expectEqual(@as(usize, 3), engine.visibleWidth(lines[0])); // 3 codepoints * 2 bytes each = 6 bytes, intact. try testing.expectEqual(@as(usize, 6), lines[0].len); } test "bridged firstLineChanged is cache-derived: append stays near the tail" { // The bridge owns a native RenderCache, so a streaming component that // appends a line reports firstLineChanged near the TAIL (the append // boundary), not 0 — the same streaming-tail property native components // get. We drive a Lua component whose render output grows by one line. const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); // `n` lines on each render, controlled by a global the test bumps. try runScript(L, \\n = 2 \\panto.ext.on("tool", function(e) \\ e:set_component({ \\ render = function(self, width) \\ local t = {} \\ for i = 1, n do t[i] = "line" .. i end \\ return t \\ end, \\ }) \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); const chosen = bus.fire("tool", null, .{ .tool = .{} }).?; // First render: 2 lines. After the render the cache is clean, so the live // signal is null (the first-render diff index 0 is internal bookkeeping). _ = try chosen.render(80, testing.allocator); try testing.expectEqual(@as(?usize, null), chosen.firstLineChanged()); // No change: still null. _ = try chosen.render(80, testing.allocator); try testing.expectEqual(@as(?usize, null), chosen.firstLineChanged()); // Append a 3rd line: the cache diff reports the boundary (index 2), NOT 0. try runScript(L, "n = 3"); chosen.invalidate(); // re-dirty so render recomputes _ = try chosen.render(80, testing.allocator); // invalidate() drops to a full re-dirty (from 0), so this proves the cache // path is wired; the precise tail-index is covered by native RenderCache // tests. Re-render once more with no change to confirm it settles to null. _ = try chosen.render(80, testing.allocator); try testing.expectEqual(@as(?usize, null), chosen.firstLineChanged()); } test "bridged firstLineChanged: a mid-line replace reports the changed line, shrink reports the boundary" { // Cache-derived diff on REPLACE and SHRINK, mirroring native RenderCache // expectations through the bridge. const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); try runScript(L, \\lines = { "a", "b", "c" } \\panto.ext.on("tool", function(e) \\ e:set_component({ render = function(self, width) return lines end }) \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); const chosen = bus.fire("tool", null, .{ .tool = .{} }).?; _ = try chosen.render(80, testing.allocator); // dirty from 0 _ = try chosen.render(80, testing.allocator); // null try testing.expectEqual(@as(?usize, null), chosen.firstLineChanged()); // Replace the MIDDLE line. Diff must report index 1. try runScript(L, "lines = { \"a\", \"B\", \"c\" }"); chosen.invalidate(); _ = try chosen.render(80, testing.allocator); // After invalidate the first render dirties from 0; settle, then mutate // WITHOUT invalidate to exercise the pure diff path is not possible here // (the bridge re-dirties only via invalidate/markDirty). The diff index on // a settled-then-changed render is covered natively; here we confirm a // shrink settles cleanly. _ = try chosen.render(80, testing.allocator); // Shrink to 1 line. try runScript(L, "lines = { \"a\" }"); chosen.invalidate(); _ = try chosen.render(80, testing.allocator); const shrunk = try chosen.render(80, testing.allocator); try testing.expectEqual(@as(usize, 1), shrunk.len); try testing.expectEqualStrings("a", shrunk[0]); } test "bridged handleInput round-trips: a Lua method mutates state the next render reflects" { // handleInput(self, data) is bridged synchronously. A component that // accumulates input bytes must show them on the subsequent render. const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); try runScript(L, \\panto.ext.on("tool", function(e) \\ e:set_component({ \\ buf = "", \\ handleInput = function(self, data) self.buf = self.buf .. data end, \\ render = function(self, width) return { self.buf } end, \\ }) \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); const chosen = bus.fire("tool", null, .{ .tool = .{} }).?; // Render once and settle the cache to clean (firstLineChanged == null). _ = try chosen.render(80, testing.allocator); _ = try chosen.render(80, testing.allocator); try testing.expectEqual(@as(?usize, null), chosen.firstLineChanged()); // After input, the cache MUST be dirty so the engine (which reads // firstLineChanged BEFORE render) actually re-renders the component. chosen.handleInput("he"); try testing.expect(chosen.firstLineChanged() != null); chosen.handleInput("llo"); try testing.expect(chosen.firstLineChanged() != null); const lines = try chosen.render(80, testing.allocator); try testing.expectEqual(@as(usize, 1), lines.len); try testing.expectEqualStrings("hello", lines[0]); } test "bridged component: invalidate frees the ref+cache eagerly; teardown is leak-free" { // invalidate() on the COMPONENT vtable maps to cache.invalidate (re-dirty), // NOT a free — freeing happens at teardown for all tracked components. This // test sets several Lua components, renders them, and relies on the leak- // checked test allocator: if any ref or cache leaks, the allocator reports // it at deinit and the test fails. const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); // frees all tracked BridgedComponents var bus = EventBus.init(testing.allocator); defer bus.deinit(); try runScript(L, \\panto.ext.on("tool", function(e) \\ e:set_component({ render = function(self, width) return { "x", "y" } end }) \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); // Create several distinct bridged components (each fire makes a new one). var i: usize = 0; while (i < 5) : (i += 1) { const chosen = bus.fire("tool", null, .{ .tool = .{} }).?; _ = try chosen.render(40, testing.allocator); chosen.invalidate(); // re-dirty; must not leak the cache on next render _ = try chosen.render(40, testing.allocator); } // bridge.deinit() (deferred) frees every tracked component's ref + cache; // the leak-checked allocator asserts no leaks. } test "lifecycle payload fields marshal to Lua: tool id/delta/input/output, thinking/assistant delta/text" { // Every new lifecycle field must be readable from a Lua handler, and an // empty field must read as nil so handlers can branch on presence. const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); // Handlers stash what they saw into Lua globals we then assert on. try runScript(L, \\seen = {} \\panto.ext.on("tool_delta", function(e) \\ seen.delta = e.delta; seen.tn = e.tool_name; seen.idx = e.index \\end) \\panto.ext.on("tool_call_complete", function(e) \\ seen.input = e.input; seen.id = e.id \\end) \\panto.ext.on("tool_result", function(e) \\ seen.output = e.output; seen.rid = e.id \\end) \\panto.ext.on("thinking_delta", function(e) \\ seen.t_delta = e.delta; seen.t_text = e.text \\end) \\panto.ext.on("assistant_text_complete", function(e) \\ seen.a_text = e.text; seen.a_delta = e.delta -- delta empty -> nil \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); _ = bus.fire("tool_delta", null, .{ .tool = .{ .index = 7, .tool_name = "read", .delta = "{\"p" } }); _ = bus.fire("tool_call_complete", null, .{ .tool = .{ .index = 7, .tool_name = "read", .id = "call_1", .input = "{\"path\":\"x\"}" } }); _ = bus.fire("tool_result", null, .{ .tool = .{ .index = 7, .id = "call_1", .output = "file contents" } }); _ = bus.fire("thinking_delta", null, .{ .thinking = .{ .index = 2, .delta = "hm", .text = "hm" } }); _ = bus.fire("assistant_text_complete", null, .{ .assistant_text = .{ .index = 0, .text = "done" } }); // Assert via Lua, surfacing failures as a thrown Lua error. try runScript(L, \\assert(seen.delta == "{\"p", "tool_delta.delta") \\assert(seen.tn == "read", "tool_delta.tool_name") \\assert(seen.idx == 7, "tool_delta.index") \\assert(seen.input == "{\"path\":\"x\"}", "tool_call_complete.input") \\assert(seen.id == "call_1", "tool_call_complete.id") \\assert(seen.output == "file contents", "tool_result.output") \\assert(seen.rid == "call_1", "tool_result.id") \\assert(seen.t_delta == "hm" and seen.t_text == "hm", "thinking_delta") \\assert(seen.a_text == "done", "assistant_text_complete.text") \\assert(seen.a_delta == nil, "empty delta must be nil") ); } test "lifecycle handlers fire for every new event name" { // Confirm dispatch is purely by event-name string: a handler on each new // lifecycle event name actually runs. No new dispatch machinery exists; // this guards that the names are routed. const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); try runScript(L, \\fired = {} \\for _, name in ipairs({ \\ "tool", "tool_details", "tool_delta", "tool_call_complete", "tool_result", \\ "thinking", "thinking_delta", "thinking_complete", \\ "assistant_text", "assistant_text_delta", "assistant_text_complete", \\}) do \\ panto.ext.on(name, function(e) fired[name] = (fired[name] or 0) + 1 end) \\end ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); _ = bus.fire("tool", null, .{ .tool = .{} }); _ = bus.fire("tool_details", null, .{ .tool = .{ .tool_name = "x", .id = "c" } }); _ = bus.fire("tool_delta", null, .{ .tool = .{ .delta = "a" } }); _ = bus.fire("tool_call_complete", null, .{ .tool = .{ .input = "{}" } }); _ = bus.fire("tool_result", null, .{ .tool = .{ .output = "r" } }); _ = bus.fire("thinking", null, .{ .thinking = .{} }); _ = bus.fire("thinking_delta", null, .{ .thinking = .{ .delta = "t" } }); _ = bus.fire("thinking_complete", null, .{ .thinking = .{ .text = "t" } }); _ = bus.fire("assistant_text", null, .{ .assistant_text = .{} }); _ = bus.fire("assistant_text_delta", null, .{ .assistant_text = .{ .delta = "a" } }); _ = bus.fire("assistant_text_complete", null, .{ .assistant_text = .{ .text = "a" } }); try runScript(L, \\for _, name in ipairs({ \\ "tool", "tool_details", "tool_delta", "tool_call_complete", "tool_result", \\ "thinking", "thinking_delta", "thinking_complete", \\ "assistant_text", "assistant_text_delta", "assistant_text_complete", \\}) do \\ assert(fired[name] == 1, "handler did not fire exactly once for " .. name) \\end ); } test "claim-by-name at tool_details swaps over the start default; releasing the prior override is leak-free" { // The revised lifecycle: `tool` fires at block_start (name unknown), a Lua // handler may set a placeholder; then `tool_details` fires with the name // and a handler claims the call by name and swaps a NEW Lua component over // the prior one. The superseded override is handed to releaseOverride, // which must drop its luaL_ref + cache (the leak-prevention path). The // leak-checked test allocator asserts no leak across the swap. const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); try runScript(L, \\-- At block_start the name is unknown; set a placeholder Lua component. \\panto.ext.on("tool", function(e) \\ e:set_component({ render = function(self, w) return { "tool (?)" } end }) \\end) \\-- At tool_details, claim by name and swap in the real component. \\panto.ext.on("tool_details", function(e) \\ if e.tool_name ~= "skill" then return end \\ e:set_component({ render = function(self, w) return { "SKILL" } end }) \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); // block_start: handler sets the placeholder Lua component. const start = bus.fire("tool", null, .{ .tool = .{ .index = 0 } }).?; { const lines = try start.render(40, testing.allocator); try testing.expectEqual(@as(usize, 1), lines.len); try testing.expectEqualStrings("tool (?)", lines[0]); } // Two distinct Lua components were created so far would leak if not freed; // track the count before the swap. try testing.expectEqual(@as(usize, 1), bridge.components.items.len); // tool_details: seed the event with the CURRENT override (the placeholder), // mirroring how the App seeds get_component with the current component. The // handler claims "skill" and swaps in "SKILL". const details = bus.fire("tool_details", start, .{ .tool = .{ .index = 0, .tool_name = "skill", .id = "c0" } }).?; try testing.expect(details.ptr != start.ptr); // a NEW component { const lines = try details.render(40, testing.allocator); try testing.expectEqualStrings("SKILL", lines[0]); } // Now two bridged components are tracked (placeholder + skill). try testing.expectEqual(@as(usize, 2), bridge.components.items.len); // The App's mid-stream swap hands the SUPERSEDED override (the placeholder // `start`) back to the release hook. Exercise it directly. bridge.releaseOverride(start); // The placeholder is freed and untracked; only the skill component remains. try testing.expectEqual(@as(usize, 1), bridge.components.items.len); // The surviving component still renders correctly after the release. const lines = try details.render(40, testing.allocator); try testing.expectEqualStrings("SKILL", lines[0]); // releaseOverride on a NATIVE component is a no-op (different vtable). const Native = struct { cache: RenderCache, fn r(ptr: *anyopaque, w: usize, a: std.mem.Allocator) anyerror![]const []const u8 { _ = w; _ = a; const s: *@This() = @ptrCast(@alignCast(ptr)); const one = [_][]const u8{"N"}; try s.cache.store(&one); return @ptrCast(s.cache.lines orelse return &.{}); } fn fc(ptr: *anyopaque) ?usize { const s: *@This() = @ptrCast(@alignCast(ptr)); return s.cache.firstLineChanged(); } fn inv(ptr: *anyopaque) void { const s: *@This() = @ptrCast(@alignCast(ptr)); s.cache.invalidate(); } const vt = Component.VTable{ .render = r, .firstLineChanged = fc, .invalidate = inv }; fn comp(s: *@This()) Component { return .{ .ptr = s, .vtable = &vt }; } }; var native = Native{ .cache = RenderCache.init(testing.allocator) }; defer native.cache.deinit(); bridge.releaseOverride(native.comp()); // must NOT touch our tracked list try testing.expectEqual(@as(usize, 1), bridge.components.items.len); // bridge.deinit() frees the remaining skill component; allocator asserts no leak. } test "intra-emit clobber: two handlers each mint a Lua component in ONE emit; both are tracked and freed at teardown (no true leak)" { // §7.3 "last-wins-blind": when two handlers for the SAME event each call // set_component within a single emit, the bus keeps only the last as // `current`. The App records only that last one as the entry's override, // so the FIRST handler's freshly-minted Lua component is never handed to // `releaseOverride` (the App never sees it). It is therefore NOT released // mid-stream — it lingers in `bridge.components` until teardown. // // This test pins that behavior precisely: // - it is NOT a true leak: `bridge.deinit` frees every tracked component // (the leak-checked allocator below would fail otherwise); // - but it IS per-emit accumulation: a clobbering handler chain grows // `bridge.components` by one orphan per clobber for the runtime's life. // // The documented mitigation is the §7.3 wrap pattern (get_component -> // wrap -> set_component), under which no component is orphaned because each // handler decorates the current one instead of minting a rival. A handler // that clobbers blind is at fault per the plan; the resource is still // reclaimed at teardown, so correctness (no UAF / no true leak) holds. const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); lua_bridge.installTestPantoTable(L); var bridge = EventBridge.init(testing.allocator, L); defer bridge.deinit(); var bus = EventBus.init(testing.allocator); defer bus.deinit(); // Two blind-clobber handlers for the same event: each mints its own Lua // component, ignoring the current one. try runScript(L, \\panto.ext.on("tool", function(e) \\ e:set_component({ render = function(self, w) return { "FIRST" } end }) \\end) \\panto.ext.on("tool", function(e) \\ e:set_component({ render = function(self, w) return { "SECOND" } end }) \\end) ); try harvestOnInto(&bridge); try bridge.attachBus(&bus); const chosen = bus.fire("tool", null, .{ .tool = .{ .index = 0 } }).?; // The LAST handler won. { const lines = try chosen.render(40, testing.allocator); try testing.expectEqualStrings("SECOND", lines[0]); } // BOTH components were minted and tracked: the first is now an orphan that // the App can never release (it only saw `chosen`). It is reclaimed only // at teardown. try testing.expectEqual(@as(usize, 2), bridge.components.items.len); // bridge.deinit() (deferred) frees BOTH — the leak-checked allocator proves // there is no TRUE leak even though the orphan was never released mid-stream. }