diff options
| author | t <t@tjp.lol> | 2026-07-06 08:34:35 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-07-06 13:06:23 -0600 |
| commit | 983e523ca9f8e6a7db56b4aca23a962be95e7051 (patch) | |
| tree | c29a19c40964d58231fbd39ce080fa47f92b295e /src/tui_components.zig | |
| parent | f759f149377942c4e04802c45162cda1c9bfb2b3 (diff) | |
rendering & chat fixes for panto cli
- resiliency for tool ids that come back from some openai_chat providers
- fixing codepoint width recognition
- implement proper rendering for full markdown as seen from ai models
Diffstat (limited to 'src/tui_components.zig')
| -rw-r--r-- | src/tui_components.zig | 113 |
1 files changed, 93 insertions, 20 deletions
diff --git a/src/tui_components.zig b/src/tui_components.zig index b93c30b..7532fac 100644 --- a/src/tui_components.zig +++ b/src/tui_components.zig @@ -97,6 +97,15 @@ pub fn codepointWidth(cp: u21) usize { return 1; } +fn tabWidth(col: usize) usize { + const rem = col % 8; + return if (rem == 0) 8 else 8 - rem; +} + +fn codepointWidthAt(cp: u21, col: usize) usize { + return if (cp == '\t') tabWidth(col) else codepointWidth(cp); +} + /// Number of display columns occupied by `text`. `text` must be PLAIN (no /// escape sequences); components wrap on plain text and only add styling /// escapes afterward. @@ -107,7 +116,7 @@ pub fn displayWidth(text: []const u8) usize { const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; const adv = @min(seq_len, text.len - i); const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?'; - cols += codepointWidth(cp); + cols += codepointWidthAt(cp, cols); i += adv; } return cols; @@ -123,7 +132,7 @@ pub fn truncateToCols(text: []const u8, max_cols: usize) []const u8 { const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; const adv = @min(seq_len, text.len - i); const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?'; - const w = codepointWidth(cp); + const w = codepointWidthAt(cp, cols); if (cols + w > max_cols) break; // adding this glyph would exceed limit i += adv; cols += w; @@ -138,24 +147,44 @@ pub fn displayWidthStyled(text: []const u8) usize { var cols: usize = 0; var i: usize = 0; while (i < text.len) { - if (text[i] == '\x1b' and i + 1 < text.len and text[i + 1] == '[') { - i += 2; - while (i < text.len) { - const c = text[i]; - i += 1; - if (c >= '@' and c <= '~') break; - } + if (skipStyledEscape(text, i)) |next| { + i = next; continue; } const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; const adv = @min(seq_len, text.len - i); const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?'; - cols += codepointWidth(cp); + cols += codepointWidthAt(cp, cols); i += adv; } return cols; } +fn skipStyledEscape(text: []const u8, start_i: usize) ?usize { + if (start_i + 1 >= text.len or text[start_i] != '\x1b') return null; + switch (text[start_i + 1]) { + '[' => { + var i = start_i + 2; + while (i < text.len) { + const c = text[i]; + i += 1; + if (c >= '@' and c <= '~') return i; + } + return text.len; + }, + ']' => { + var i = start_i + 2; + while (i < text.len) { + if (text[i] == 0x07) return i + 1; + if (text[i] == '\x1b' and i + 1 < text.len and text[i + 1] == '\\') return i + 2; + i += 1; + } + return text.len; + }, + else => return null, + } +} + fn reapplyAfterReset(alloc: std.mem.Allocator, text: []const u8, bg_open: []const u8, fg_open: []const u8) ![]const u8 { const reset = theme.reset; var count: usize = 0; @@ -186,19 +215,14 @@ pub fn truncateStyledToCols(text: []const u8, max_cols: usize) []const u8 { var cols: usize = 0; var i: usize = 0; while (i < text.len) { - if (text[i] == '\x1b' and i + 1 < text.len and text[i + 1] == '[') { - i += 2; - while (i < text.len) { - const c = text[i]; - i += 1; - if (c >= '@' and c <= '~') break; - } + if (skipStyledEscape(text, i)) |next| { + i = next; continue; } const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; const adv = @min(seq_len, text.len - i); const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?'; - const w = codepointWidth(cp); + const w = codepointWidthAt(cp, cols); if (cols + w > max_cols) break; i += adv; cols += w; @@ -234,7 +258,7 @@ fn wrapParagraph(text: []const u8, width: usize, out: *std.ArrayList([]const u8) const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; const adv = @min(seq_len, text.len - i); const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?'; - const cp_w = codepointWidth(cp); + const cp_w = codepointWidthAt(cp, line_cols); const is_space = adv == 1 and text[i] == ' '; if (is_space and line_cols == width) { @@ -1430,7 +1454,7 @@ pub const InputBox = struct { const seq_len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1; const adv = @min(seq_len, line.len - i); const cp = std.unicode.utf8Decode(line[i .. i + adv]) catch '?'; - const w = codepointWidth(cp); + const w = codepointWidthAt(cp, cols); if (cols > 0 and cols + w > width) break; i += adv; cols += w; @@ -2650,6 +2674,12 @@ pub const ToolUse = struct { @memset(rp, ' '); const inner = try reapplyAfterReset(ctx.a, text, ctx.bg.open(), fg.open()); defer ctx.a.free(inner); + if (std.mem.indexOfScalar(u8, text, '\t') != null) { + const prepaint = try ctx.a.alloc(u8, ctx.width); + defer ctx.a.free(prepaint); + @memset(prepaint, ' '); + return std.fmt.allocPrint(ctx.a, "{s}{s}\r{s}{s}{s}{s}{s}{s}", .{ ctx.bg.open(), prepaint, ctx.bg.open(), fg.open(), indent, inner, rp, ansi_reset }); + } return std.fmt.allocPrint(ctx.a, "{s}{s}{s}{s}{s}{s}", .{ ctx.bg.open(), fg.open(), indent, inner, rp, ansi_reset }); } fn blank(ctx: @This()) ![]u8 { @@ -2778,8 +2808,13 @@ fn vw(line: []const u8) usize { test "displayWidth counts codepoints; truncateToCols respects boundaries" { try testing.expectEqual(@as(usize, 3), displayWidth("abc")); try testing.expectEqual(@as(usize, 3), displayWidth("aé✓")); + try testing.expectEqual(@as(usize, 8), displayWidth("\t")); + try testing.expectEqual(@as(usize, 9), displayWidth("\tX")); + try testing.expectEqual(@as(usize, 9), displayWidth("a\tX")); try testing.expectEqualStrings("aé", truncateToCols("aé✓", 2)); try testing.expectEqualStrings("abc", truncateToCols("abcdef", 3)); + try testing.expectEqualStrings("", truncateToCols("\tX", 7)); + try testing.expectEqualStrings("\t", truncateToCols("\tX", 8)); // Never splits a multibyte codepoint. const t = truncateToCols("é", 1); try testing.expectEqualStrings("é", t); @@ -2792,6 +2827,16 @@ test "displayWidth counts codepoints; truncateToCols respects boundaries" { try testing.expectEqualStrings("hello 🤖", truncateToCols("hello 🤖", 8)); } +test "displayWidthStyled treats OSC hyperlinks as zero width" { + try testing.expectEqual(@as(usize, 4), displayWidthStyled("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\")); + try testing.expectEqualStrings("\x1b]8;;https://example.com\x1b\\link", truncateStyledToCols("\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\", 4)); +} + +test "displayWidthStyled counts styled hard tabs as tab stops" { + try testing.expectEqual(@as(usize, 8), displayWidthStyled("\x1b[48;2;40;50;40m\t\x1b[0m")); + try testing.expectEqual(@as(usize, 9), displayWidthStyled("\x1b[48;2;40;50;40m\tX\x1b[0m")); +} + test "wrapParagraph word-wraps and hard-splits long words" { var out: std.ArrayList([]const u8) = .empty; defer out.deinit(testing.allocator); @@ -2807,6 +2852,12 @@ test "wrapParagraph word-wraps and hard-splits long words" { try testing.expectEqualStrings("abcd", out.items[0]); try testing.expectEqualStrings("efgh", out.items[1]); try testing.expectEqualStrings("ij", out.items[2]); + + out.clearRetainingCapacity(); + try wrapParagraph("\treturn err", 14, &out, testing.allocator); + try testing.expectEqual(@as(usize, 2), out.items.len); + try testing.expectEqualStrings("\treturn", out.items[0]); + try testing.expectEqualStrings("err", out.items[1]); } // -- AssistantText --------------------------------------------------------- @@ -3999,3 +4050,25 @@ 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); } + +test "ToolUse: tabbed output prepaints background without expanding tabs" { + var t = ToolUse.init(testing.allocator); + defer t.deinit(); + try t.setName("read"); + try t.setInput("{}"); + t.setCollapsed(false); + try t.setOutput("\treturn err"); + t.setResultOk(true); + + const lines = try t.comp().render(32, testing.allocator); + var found = false; + for (lines) |line| { + if (std.mem.indexOf(u8, line, "\treturn err")) |_| { + found = true; + try testing.expect(std.mem.indexOfScalar(u8, line, '\r') != null); + try testing.expect(vw(line) <= 32); + try testing.expectEqual(@as(usize, 1), std.mem.count(u8, line, "\t")); + } + } + try testing.expect(found); +} |
