diff options
| -rw-r--r-- | issues.md | 2 | ||||
| -rw-r--r-- | src/tui_components.zig | 75 |
2 files changed, 64 insertions, 13 deletions
@@ -1,5 +1,5 @@ - [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. -- [ ] screen flickers. I think our differential rendering has gotten broken and we're re-drawing the full conversation on every update. +- [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. - [ ] 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. diff --git a/src/tui_components.zig b/src/tui_components.zig index f85c294..545ccce 100644 --- a/src/tui_components.zig +++ b/src/tui_components.zig @@ -446,8 +446,8 @@ fn renderBlock( @memset(right_spaces, ' '); const composed = try std.fmt.allocPrint(alloc, "{s}{s}{s}{s}{s}{s}", .{ bg_style.open(), fg_style.open(), - indent, vis, right_spaces, - ansi_reset, + indent, vis, + right_spaces, ansi_reset, }); try out.append(alloc, composed); } @@ -2392,8 +2392,12 @@ pub const ToolUse = struct { /// Resolve the tool name (from `tool_details` or the completed block). pub fn setName(self: *ToolUse, name: []const u8) !void { - if (self.name == null) self.name = .empty; - self.name.?.clearRetainingCapacity(); + if (self.name) |existing| { + if (std.mem.eql(u8, existing.items, name)) return; + self.name.?.clearRetainingCapacity(); + } else { + self.name = .empty; + } try self.name.?.appendSlice(self.alloc, name); self.cache.markDirty(); } @@ -2401,11 +2405,17 @@ pub const ToolUse = struct { /// Append a streaming args delta (verbatim JSON bytes). pub fn appendInput(self: *ToolUse, delta: []const u8) !void { try self.input.appendSlice(self.alloc, delta); - self.cache.markDirty(); + // Input arrives as an append-only stream. Keep the previous render as + // the diff baseline so the engine can roll back only to the first + // header line that actually changed instead of conservatively cutting + // from the top of the tool (and reprinting everything below it) on + // every args chunk. + self.cache.markDirtyAppend(); } /// Replace the input verbatim (e.g. from the completed block's `input`). pub fn setInput(self: *ToolUse, value: []const u8) !void { + if (std.mem.eql(u8, self.input.items, value)) return; self.input.clearRetainingCapacity(); try self.input.appendSlice(self.alloc, value); self.cache.markDirty(); @@ -2448,8 +2458,8 @@ pub const ToolUse = struct { // false -> error (dark red) const bg = theme.default.fg(.tool_pending_bg); const header_fg = theme.default.fg(.tool_header); - const dim_fg = theme.default.fg(.dim); - const plain_fg = theme.default.fg(.assistant); + const dim_fg = theme.default.fg(.dim); + const plain_fg = theme.default.fg(.assistant); // Padding within the block (1 col each side matches pi). const pad: usize = 1; @@ -2468,8 +2478,7 @@ pub const ToolUse = struct { const rp = try ctx.a.alloc(u8, right_pad_n); defer ctx.a.free(rp); @memset(rp, ' '); - return std.fmt.allocPrint(ctx.a, "{s}{s}{s}{s}{s}{s}", - .{ ctx.bg.open(), fg.open(), indent, text, rp, ansi_reset }); + return std.fmt.allocPrint(ctx.a, "{s}{s}{s}{s}{s}{s}", .{ ctx.bg.open(), fg.open(), indent, text, rp, ansi_reset }); } fn blank(ctx: @This()) ![]u8 { return ctx.line(.{ .open_seq = "", .is_plain = true }, ""); @@ -3490,7 +3499,10 @@ test "CompactionSummary: header + wrapped summary within width" { try testing.expect(lines.len >= 4); // Content starts at lines[1] (lines[0] is the top margin). var found_compacted = false; - for (lines) |l| if (std.mem.indexOf(u8, l, "compacted") != null) { found_compacted = true; break; }; + for (lines) |l| if (std.mem.indexOf(u8, l, "compacted") != null) { + found_compacted = true; + break; + }; try testing.expect(found_compacted); for (lines) |l| try testing.expect(vw(l) <= 20); } @@ -3502,7 +3514,10 @@ test "ToolUse: stage 1 renders tool (?) before the name resolves" { // margin + pad + header + pad + margin = 5 try testing.expectEqual(@as(usize, 5), lines.len); var found = false; - for (lines) |l| if (std.mem.indexOf(u8, l, "tool (?)") != null) { found = true; break; }; + for (lines) |l| if (std.mem.indexOf(u8, l, "tool (?)") != null) { + found = true; + break; + }; try testing.expect(found); } @@ -3648,6 +3663,43 @@ test "ToolUse: collapse/expand is a length change with a cache-derived firstLine try testing.expectEqual(@as(usize, 15), recollapsed.len); } +test "ToolUse: streaming args keep the render baseline for differential updates" { + var t = ToolUse.init(testing.allocator); + defer t.deinit(); + try t.setName("search"); + try t.appendInput("{\"q\":"); + + _ = try t.comp().render(40, testing.allocator); + try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged()); + + const old_len = t.cache.lines.?.len; + try t.appendInput("\"term\"}"); + + // Args deltas are append-only, so the component must retain its old lines + // as the diff baseline. Dropping the cache here made the engine cut from + // the top of the tool on every streamed args chunk, repainting all content + // below it and causing visible flicker in long transcripts. + try testing.expect(t.cache.lines != null); + try testing.expectEqual(old_len, t.cache.lines.?.len); + try testing.expectEqual(@as(?usize, old_len - 1), t.comp().firstLineChanged()); + + _ = try t.comp().render(40, testing.allocator); + try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged()); +} + +test "ToolUse: final identical name/input does not dirty a clean tool" { + var t = ToolUse.init(testing.allocator); + defer t.deinit(); + try t.setName("search"); + try t.setInput("{\"q\":\"term\"}"); + + _ = try t.comp().render(40, testing.allocator); + try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged()); + + try t.setName("search"); + try t.setInput("{\"q\":\"term\"}"); + try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged()); +} test "ToolUse: args are rendered VERBATIM (no pretty-print) and within width" { // The input JSON must pass through byte-for-byte (no reflow of the JSON // structure), only terminal-wrapped. We use a compact object with no spaces @@ -3681,4 +3733,3 @@ test "ToolUse: long output lines honor the width contract" { const lines = try t.comp().render(10, testing.allocator); for (lines) |l| try testing.expect(vw(l) <= 10); } - |
