summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-16 11:49:54 -0600
committert <t@tjp.lol>2026-06-16 11:58:44 -0600
commit28cb3eacfcf1217b4d014dd864005deb2d7f0585 (patch)
tree22a49deb365589e3180a8e119870d6abb2ed308c
parent92ce53385fa438cab221c6881da141b5ef0a7261 (diff)
tool collapsed state, and fixing dirty component flags
-rw-r--r--agent/extensions/std_render.lua15
-rw-r--r--issues.md3
-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
7 files changed, 114 insertions, 17 deletions
diff --git a/agent/extensions/std_render.lua b/agent/extensions/std_render.lua
index d82ccfa..43a2c86 100644
--- a/agent/extensions/std_render.lua
+++ b/agent/extensions/std_render.lua
@@ -14,6 +14,7 @@ end
local states_by_index = {}
local states_by_id = {}
local index_by_id = {}
+local collapsed_tail_lines = 8
local function decode(s)
if type(s) ~= "string" or s == "" or not ok_json then return nil end
@@ -130,7 +131,16 @@ local function component(st)
body = edit_diff(st.input)
end
body = body or st.output or "(…)"
- add(body)
+ local body_lines = wrap_text(body, inner)
+ local start = 1
+ if st.collapsed and #body_lines > collapsed_tail_lines then
+ add("…")
+ start = #body_lines - collapsed_tail_lines + 1
+ end
+ for i = start, #body_lines do
+ local l = body_lines[i]
+ lines[#lines + 1] = " " .. l .. spaces(inner - #l + 1)
+ end
lines[#lines + 1] = ""
return lines
end,
@@ -170,6 +180,8 @@ local function state_for_event(e)
if e.input then st.input = e.input end
if e.delta then st.input = (st.input or "") .. e.delta end
if e.output then st.output = e.output end
+ if e.collapsed ~= nil then st.collapsed = e.collapsed end
+ if st.collapsed == nil then st.collapsed = true end
if st.name or st.input ~= "" then st.header_text = derive_header(st) end
return st
end
@@ -191,3 +203,4 @@ panto.ext.on("tool_details", on_tool_event)
panto.ext.on("tool_delta", on_tool_event)
panto.ext.on("tool_call_complete", on_tool_event)
panto.ext.on("tool_result", on_tool_event)
+panto.ext.on("tool_collapse", on_tool_event)
diff --git a/issues.md b/issues.md
index af1162c..0915f43 100644
--- a/issues.md
+++ b/issues.md
@@ -1,5 +1,6 @@
- [x] at the end of _any_ **kind** ~of~ `formatting`, ALL styles are cleared, which for user-text clears the background that should still be there.
- [x] screen flickers. I think our differential rendering has gotten broken and we're re-drawing the full conversation on every update.
-- [ ] tool components don't have their collapsed form any more, they're always showing their output expanded, ctrl+o does nothing.
+- [x] tool components don't have their collapsed form any more, they're always showing their output expanded, ctrl+o does nothing.
- [ ] empty lines in user-text are getting dropped - (shift+enter)*2 doesn't show an empty line in the middle when the user text is rendered in the component.
- [ ] in another test, this text: "`code block`\n_underline_\n**bold**\n~strikethrough~" was collapsed down to one line with just spaces between them. this time it wasn't even a double-newline.
+- [ ] keybindings need to work while the agent is working in a loop. most important: Escape should interrupt the agent wherever it is, return back to user-input mode (to avoid a broken convo history we'll have to clear any tool calls that didn't get results).
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 = "",