summaryrefslogtreecommitdiff
path: root/src/tui_app.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tui_app.zig')
-rw-r--r--src/tui_app.zig174
1 files changed, 172 insertions, 2 deletions
diff --git a/src/tui_app.zig b/src/tui_app.zig
index 88c2c66..d097225 100644
--- a/src/tui_app.zig
+++ b/src/tui_app.zig
@@ -245,6 +245,14 @@ const Entry = struct {
}
};
+/// A staged `tool_result` output override: the replacement text a handler
+/// assigned to `ev.output`, keyed by the call it belongs to. Both slices
+/// owned by the App's allocator.
+const PendingOutputOverride = struct {
+ id: []u8,
+ text: []u8,
+};
+
// ===========================================================================
// App
// ===========================================================================
@@ -279,6 +287,19 @@ pub const App = struct {
/// tool output starts collapsed to its last few lines.
tools_collapsed: bool = true,
+ /// Staged writable-event-field overrides (taken off the bus right after
+ /// the corresponding fire), waiting for the turn driver — which owns the
+ /// agent — to apply them to the conversation:
+ /// - input overrides: tool_use_id → replacement input JSON, staged at
+ /// `tool_call_complete` (mid-stream), applied at `tool_dispatch_start`
+ /// once the assistant message is committed but before dispatch;
+ /// - output overrides: staged at `tool_result`, applied right after
+ /// `tool_dispatch_complete` routing, before the next provider call.
+ /// Keys and values owned by `alloc`. Cleared as applied (and defensively
+ /// at turn start).
+ pending_input_overrides: std.StringHashMapUnmanaged([]u8) = .empty,
+ pending_output_overrides: std.ArrayListUnmanaged(PendingOutputOverride) = .empty,
+
/// Optional override-release hook. When a slot's `override` is REPLACED by
/// a newer override (a second handler swap on the same slot), the old
/// override is no longer referenced by panto and its OWNER must release it.
@@ -340,6 +361,24 @@ pub const App = struct {
self.transcript.deinit(self.alloc);
self.router.deinit();
self.bus.deinit();
+ self.clearPendingOverrides();
+ self.pending_input_overrides.deinit(self.alloc);
+ self.pending_output_overrides.deinit(self.alloc);
+ }
+
+ /// Drop all staged (unapplied) writable-field overrides.
+ fn clearPendingOverrides(self: *App) void {
+ var it = self.pending_input_overrides.iterator();
+ while (it.next()) |entry| {
+ self.alloc.free(entry.key_ptr.*);
+ self.alloc.free(entry.value_ptr.*);
+ }
+ self.pending_input_overrides.clearRetainingCapacity();
+ for (self.pending_output_overrides.items) |po| {
+ self.alloc.free(po.id);
+ self.alloc.free(po.text);
+ }
+ self.pending_output_overrides.clearRetainingCapacity();
}
/// Access the event bus so the embedder (and, later, the Lua bridge) can
@@ -641,6 +680,42 @@ pub const App = struct {
}
}
_ = try self.fireForEntry(entry, lc, name, enriched);
+
+ // Writable-field overrides: take what a handler wrote during this
+ // fire and stage it by call id for the turn driver (which owns the
+ // agent) to apply at the effective boundary. An override without a
+ // resolved call id has nothing to attach to and is dropped.
+ const is_result = std.mem.eql(u8, name, "tool_result");
+ const is_call_complete = std.mem.eql(u8, name, "tool_call_complete");
+ if (!is_result and !is_call_complete) return;
+ const value = self.bus.takeOverride() orelse return;
+ const id = enriched.tool.id;
+ if (id.len == 0) {
+ self.alloc.free(value);
+ return;
+ }
+ if (is_result) {
+ const id_copy = self.alloc.dupe(u8, id) catch |e| {
+ self.alloc.free(value);
+ return e;
+ };
+ try self.pending_output_overrides.append(self.alloc, .{ .id = id_copy, .text = value });
+ } else {
+ const gop = self.pending_input_overrides.getOrPut(self.alloc, id) catch |e| {
+ self.alloc.free(value);
+ return e;
+ };
+ if (gop.found_existing) {
+ self.alloc.free(gop.value_ptr.*);
+ } else {
+ gop.key_ptr.* = self.alloc.dupe(u8, id) catch |e| {
+ _ = self.pending_input_overrides.remove(id);
+ self.alloc.free(value);
+ return e;
+ };
+ }
+ gop.value_ptr.* = value;
+ }
}
/// Fire a thinking/assistant lifecycle event for the entry backing a
@@ -1029,6 +1104,7 @@ pub const App = struct {
try self.fireToolLifecycle(box, .tool_result, "tool_result", .{ .tool = .{
.tool_name = if (box.name) |n| n.items else "",
.id = tr.tool_use_id,
+ .input = box.input.items,
.output = text.items,
} });
any = true;
@@ -1043,6 +1119,9 @@ pub const App = struct {
/// the chat history); only the block-index map is cleared.
pub fn beginTurn(self: *App) void {
self.router.reset();
+ // Defensive: an interrupted turn may have staged overrides it never
+ // applied; they must not leak into this turn's calls.
+ self.clearPendingOverrides();
}
/// Surface a turn error as a dim status line in the transcript.
@@ -1934,7 +2013,13 @@ fn handleSubmittedLine(app: *App, term: *Terminal, line: []const u8, opts: RunOp
}
// Model turn. Echo the user message, then pump the stream into components.
- try app.spawnUser(line);
+ try app.spawnUser(line); // fires `user_message`
+ // A `user_message` handler may have overridden `ev.text`: the echo above
+ // keeps what the user typed; the override is what the turn sends. (The
+ // fire itself clears any stale override a replayed user_message left.)
+ const text_override = app.bus.takeOverride();
+ defer if (text_override) |t| app.alloc.free(t);
+ const turn_text = text_override orelse line;
app.beginTurn();
try app.renderNow();
@@ -1947,7 +2032,7 @@ fn handleSubmittedLine(app: *App, term: *Terminal, line: []const u8, opts: RunOp
return;
};
- driveTurn(app, term, opts, line) catch |err| {
+ driveTurn(app, term, opts, turn_text) catch |err| {
try app.routeError(err);
};
try app.renderNow();
@@ -2069,6 +2154,7 @@ fn driveTurn(app: *App, term: *Terminal, opts: RunOptions, message_text: []const
};
const e = ev orelse break;
try app.routeEvent(e);
+ try applyPendingOverrides(app, opts.agent, e);
_ = try app.maybeRender();
if (try pumpTurnKeys(app, term, &turn_input_tail)) {
interrupted = true;
@@ -2084,6 +2170,36 @@ fn driveTurn(app: *App, term: *Terminal, opts: RunOptions, message_text: []const
}
}
+/// Apply staged writable-event-field overrides at their effective stream
+/// boundaries. Runs right after `routeEvent` (which is where the fires that
+/// stage them happen), still before the next `stream.next()` — i.e. before
+/// the agent advances — so the mutation is authoritative for dispatch, the
+/// next provider request, and turn persistence:
+/// - `tool_dispatch_start`: the assistant message is committed but tools
+/// have not run — rewrite ToolUse inputs staged at `tool_call_complete`.
+/// - `tool_dispatch_complete`: results are in the conversation tail —
+/// rewrite ToolResult text staged at `tool_result`.
+/// An override whose call id no longer matches (e.g. a stale stage after a
+/// reopen) is dropped silently — the conversation stays untouched.
+fn applyPendingOverrides(app: *App, agent: *panto.Agent, ev: Event) !void {
+ switch (ev) {
+ .tool_dispatch_start => {
+ defer app.clearPendingOverrides();
+ var it = app.pending_input_overrides.iterator();
+ while (it.next()) |entry| {
+ _ = try agent.overrideToolUseInput(entry.key_ptr.*, entry.value_ptr.*);
+ }
+ },
+ .tool_dispatch_complete => {
+ defer app.clearPendingOverrides();
+ for (app.pending_output_overrides.items) |po| {
+ _ = try agent.overrideToolResultOutput(po.id, po.text);
+ }
+ },
+ else => {},
+ }
+}
+
/// Service app-level keybindings while a turn is in flight. Returns true when
/// the user requested an interrupt (Escape). Text-editing keys are ignored: the
/// input box is restored only after the interrupted turn is back in user-input
@@ -2967,6 +3083,60 @@ test "event wiring: tool lifecycle events each fire EXACTLY ONCE at their bounda
try testing.expectEqual(@as(usize, 1), counter.result);
}
+test "writable-field overrides are staged by call id at tool_call_complete and tool_result" {
+ const alloc = testing.allocator;
+ const h = try Harness.make(alloc);
+ defer h.teardown(alloc);
+
+ // A native handler standing in for a Lua `ev.input = ...` / `ev.output
+ // = ...` assignment (the bridge routes those to the same bus slot).
+ const Writer = struct {
+ bus: *EventBus,
+ fn cb(ctx: *anyopaque, ev: *ui_event.Event) void {
+ const w: *@This() = @ptrCast(@alignCast(ctx));
+ if (std.mem.eql(u8, ev.name, "tool_call_complete")) {
+ w.bus.setOverride("{\"a\":2}") catch {};
+ } else if (std.mem.eql(u8, ev.name, "tool_result")) {
+ w.bus.setOverride("redacted") catch {};
+ }
+ }
+ };
+ var w = Writer{ .bus = &h.app.bus };
+ try h.app.bus.on("tool_call_complete", .{ .ctx = &w, .callback = Writer.cb });
+ try h.app.bus.on("tool_result", .{ .ctx = &w, .callback = Writer.cb });
+
+ try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
+ try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "a", .name = "read" } });
+ var tu = panto.ToolUseBlock{
+ .id = try alloc.dupe(u8, "a"),
+ .name = try alloc.dupe(u8, "read"),
+ };
+ defer tu.deinit(alloc);
+ try tu.input.appendSlice(alloc, "{\"a\":1}");
+ try h.app.routeEvent(.{ .block_complete = .{ .index = 0, .block = .{ .ToolUse = tu } } });
+
+ // The input override is staged, keyed by the resolved call id.
+ try testing.expectEqual(@as(usize, 1), h.app.pending_input_overrides.count());
+ try testing.expectEqualStrings("{\"a\":2}", h.app.pending_input_overrides.get("a").?);
+
+ var msg: panto.Message = .{ .role = .user };
+ defer msg.deinit(alloc);
+ var parts: std.ArrayList(panto.ResultPartStored) = .empty;
+ var text: panto.TextualBlock = .empty;
+ try text.appendSlice(alloc, "out");
+ try parts.append(alloc, .{ .text = text });
+ try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "a"), .parts = parts } });
+ try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } });
+
+ // The output override is staged with the id it belongs to.
+ try testing.expectEqual(@as(usize, 1), h.app.pending_output_overrides.items.len);
+ try testing.expectEqualStrings("a", h.app.pending_output_overrides.items[0].id);
+ try testing.expectEqualStrings("redacted", h.app.pending_output_overrides.items[0].text);
+
+ // Nothing left on the bus once staged.
+ try testing.expect(h.app.bus.takeOverride() == null);
+}
+
test "event wiring: thinking lifecycle fires start + per-delta + complete" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);