summaryrefslogtreecommitdiff
path: root/src/tui_components.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tui_components.zig')
-rw-r--r--src/tui_components.zig367
1 files changed, 280 insertions, 87 deletions
diff --git a/src/tui_components.zig b/src/tui_components.zig
index b2ed842..de53f04 100644
--- a/src/tui_components.zig
+++ b/src/tui_components.zig
@@ -30,6 +30,7 @@ const Focusable = component.Focusable;
const RenderCache = component.RenderCache;
const CURSOR_MARKER = component.CURSOR_MARKER;
const Style = theme.Style;
+const ansi_reset = theme.reset;
const Key = key.Key;
const KeyCode = key.KeyCode;
@@ -181,6 +182,110 @@ fn renderStyledLines(
return cacheLines(cache);
}
+/// Render a full-width background block with surrounding whitespace.
+///
+/// Layout (what gets pushed to `out`):
+/// - 1 blank margin line (no bg — uses the terminal's default background)
+/// - N content lines, each right-padded to `width` columns so the `bg_style`
+/// fill colour extends to the right edge of the terminal
+/// - 1 blank margin line
+///
+/// `bg_style` sets the background colour; `fg_style` sets the text colour.
+/// They are stacked as `bg_open ++ fg_open ++ text ++ reset`. Both can be
+/// `.is_plain = true` (no-op) if only one layer is needed.
+///
+/// The content text is word-wrapped to `width - 2*pad_x` columns and each
+/// line is indented by `pad_x` spaces on the left, then right-padded with
+/// spaces to fill the full `width`. `pad_x = 1` matches pi's style.
+///
+/// `pad_y` adds that many bg-coloured blank lines inside the block above and
+/// below the text content. Use `pad_y = 1` for user messages.
+fn renderBlock(
+ out: *std.ArrayList([]const u8),
+ buffer: []const u8,
+ bg_style: Style,
+ fg_style: Style,
+ width: usize,
+ pad_x: usize,
+ pad_y: usize,
+ alloc: std.mem.Allocator,
+) !void {
+ // Blank margin above (no bg).
+ try out.append(alloc, try alloc.dupe(u8, ""));
+
+ // Wrap the inner text to (width - 2*pad_x) columns, with a floor of 1.
+ const inner_w = if (width > 2 * pad_x) width - 2 * pad_x else 1;
+ var plain: std.ArrayList([]const u8) = .empty;
+ defer plain.deinit(alloc);
+ if (buffer.len == 0) {
+ try plain.append(alloc, "");
+ } else {
+ try wrapBuffer(buffer, inner_w, &plain, alloc);
+ }
+
+ // A full-width bg-only line (for vertical padding inside the block).
+ const bg_blank_spaces = try alloc.alloc(u8, width);
+ defer alloc.free(bg_blank_spaces);
+ @memset(bg_blank_spaces, ' ');
+ const bg_blank = try std.fmt.allocPrint(alloc, "{s}{s}{s}", .{
+ bg_style.open(), bg_blank_spaces, ansi_reset,
+ });
+ defer alloc.free(bg_blank);
+
+ // Top vertical padding (inside bg).
+ for (0..pad_y) |_| try out.append(alloc, try alloc.dupe(u8, bg_blank));
+
+ // Build the left-indent string once.
+ const indent = try alloc.alloc(u8, pad_x);
+ defer alloc.free(indent);
+ @memset(indent, ' ');
+
+ for (plain.items) |line| {
+ const vis = truncateToCols(line, inner_w);
+ const text_cols = displayWidth(vis);
+ // Right-pad so bg colour fills to the right edge.
+ const right_pad = width - pad_x - text_cols;
+ const right_spaces = try alloc.alloc(u8, right_pad);
+ defer alloc.free(right_spaces);
+ @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,
+ });
+ try out.append(alloc, composed);
+ }
+
+ // Bottom vertical padding (inside bg).
+ for (0..pad_y) |_| try out.append(alloc, try alloc.dupe(u8, bg_blank));
+
+ // Blank margin below.
+ try out.append(alloc, try alloc.dupe(u8, ""));
+}
+
+/// Thin wrapper around `renderBlock` that also stores the result in `cache`
+/// and returns the cache's owned lines. The transient `out` lines are freed
+/// here after the cache dupes them.
+fn renderBlockCached(
+ cache: *RenderCache,
+ buffer: []const u8,
+ bg_style: Style,
+ fg_style: Style,
+ width: usize,
+ pad_x: usize,
+ pad_y: usize,
+ alloc: std.mem.Allocator,
+) ![]const []const u8 {
+ var out: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (out.items) |l| alloc.free(l);
+ out.deinit(alloc);
+ }
+ try renderBlock(&out, buffer, bg_style, fg_style, width, pad_x, pad_y, alloc);
+ try cache.store(out.items);
+ return cacheLines(cache);
+}
+
/// Re-type the cache's owned `[][]u8` lines as `[]const []const u8` for the
/// vtable return. The cache guarantees these outlive the call until the next
/// render/invalidate.
@@ -244,7 +349,10 @@ pub const AssistantText = struct {
fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
_ = alloc;
const self: *AssistantText = @ptrCast(@alignCast(ptr));
- return renderStyledLines(&self.cache, self.buffer.items, theme.default.fg(.assistant), width, self.alloc);
+ // Assistant text: no background, but pad with margin lines and 1-col
+ // indent so it visually breathes alongside the background-filled blocks.
+ const plain_style = theme.default.fg(.assistant);
+ return renderBlockCached(&self.cache, self.buffer.items, plain_style, plain_style, width, 1, 0, self.alloc);
}
fn firstLineChangedImpl(ptr: *anyopaque) ?usize {
@@ -298,7 +406,10 @@ pub const UserText = struct {
fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
_ = alloc;
const self: *UserText = @ptrCast(@alignCast(ptr));
- return renderStyledLines(&self.cache, self.buffer.items, theme.default.fg(.user), width, self.alloc);
+ // User messages: full-width gray background block with 1-col padding.
+ return renderBlockCached(&self.cache, self.buffer.items,
+ theme.default.fg(.user_bg), theme.default.fg(.user_text),
+ width, 1, 1, self.alloc);
}
fn firstLineChangedImpl(ptr: *anyopaque) ?usize {
@@ -1107,6 +1218,7 @@ pub const Welcome = struct {
const a = self.alloc;
const accent = theme.default.fg(.welcome);
const dim = theme.default.fg(.dim);
+ const plain_bg = theme.default.fg(.assistant); // no-op bg
// Transient owned lines; freed after the cache dupes them.
var lines: std.ArrayList([]const u8) = .empty;
@@ -1115,31 +1227,38 @@ pub const Welcome = struct {
lines.deinit(a);
}
- // Title line: "panto v<version>" in the accent color.
+ // Blank margin above.
+ try lines.append(a, try a.dupe(u8, ""));
+
+ // Title line: "panto v<version>" in the accent color, padded to width.
{
const title_plain = if (self.version.items.len != 0)
- try std.fmt.allocPrint(a, "panto v{s}", .{self.version.items})
+ try std.fmt.allocPrint(a, " panto v{s}", .{self.version.items})
else
- try std.fmt.allocPrint(a, "panto", .{});
+ try std.fmt.allocPrint(a, " panto", .{});
defer a.free(title_plain);
const vis = truncateToCols(title_plain, width);
try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ accent.open(), vis, accent.close() }));
}
- // Detail lines (dim): cwd and model, only when set.
+ // Detail lines (dim, with leading space): cwd and model, only when set.
if (self.cwd.items.len != 0) {
- const plain = try std.fmt.allocPrint(a, "cwd: {s}", .{self.cwd.items});
- defer a.free(plain);
- const vis = truncateToCols(plain, width);
+ const txt = try std.fmt.allocPrint(a, " cwd: {s}", .{self.cwd.items});
+ defer a.free(txt);
+ const vis = truncateToCols(txt, width);
try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() }));
}
if (self.model.items.len != 0) {
- const plain = try std.fmt.allocPrint(a, "model: {s}", .{self.model.items});
- defer a.free(plain);
- const vis = truncateToCols(plain, width);
+ const txt = try std.fmt.allocPrint(a, " model: {s}", .{self.model.items});
+ defer a.free(txt);
+ const vis = truncateToCols(txt, width);
try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() }));
}
+ _ = plain_bg;
+ // Blank margin below.
+ try lines.append(a, try a.dupe(u8, ""));
+
try self.cache.store(lines.items);
return cacheLines(&self.cache);
}
@@ -1207,7 +1326,10 @@ pub const Thinking = struct {
fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
_ = alloc;
const self: *Thinking = @ptrCast(@alignCast(ptr));
- return renderStyledLines(&self.cache, self.buffer.items, theme.default.fg(.thinking), width, self.alloc);
+ // Thinking blocks: dimmed text, no bg, with margin/indent spacing.
+ const dim = theme.default.fg(.dim);
+ const plain = theme.default.fg(.assistant);
+ return renderBlockCached(&self.cache, self.buffer.items, plain, dim, width, 1, 0, self.alloc);
}
fn firstLineChangedImpl(ptr: *anyopaque) ?usize {
@@ -1264,27 +1386,26 @@ pub const CompactionSummary = struct {
_ = alloc;
const self: *CompactionSummary = @ptrCast(@alignCast(ptr));
const a = self.alloc;
- const style = theme.default.fg(.compaction);
+ const dim = theme.default.fg(.dim);
+ const plain_bg = theme.default.fg(.assistant); // no-op bg
- // Wrap a header line plus the summary body, all styled as compaction
- // chrome. The header makes the event legible even when the summary is
- // empty.
- var plain: std.ArrayList([]const u8) = .empty;
- defer plain.deinit(a);
- try plain.append(a, "[context compacted]");
- try wrapBuffer(self.buffer.items, width, &plain, a);
+ // Build body: header + wrapped summary text.
+ var body: std.ArrayList(u8) = .empty;
+ defer body.deinit(a);
+ try body.appendSlice(a, "[context compacted]");
+ if (self.buffer.items.len != 0) {
+ try body.append(a, '\n');
+ try body.appendSlice(a, self.buffer.items);
+ }
- var styled: std.ArrayList([]const u8) = .empty;
+ // Render as a dim block with margin/indent spacing.
+ var out: std.ArrayList([]const u8) = .empty;
defer {
- for (styled.items) |s| a.free(s);
- styled.deinit(a);
+ for (out.items) |l| a.free(l);
+ out.deinit(a);
}
- for (plain.items) |line| {
- const vis = truncateToCols(line, width);
- try styled.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ style.open(), vis, style.close() }));
- }
-
- try self.cache.store(styled.items);
+ try renderBlock(&out, body.items, plain_bg, dim, width, 1, 0, a);
+ try self.cache.store(out.items);
return cacheLines(&self.cache);
}
@@ -1352,6 +1473,9 @@ pub const ToolUse = struct {
output: ?std.ArrayList(u8) = null,
/// Whether the output is collapsed to its tail. Default true.
collapsed: bool = true,
+ /// Result status: null = still running, true = succeeded, false = errored.
+ /// Set alongside (or after) `setOutput`; controls the background colour.
+ result_ok: ?bool = null,
pub fn init(alloc: std.mem.Allocator) ToolUse {
return .{ .alloc = alloc, .cache = RenderCache.init(alloc) };
@@ -1393,6 +1517,14 @@ pub const ToolUse = struct {
self.cache.markDirty();
}
+ /// Record whether the tool call succeeded or failed (for bg colour).
+ /// Call this alongside or after `setOutput`.
+ pub fn setResultOk(self: *ToolUse, ok: bool) void {
+ if (self.result_ok != null and self.result_ok.? == ok) return;
+ self.result_ok = ok;
+ self.cache.markDirty();
+ }
+
/// Global collapse toggle target. The app calls this on every ToolUse
/// component when ctrl+o is pressed. A no-op state change skips the dirty.
pub fn setCollapsed(self: *ToolUse, value: bool) void {
@@ -1405,52 +1537,95 @@ pub const ToolUse = struct {
_ = alloc;
const self: *ToolUse = @ptrCast(@alignCast(ptr));
const a = self.alloc;
- const tool_style = theme.default.fg(.tool);
- const dim = theme.default.fg(.dim);
- // Transient owned lines; the cache dupes them and we free here.
+ // Choose background based on result state:
+ // null -> pending (dark blue-gray)
+ // true -> success (dark green)
+ // false -> error (dark red)
+ const bg = if (self.result_ok == null)
+ theme.default.fg(.tool_pending_bg)
+ else if (self.result_ok.?)
+ theme.default.fg(.tool_success_bg)
+ else
+ theme.default.fg(.tool_error_bg);
+ const header_fg = theme.default.fg(.tool_header);
+ 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;
+ const inner_w = if (width > 2 * pad) width - 2 * pad else 1;
+ const indent = " "; // pad_x = 1
+
+ // Helper: emit one full-width bg line with the given fg text.
+ // `text` is ALREADY truncated to inner_w.
+ const Ctx = struct {
+ a: std.mem.Allocator,
+ bg: Style,
+ width: usize,
+ fn line(ctx: @This(), fg: Style, text: []const u8) ![]u8 {
+ const text_cols = displayWidth(text);
+ const right_pad_n = ctx.width -| (1 + text_cols); // 1 = left indent col
+ 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 });
+ }
+ fn blank(ctx: @This()) ![]u8 {
+ return ctx.line(.{ .open_seq = "", .is_plain = true }, "");
+ }
+ };
+ const ctx: Ctx = .{ .a = a, .bg = bg, .width = width };
+
+ // Transient owned lines; the cache dupes them.
var lines: std.ArrayList([]const u8) = .empty;
defer {
for (lines.items) |l| a.free(l);
lines.deinit(a);
}
- // -- Header: `tool (?)…` or `tool (<name>) <input json>` ------------
+ // Blank margin ABOVE (no bg colour).
+ try lines.append(a, try a.dupe(u8, ""));
+
+ // -- Stage 1: name not yet known -----------------------------------
if (self.name == null) {
- const plain = "tool (?)…";
- const vis = truncateToCols(plain, width);
- try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ tool_style.open(), vis, tool_style.close() }));
+ try lines.append(a, try ctx.blank());
+ const vis = truncateToCols("tool (?)\xe2\x80\xa6", inner_w);
+ try lines.append(a, try ctx.line(header_fg, vis));
+ try lines.append(a, try ctx.blank());
+ try lines.append(a, try a.dupe(u8, ""));
try self.cache.store(lines.items);
return cacheLines(&self.cache);
}
- // Name known: header is `tool (<name>) <input json>`, wrapped to width.
- // The whole header (including verbatim JSON) is one logical paragraph
- // that we wrap; it is styled with the tool accent.
+ // -- Header: `tool (<name>) <args json>` ---------------------------
+ // Top padding row inside the block.
+ try lines.append(a, try ctx.blank());
+
{
const header_plain = try std.fmt.allocPrint(a, "tool ({s}) {s}", .{ self.name.?.items, self.input.items });
defer a.free(header_plain);
var wrapped: std.ArrayList([]const u8) = .empty;
defer wrapped.deinit(a);
- try wrapParagraph(header_plain, width, &wrapped, a);
- for (wrapped.items) |line| {
- const vis = truncateToCols(line, width);
- try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ tool_style.open(), vis, tool_style.close() }));
+ try wrapParagraph(header_plain, inner_w, &wrapped, a);
+ for (wrapped.items) |wline| {
+ const vis = truncateToCols(wline, inner_w);
+ try lines.append(a, try ctx.line(header_fg, vis));
}
}
- // -- Blank separator line ------------------------------------------
- try lines.append(a, try a.dupe(u8, ""));
+ // Separator blank row inside the block.
+ try lines.append(a, try ctx.blank());
- // -- Result region: `(…)` placeholder or the output text -----------
+ // -- Result region: `(…)` placeholder or output text ---------------
if (self.output == null) {
- const vis = truncateToCols("(…)", width);
- try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() }));
+ const vis = truncateToCols("(\xe2\x80\xa6)", inner_w);
+ try lines.append(a, try ctx.line(dim_fg, vis));
} else {
- // Wrap the full output, then optionally collapse to the tail.
var out_lines: std.ArrayList([]const u8) = .empty;
defer out_lines.deinit(a);
- try wrapBuffer(self.output.?.items, width, &out_lines, a);
+ try wrapBuffer(self.output.?.items, inner_w, &out_lines, a);
var start: usize = 0;
var truncated = false;
@@ -1459,18 +1634,21 @@ pub const ToolUse = struct {
truncated = true;
}
if (truncated) {
- // A leading marker so the user knows output was elided.
- const vis = truncateToCols("…", width);
- try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() }));
+ const vis = truncateToCols("\xe2\x80\xa6", inner_w);
+ try lines.append(a, try ctx.line(dim_fg, vis));
}
- for (out_lines.items[start..]) |line| {
- const vis = truncateToCols(line, width);
- // Output uses plain assistant style (no escape) so it reads as
- // content; truncate enforces the width contract.
- try lines.append(a, try a.dupe(u8, vis));
+ for (out_lines.items[start..]) |oline| {
+ const vis = truncateToCols(oline, inner_w);
+ try lines.append(a, try ctx.line(plain_fg, vis));
}
}
+ // Bottom padding row inside the block.
+ try lines.append(a, try ctx.blank());
+
+ // Blank margin BELOW (no bg colour).
+ try lines.append(a, try a.dupe(u8, ""));
+
try self.cache.store(lines.items);
return cacheLines(&self.cache);
}
@@ -1545,7 +1723,8 @@ test "AssistantText: renders wrapped text within width" {
defer at.deinit();
try at.setText("hello world foo");
const lines = try at.comp().render(7, testing.allocator);
- try testing.expectEqual(@as(usize, 3), lines.len);
+ // renderBlock adds 2 margin lines: 3 content + 2 = 5.
+ try testing.expectEqual(@as(usize, 5), lines.len);
for (lines) |l| try testing.expect(vw(l) <= 7);
// First render after empty cache => changed from 0.
try testing.expectEqual(@as(?usize, 0), at.comp().firstLineChanged());
@@ -2137,19 +2316,21 @@ test "Welcome: renders title + cwd + model, all within width" {
try w.setCwd("/tmp/project");
try w.setModel("anthropic:claude");
const lines = try w.comp().render(40, testing.allocator);
- try testing.expectEqual(@as(usize, 3), lines.len);
+ // 3 content lines + 2 margin = 5.
+ try testing.expectEqual(@as(usize, 5), lines.len);
for (lines) |l| try testing.expect(vw(l) <= 40);
- try testing.expect(std.mem.indexOf(u8, lines[0], "panto v0.1.0") != null);
- try testing.expect(std.mem.indexOf(u8, lines[1], "/tmp/project") != null);
- try testing.expect(std.mem.indexOf(u8, lines[2], "anthropic:claude") != null);
+ try testing.expect(std.mem.indexOf(u8, lines[1], "panto v0.1.0") != null);
+ try testing.expect(std.mem.indexOf(u8, lines[2], "/tmp/project") != null);
+ try testing.expect(std.mem.indexOf(u8, lines[3], "anthropic:claude") != null);
}
test "Welcome: title only when cwd/model unset" {
var w = Welcome.init(testing.allocator);
defer w.deinit();
const lines = try w.comp().render(20, testing.allocator);
- try testing.expectEqual(@as(usize, 1), lines.len);
- try testing.expect(std.mem.indexOf(u8, lines[0], "panto") != null);
+ // 1 content line + 2 margin = 3.
+ try testing.expectEqual(@as(usize, 3), lines.len);
+ try testing.expect(std.mem.indexOf(u8, lines[1], "panto") != null);
}
test "Welcome: honors the width contract at a tiny width" {
@@ -2159,8 +2340,9 @@ test "Welcome: honors the width contract at a tiny width" {
try w.setCwd("/a/very/long/working/directory/path/that/overflows");
try w.setModel("anthropic:claude-some-very-long-model-id");
// Width 6: every banner row (title + cwd + model) must truncate to fit.
+ // 3 content lines + 2 margin = 5.
const lines = try w.comp().render(6, testing.allocator);
- try testing.expectEqual(@as(usize, 3), lines.len);
+ try testing.expectEqual(@as(usize, 5), lines.len);
for (lines) |l| try testing.expect(vw(l) <= 6);
}
@@ -2185,8 +2367,12 @@ test "CompactionSummary: header + wrapped summary within width" {
defer c.deinit();
try c.setSummary("summarized prior turns here");
const lines = try c.comp().render(20, testing.allocator);
- try testing.expect(lines.len >= 2);
- try testing.expect(std.mem.indexOf(u8, lines[0], "compacted") != null);
+ // header + body lines + 2 margin = at least 4.
+ 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; };
+ try testing.expect(found_compacted);
for (lines) |l| try testing.expect(vw(l) <= 20);
}
@@ -2194,8 +2380,11 @@ test "ToolUse: stage 1 renders tool (?) before the name resolves" {
var t = ToolUse.init(testing.allocator);
defer t.deinit();
const lines = try t.comp().render(40, testing.allocator);
- try testing.expectEqual(@as(usize, 1), lines.len);
- try testing.expect(std.mem.indexOf(u8, lines[0], "tool (?)") != null);
+ // 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; };
+ try testing.expect(found);
}
test "ToolUse: stage 2 shows name + verbatim json + placeholder" {
@@ -2204,11 +2393,12 @@ test "ToolUse: stage 2 shows name + verbatim json + placeholder" {
try t.setName("read");
try t.appendInput("{\"path\":\"a\"}");
const lines = try t.comp().render(60, testing.allocator);
- // header line, blank, placeholder
- try testing.expect(lines.len >= 3);
- try testing.expect(std.mem.indexOf(u8, lines[0], "tool (read) {\"path\":\"a\"}") != null);
- try testing.expectEqualStrings("", lines[lines.len - 2]);
- try testing.expect(std.mem.indexOf(u8, lines[lines.len - 1], "(…)") != null);
+ // margin+pad+header+sep+(\u2026)+pad+margin = 7
+ try testing.expect(lines.len >= 7);
+ // Header is at lines[2] (top-margin + top-pad + header).
+ try testing.expect(std.mem.indexOf(u8, lines[2], "tool (read) {\"path\":\"a\"}") != null);
+ // Placeholder (\u2026) is before bottom pad (lines[len-3]).
+ try testing.expect(std.mem.indexOf(u8, lines[lines.len - 3], "(…)") != null);
for (lines) |l| try testing.expect(vw(l) <= 60);
}
@@ -2220,9 +2410,10 @@ test "ToolUse: collapsed shows only the last 5 output lines (default)" {
// 8 short output lines -> collapsed shows the marker + last 5.
try t.setOutput("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8");
const collapsed = try t.comp().render(40, testing.allocator);
- // header, blank, marker, l4..l8 = 3 + 5
- try testing.expect(std.mem.indexOf(u8, collapsed[collapsed.len - 1], "l8") != null);
- try testing.expect(std.mem.indexOf(u8, collapsed[collapsed.len - 5], "l4") != null);
+ // margin+pad+header+sep+marker+l4..l8+pad+margin = 1+1+1+1+1+5+1+1 = 12
+ // Last output line (l8) is at lines[len-3] (before bottom pad + margin).
+ try testing.expect(std.mem.indexOf(u8, collapsed[collapsed.len - 3], "l8") != null);
+ try testing.expect(std.mem.indexOf(u8, collapsed[collapsed.len - 7], "l4") != null);
// The earliest output lines are elided when collapsed.
var has_l1 = false;
for (collapsed) |l| {
@@ -2273,19 +2464,20 @@ test "ToolUse: collapse/expand is a length change with a cache-derived firstLine
try t.setInput("{}");
try t.setOutput("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8");
- // Default collapsed: header + blank + marker + last 5 = 8 rows.
+ // Default collapsed:
+ // margin+pad+header+sep+truncmark+5lines+pad+margin = 1+1+1+1+1+5+1+1 = 12
const collapsed = try t.comp().render(40, testing.allocator);
- try testing.expectEqual(@as(usize, 8), collapsed.len);
+ try testing.expectEqual(@as(usize, 12), collapsed.len);
// A stable re-render is clean (cache-derived, no drift).
_ = try t.comp().render(40, testing.allocator);
try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged());
- // Expand: header + blank + all 8 output rows = 10 rows (a length GROWTH).
+ // Expand: margin+pad+header+sep+8lines+pad+margin = 1+1+1+1+8+1+1 = 14
t.setCollapsed(false);
// While dirty (full drop), the signal is the cache-derived 0.
try testing.expectEqual(@as(?usize, 0), t.comp().firstLineChanged());
const expanded = try t.comp().render(40, testing.allocator);
- try testing.expectEqual(@as(usize, 10), expanded.len);
+ try testing.expectEqual(@as(usize, 14), expanded.len);
// After the render the baseline was dropped on the toggle, so the diff
// reports from 0 — cache-derived, not a hand-managed value.
try testing.expectEqual(@as(?usize, 0), t.comp().firstLineChanged());
@@ -2293,10 +2485,10 @@ test "ToolUse: collapse/expand is a length change with a cache-derived firstLine
_ = try t.comp().render(40, testing.allocator);
try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged());
- // Collapse again: shrink back to 8 rows (the length-change shrink path).
+ // Collapse again: shrink back to 12 rows.
t.setCollapsed(true);
const recollapsed = try t.comp().render(40, testing.allocator);
- try testing.expectEqual(@as(usize, 8), recollapsed.len);
+ try testing.expectEqual(@as(usize, 12), recollapsed.len);
}
test "ToolUse: args are rendered VERBATIM (no pretty-print) and within width" {
@@ -2311,9 +2503,10 @@ test "ToolUse: args are rendered VERBATIM (no pretty-print) and within width" {
const verbatim = "{\"q\":\"a b\",\"n\":10}";
try testing.expectEqualStrings(verbatim, t.input.items);
- // Wide render: the verbatim args appear unmodified on the header line.
+ // Wide render: the verbatim args appear unmodified on the header line
+ // (lines[2] = top-margin + top-pad + header).
const wide = try t.comp().render(80, testing.allocator);
- try testing.expect(std.mem.indexOf(u8, wide[0], verbatim) != null);
+ try testing.expect(std.mem.indexOf(u8, wide[2], verbatim) != null);
// Narrow render: header wraps across rows but every row honors the width
// contract (no pretty-print expansion, just wrapping).