summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lua_event_bridge.zig4
-rw-r--r--src/tui_app.zig28
-rw-r--r--src/tui_components.zig16
-rw-r--r--src/tui_engine.zig63
-rw-r--r--src/tui_event.zig2
5 files changed, 98 insertions, 15 deletions
diff --git a/src/lua_event_bridge.zig b/src/lua_event_bridge.zig
index 24bc6fc..26a4646 100644
--- a/src/lua_event_bridge.zig
+++ b/src/lua_event_bridge.zig
@@ -528,6 +528,10 @@ fn pushPayloadField(L: *c.lua_State, ev: *Event, key: []const u8) void {
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);
+ if (std.mem.eql(u8, key, "collapsed")) {
+ c.lua_pushboolean(L, if (t.collapsed) 1 else 0);
+ return;
+ }
},
.thinking => |t| {
// index plus `delta` (this chunk on *_delta) and `text` (the
diff --git a/src/tui_app.zig b/src/tui_app.zig
index e44a74a..c68daf4 100644
--- a/src/tui_app.zig
+++ b/src/tui_app.zig
@@ -605,7 +605,7 @@ pub const App = struct {
.{ .tool = box },
.tool,
"tool",
- .{ .tool = .{ .index = index } },
+ .{ .tool = .{ .index = index, .collapsed = self.tools_collapsed } },
);
return box;
}
@@ -633,7 +633,14 @@ pub const App = struct {
payload: Payload,
) !void {
const entry = self.findToolEntry(box) orelse return;
- _ = try self.fireForEntry(entry, lc, name, payload);
+ var enriched = payload;
+ if (enriched == .tool) {
+ enriched.tool.collapsed = box.collapsed;
+ if (enriched.tool.id.len == 0) {
+ if (box.id) |id| enriched.tool.id = id.items;
+ }
+ }
+ _ = try self.fireForEntry(entry, lc, name, enriched);
}
/// Fire a thinking/assistant lifecycle event for the entry backing a
@@ -732,6 +739,7 @@ pub const App = struct {
.ToolUse => |tu| {
const box = try self.spawnTool(0);
try box.setName(tu.name);
+ try box.setId(tu.id);
try box.setInput(tu.input.items);
try self.router.putToolId(tu.id, box);
},
@@ -760,8 +768,18 @@ pub const App = struct {
/// the whole list and flip each one. Requests a render.
pub fn toggleToolCollapse(self: *App) void {
self.tools_collapsed = !self.tools_collapsed;
- for (self.transcript.items) |e| {
- if (e.kind == .tool) e.kind.tool.setCollapsed(self.tools_collapsed);
+ for (self.transcript.items) |*e| {
+ if (e.kind == .tool) {
+ const box = e.kind.tool;
+ box.setCollapsed(self.tools_collapsed);
+ _ = self.fireForEntry(e, null, "tool_collapse", .{ .tool = .{
+ .tool_name = if (box.name) |n| n.items else "",
+ .id = if (box.id) |id| id.items else "",
+ .input = box.input.items,
+ .output = if (box.output) |out| out.items else "",
+ .collapsed = self.tools_collapsed,
+ } }) catch {};
+ }
}
self.scheduler.requestRender();
}
@@ -837,6 +855,7 @@ pub const App = struct {
// the name-based claim point). A handler swap here takes
// over before the real content paints further.
try box.setName(d.name);
+ try box.setId(d.id);
try self.fireToolLifecycle(box, .tool_details, "tool_details", .{ .tool = .{
.index = d.index,
.tool_name = d.name,
@@ -916,6 +935,7 @@ pub const App = struct {
// tool_details never fired and replaces any
// partial streamed args with the final bytes).
try box.setName(tu.name);
+ try box.setId(tu.id);
try box.setInput(tu.input.items);
try self.router.putToolId(tu.id, box);
// Fire `tool_call_complete` (end of the CALL;
diff --git a/src/tui_components.zig b/src/tui_components.zig
index 545ccce..3944ffa 100644
--- a/src/tui_components.zig
+++ b/src/tui_components.zig
@@ -2369,6 +2369,8 @@ pub const ToolUse = struct {
cache: RenderCache,
/// Resolved tool name, or null until `tool_details`/completion.
name: ?std.ArrayList(u8) = null,
+ /// Tool-call id, once resolved.
+ id: ?std.ArrayList(u8) = null,
/// Accumulated verbatim input JSON (streamed args).
input: std.ArrayList(u8) = .empty,
/// Result output text, or null until the result lands.
@@ -2385,11 +2387,25 @@ pub const ToolUse = struct {
pub fn deinit(self: *ToolUse) void {
if (self.name) |*n| n.deinit(self.alloc);
+ if (self.id) |*i| i.deinit(self.alloc);
self.input.deinit(self.alloc);
if (self.output) |*o| o.deinit(self.alloc);
self.cache.deinit();
}
+ /// Resolve the tool-call id.
+ pub fn setId(self: *ToolUse, id: []const u8) !void {
+ if (id.len == 0) return;
+ if (self.id) |existing| {
+ if (std.mem.eql(u8, existing.items, id)) return;
+ self.id.?.clearRetainingCapacity();
+ } else {
+ self.id = .empty;
+ }
+ try self.id.?.appendSlice(self.alloc, id);
+ self.cache.markDirty();
+ }
+
/// Resolve the tool name (from `tool_details` or the completed block).
pub fn setName(self: *ToolUse, name: []const u8) !void {
if (self.name) |existing| {
diff --git a/src/tui_engine.zig b/src/tui_engine.zig
index 6c5b372..f63e269 100644
--- a/src/tui_engine.zig
+++ b/src/tui_engine.zig
@@ -485,10 +485,12 @@ pub const Engine = struct {
var cut: ?usize = null;
for (self.slots.items, 0..) |*slot, idx| {
- const first_changed = slot.comp.firstLineChanged();
+ const dirty_signal = slot.comp.firstLineChanged();
// Render only when there's a reason to: dirty signal, or first
- // paint (no cached lines yet), or a forced full redraw.
- const must_render = first_changed != null or slot.lines.len == 0 or self.force_full;
+ // paint (no cached lines yet), or a forced full redraw. The signal
+ // is ONLY a dirty/render hint; the repaint cut below is computed
+ // from the actual old-vs-new line diff after rendering.
+ const must_render = dirty_signal != null or slot.lines.len == 0 or self.force_full;
var new_lines: []const []const u8 = undefined;
if (must_render) {
@@ -503,6 +505,7 @@ pub const Engine = struct {
if (visibleWidth(line) > self.width) return Error.LineOverflow;
}
+ const first_changed = firstDiffLines(slot.lines, new_lines);
frames[idx] = .{
.new_lines = new_lines,
.first_changed = first_changed,
@@ -510,18 +513,13 @@ pub const Engine = struct {
.offset = offset,
};
- // 2. cut = min across ALL components (not just the first dirty).
+ // 2. cut = min across ALL components with an ACTUAL line diff.
+ // A dirty component whose re-rendered bytes are identical leaves
+ // first_changed null and does not move the repaint point.
if (first_changed) |fc| {
const global = offset + fc;
cut = if (cut) |c| @min(c, global) else global;
}
- // A length delta is itself a change point even when the component
- // reports firstLineChanged == null (the diff backstop will catch
- // the content, but the boundary must roll the cut back).
- if (new_lines.len != slot.line_count) {
- const boundary = offset + @min(new_lines.len, slot.line_count);
- cut = if (cut) |c| @min(c, boundary) else boundary;
- }
offset += new_lines.len;
}
@@ -634,6 +632,21 @@ pub const Engine = struct {
slot.lines = copies;
}
+ /// Lowest local line index where `new_lines` differs from `old_lines`, or
+ /// null when they are byte-identical (including line count). This is the
+ /// authoritative repaint signal for a rendered component in the current
+ /// frame; pre-render `firstLineChanged()` is treated only as a cheap dirty
+ /// hint for whether the component needs to render.
+ fn firstDiffLines(old_lines: []const []const u8, new_lines: []const []const u8) ?usize {
+ const n = @min(old_lines.len, new_lines.len);
+ var i: usize = 0;
+ while (i < n) : (i += 1) {
+ if (!std.mem.eql(u8, old_lines[i], new_lines[i])) return i;
+ }
+ if (old_lines.len != new_lines.len) return n;
+ return null;
+ }
+
/// Line-diff backstop (the CORRECTNESS FLOOR). Flattens the previous
/// baseline lines (`slot.lines`) and the new frame lines into global arrays
/// and returns the FIRST global index where they actually differ, or null
@@ -1324,6 +1337,34 @@ test "line-diff backstop corrects an inaccurate firstLineChanged" {
try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null);
}
+test "dirty hint at 0 still repaints from actual first changed line" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 100);
+ defer eng.deinit();
+
+ // Simulates a component that conservatively reports dirty from its top
+ // (e.g. markDirty dropped its internal cache), while the engine still has
+ // the previous slot baseline and can cheaply find the true line diff.
+ var body = FakeComponent{
+ .scripts = &.{ &.{ "l0", "l1", "l2", "l3" }, &.{ "l0", "l1", "l2", "L3" } },
+ .first_changed = &.{ 0, 0 },
+ };
+ try eng.addComponent(body.comp());
+
+ try eng.render();
+ body.advance();
+ buf.clearRetainingCapacity();
+ try eng.render();
+
+ const out = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out, "L3") != null);
+ try testing.expect(std.mem.indexOf(u8, out, "l0") == null);
+ try testing.expect(std.mem.indexOf(u8, out, "l1") == null);
+ try testing.expect(std.mem.indexOf(u8, out, "l2") == null);
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null);
+}
+
test "consecutive differential frames move the cursor up correctly (streaming append)" {
// Regression: the differential path must account for the cursor resting at
// the END of the last content line (no trailing newline), not one row
diff --git a/src/tui_event.zig b/src/tui_event.zig
index 70d2fca..c32db39 100644
--- a/src/tui_event.zig
+++ b/src/tui_event.zig
@@ -211,6 +211,8 @@ pub const Payload = union(enum) {
input: []const u8 = "",
/// Tool result text for `tool_result`; empty otherwise.
output: []const u8 = "",
+ /// Whether tool output should render collapsed at this boundary.
+ collapsed: bool = true,
};
pub const Compaction = struct {
summary: []const u8 = "",