diff options
| author | t <t@tjp.lol> | 2026-06-16 11:49:54 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-16 11:58:44 -0600 |
| commit | 28cb3eacfcf1217b4d014dd864005deb2d7f0585 (patch) | |
| tree | 22a49deb365589e3180a8e119870d6abb2ed308c | |
| parent | 92ce53385fa438cab221c6881da141b5ef0a7261 (diff) | |
tool collapsed state, and fixing dirty component flags
| -rw-r--r-- | agent/extensions/std_render.lua | 15 | ||||
| -rw-r--r-- | issues.md | 3 | ||||
| -rw-r--r-- | src/lua_event_bridge.zig | 4 | ||||
| -rw-r--r-- | src/tui_app.zig | 28 | ||||
| -rw-r--r-- | src/tui_components.zig | 16 | ||||
| -rw-r--r-- | src/tui_engine.zig | 63 | ||||
| -rw-r--r-- | src/tui_event.zig | 2 |
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) @@ -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 = "", |
