diff options
| author | t <t@tjp.lol> | 2026-06-09 15:24:41 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-09 19:26:07 -0600 |
| commit | f7503987e6aa4e96ade35bc7f1400ff8b2510e28 (patch) | |
| tree | cf723819027a918dc1d4f4b5b23805c9617dbc91 | |
| parent | af5119901997774ef7d04b1940194ce54a5b35a4 (diff) | |
color theme
| -rw-r--r-- | src/tui_app.zig | 1 | ||||
| -rw-r--r-- | src/tui_components.zig | 367 | ||||
| -rw-r--r-- | src/tui_theme.zig | 23 |
3 files changed, 304 insertions, 87 deletions
diff --git a/src/tui_app.zig b/src/tui_app.zig index c99985b..dbcf8f9 100644 --- a/src/tui_app.zig +++ b/src/tui_app.zig @@ -880,6 +880,7 @@ pub const App = struct { defer text.deinit(self.alloc); try tr.appendTextInto(self.alloc, &text); try box.setOutput(text.items); + box.setResultOk(!tr.is_error); // Fire `tool_result` — the atomic result landed. This is the // terminal tool-lifecycle event (after `tool_call_complete`). try self.fireToolLifecycle(box, .tool_result, "tool_result", .{ .tool = .{ 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). diff --git a/src/tui_theme.zig b/src/tui_theme.zig index 168b4f4..9e7e565 100644 --- a/src/tui_theme.zig +++ b/src/tui_theme.zig @@ -48,6 +48,18 @@ pub const StyleName = enum { thinking, /// Compaction-summary chrome. compaction, + /// Full-width background for user messages (#343541 dark blue-gray). + user_bg, + /// Full-width background for a tool call that is still running (#282832). + tool_pending_bg, + /// Full-width background for a succeeded tool call (#283228 dark green). + tool_success_bg, + /// Full-width background for a failed tool call (#3c2828 dark red). + tool_error_bg, + /// Dim foreground used on top of user_bg (slightly brighter than .dim). + user_text, + /// Dim foreground used on tool headers (cyan toned down). + tool_header, }; /// A resolved style: the opening escape and (implicitly) the `reset` close. @@ -117,6 +129,17 @@ fn styleFor(name: StyleName) Style { .thinking => .{ .open_seq = "\x1b[2m" }, // Compaction chrome: dimmed. .compaction => .{ .open_seq = "\x1b[2m" }, + // Full-width background fills — truecolor 24-bit (RGB), matching pi's + // dark theme palette. These are set-background-color only; text color + // is layered on top by pairing with a fg style. + .user_bg => .{ .open_seq = "\x1b[48;2;52;53;65m" }, // #343541 + .tool_pending_bg => .{ .open_seq = "\x1b[48;2;40;40;50m" }, // #282832 + .tool_success_bg => .{ .open_seq = "\x1b[48;2;40;50;40m" }, // #283228 + .tool_error_bg => .{ .open_seq = "\x1b[48;2;60;40;40m" }, // #3c2828 + // Foreground color for text rendered on top of user_bg. + .user_text => .{ .open_seq = "\x1b[38;2;212;212;212m" }, // #d4d4d4 + // Cyan tool header, slightly muted. + .tool_header => .{ .open_seq = "\x1b[38;2;0;215;255m" }, // #00d7ff }; } |
