From 983e523ca9f8e6a7db56b4aca23a962be95e7051 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 6 Jul 2026 08:34:35 -0600 Subject: 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 --- libpanto/src/provider_openai_chat.zig | 84 +++- src/markdown.zig | 740 ++++++++++++++++++++++++++++++++-- src/tui_components.zig | 113 +++++- src/tui_engine.zig | 14 + 4 files changed, 882 insertions(+), 69 deletions(-) diff --git a/libpanto/src/provider_openai_chat.zig b/libpanto/src/provider_openai_chat.zig index a05c7ba..4ac1e11 100644 --- a/libpanto/src/provider_openai_chat.zig +++ b/libpanto/src/provider_openai_chat.zig @@ -466,12 +466,12 @@ const StreamState = struct { } const tu = &self.active_tool.?; - // Append identity fragments. Most providers send id+name whole on - // the first delta and never repeat them, but appending is the only - // correct behavior across the full range of OpenAI-compatible - // backends — some chunk these strings. - if (d.id) |s| try tu.id_buf.appendSlice(self.allocator, s); - if (d.name) |s| try tu.name_buf.appendSlice(self.allocator, s); + // Some OpenAI-compatible backends send identity as fragments + // (`call_` then `xyz`), while others resend the cumulative/full value + // on later argument chunks. Accept both without turning repeats into + // runaway ids like `call_xyzcall_xyz...`. + if (d.id) |s| try mergeStreamingField(self.allocator, &tu.id_buf, s); + if (d.name) |s| try mergeStreamingField(self.allocator, &tu.name_buf, s); // Defer `onBlockStart` until args begin. The first argument // fragment is our signal that identity is likely settled enough @@ -601,6 +601,24 @@ const StreamState = struct { try out.push(.{ .block_start = .{ .block_type = .ToolUse, .index = tu.block_index } }); } + fn mergeStreamingField( + allocator: Allocator, + buf: *conversation.TextualBlock, + piece: []const u8, + ) !void { + if (piece.len == 0) return; + if (buf.items.len == 0) { + try buf.appendSlice(allocator, piece); + } else if (std.mem.eql(u8, buf.items, piece)) { + return; + } else if (std.mem.startsWith(u8, piece, buf.items)) { + buf.clearRetainingCapacity(); + try buf.appendSlice(allocator, piece); + } else { + try buf.appendSlice(allocator, piece); + } + } + /// End the stream: close any open text/thinking block, close the still- /// active tool_use (if any), then commit the assembled assistant /// Message to the conversation and push the terminal `message_complete`. @@ -988,6 +1006,60 @@ test "fragmented tool_call id and name are reassembled" { try testing.expectEqualStrings("{\"host\":\"a.com\"}", tu.input.items); } +test "repeated full tool_call id and name do not accumulate" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try addUserText(&conv, "read it"); + + const events = [_][]const u8{ + \\{"choices":[{"delta":{"role":"assistant"}}]} + , + \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"chatcmpl-tool-abc","type":"function","function":{"name":"std__read","arguments":"{\"path\""}}]}}]} + , + \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"chatcmpl-tool-abc","function":{"name":"std__read","arguments":":\"a\"}"}}]}}]} + , + \\{"choices":[{"delta":{},"finish_reason":"tool_calls"}]} + , + "[DONE]", + }; + + try runStreamedTurn(allocator, &conv, null, &events); + + const tu = conv.messages.items[1].content.items[0].ToolUse; + try testing.expectEqualStrings("chatcmpl-tool-abc", tu.id); + try testing.expectEqualStrings("std.read", tu.name); + try testing.expectEqualStrings("{\"path\":\"a\"}", tu.input.items); +} + +test "cumulative tool_call id and name replace their prefix" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try addUserText(&conv, "read it"); + + const events = [_][]const u8{ + \\{"choices":[{"delta":{"role":"assistant"}}]} + , + \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"chatcmpl-tool-","type":"function","function":{"name":"std_","arguments":"{\"path\""}}]}}]} + , + \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"chatcmpl-tool-abc","function":{"name":"std__read","arguments":":\"a\"}"}}]}}]} + , + \\{"choices":[{"delta":{},"finish_reason":"tool_calls"}]} + , + "[DONE]", + }; + + try runStreamedTurn(allocator, &conv, null, &events); + + const tu = conv.messages.items[1].content.items[0].ToolUse; + try testing.expectEqualStrings("chatcmpl-tool-abc", tu.id); + try testing.expectEqualStrings("std.read", tu.name); + try testing.expectEqualStrings("{\"path\":\"a\"}", tu.input.items); +} + test "inbound wire tool name is decoded to dotted form (even split across __)" { // The model echoes the wire name it was given (`std__read`). It is // decoded to the internal `std.read` for the conversation/session/ diff --git a/src/markdown.zig b/src/markdown.zig index 13358b4..73c5a1f 100644 --- a/src/markdown.zig +++ b/src/markdown.zig @@ -12,6 +12,65 @@ const c = @cImport({ }); const Allocator = std.mem.Allocator; +const max_style_depth = 64; +const max_list_depth = 64; + +fn codepointWidth(cp: u21) usize { + if (cp == 0x200D) return 0; + if (cp >= 0x0300 and cp <= 0x036F) return 0; + if (cp >= 0x0483 and cp <= 0x0489) return 0; + if (cp >= 0x0591 and cp <= 0x05BD) return 0; + if (cp >= 0x0610 and cp <= 0x061A) return 0; + if (cp >= 0x064B and cp <= 0x065F) return 0; + if (cp >= 0x1AB0 and cp <= 0x1AFF) return 0; + if (cp >= 0x1DC0 and cp <= 0x1DFF) return 0; + if (cp >= 0x20D0 and cp <= 0x20FF) return 0; + if (cp >= 0xFE00 and cp <= 0xFE0F) return 0; + if (cp >= 0xFE20 and cp <= 0xFE2F) return 0; + if (cp >= 0xE0100 and cp <= 0xE01EF) return 0; + + if (cp >= 0x1100 and cp <= 0x115F) return 2; + if (cp >= 0x2E80 and cp <= 0x303E) return 2; + if (cp >= 0x3041 and cp <= 0x33BF) return 2; + if (cp >= 0x3400 and cp <= 0x4DBF) return 2; + if (cp >= 0x4E00 and cp <= 0x9FFF) return 2; + if (cp >= 0xA000 and cp <= 0xA4CF) return 2; + if (cp >= 0xA960 and cp <= 0xA97F) return 2; + if (cp >= 0xAC00 and cp <= 0xD7AF) return 2; + if (cp >= 0xD7B0 and cp <= 0xD7FF) return 2; + if (cp >= 0xF900 and cp <= 0xFAFF) return 2; + if (cp >= 0xFE10 and cp <= 0xFE1F) return 2; + if (cp >= 0xFE30 and cp <= 0xFE4F) return 2; + if (cp >= 0xFF01 and cp <= 0xFF60) return 2; + if (cp >= 0xFFE0 and cp <= 0xFFE6) return 2; + if (cp >= 0x1B000 and cp <= 0x1B0FF) return 2; + if (cp >= 0x1F004 and cp <= 0x1F004) return 2; + if (cp >= 0x1F0CF and cp <= 0x1F0CF) return 2; + if (cp >= 0x1F200 and cp <= 0x1F2FF) return 2; + if (cp >= 0x1F300 and cp <= 0x1F64F) return 2; + if (cp >= 0x1F680 and cp <= 0x1F6FF) return 2; + if (cp >= 0x1F700 and cp <= 0x1F77F) return 2; + if (cp >= 0x1F780 and cp <= 0x1F7FF) return 2; + if (cp >= 0x1F800 and cp <= 0x1F8FF) return 2; + if (cp >= 0x1F900 and cp <= 0x1F9FF) return 2; + if (cp >= 0x1FA00 and cp <= 0x1FAFF) return 2; + if (cp >= 0x20000 and cp <= 0x2A6DF) return 2; + if (cp >= 0x2A700 and cp <= 0x2CEAF) return 2; + if (cp >= 0x2CEB0 and cp <= 0x2EBEF) return 2; + if (cp >= 0x2F800 and cp <= 0x2FA1F) return 2; + if (cp >= 0x30000 and cp <= 0x3134F) return 2; + + 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); +} /// Return the largest prefix that is safe to render while markdown is still /// streaming. Avoid rendering an unterminated trailing line (which may still @@ -64,27 +123,41 @@ pub fn streamingSafeCut(buffer: []const u8) []const u8 { return safe[0..opener]; } -fn isAnsiAt(s: []const u8, i: usize) bool { - return i + 1 < s.len and s[i] == '\x1b' and s[i + 1] == '['; -} - -fn skipAnsi(s: []const u8, start_i: usize) usize { - var i = start_i + 2; - while (i < s.len) : (i += 1) { - const ch = s[i]; - if (ch >= '@' and ch <= '~') return i + 1; +fn skipEscape(s: []const u8, start_i: usize) ?usize { + if (start_i + 1 >= s.len or s[start_i] != '\x1b') return null; + switch (s[start_i + 1]) { + '[' => { + var i = start_i + 2; + while (i < s.len) : (i += 1) { + if (s[i] >= '@' and s[i] <= '~') return i + 1; + } + return s.len; + }, + ']' => { + var i = start_i + 2; + while (i < s.len) : (i += 1) { + if (s[i] == 0x07) return i + 1; + if (s[i] == '\x1b' and i + 1 < s.len and s[i + 1] == '\\') return i + 2; + } + return s.len; + }, + else => return null, } - return s.len; } fn visibleWidth(s: []const u8) usize { var w: usize = 0; var i: usize = 0; while (i < s.len) { - if (isAnsiAt(s, i)) { i = skipAnsi(s, i); continue; } + if (skipEscape(s, i)) |next| { + i = next; + continue; + } const n = std.unicode.utf8ByteSequenceLength(s[i]) catch 1; - i += @min(n, s.len - i); - w += 1; + const adv = @min(n, s.len - i); + const cp = std.unicode.utf8Decode(s[i .. i + adv]) catch '?'; + i += adv; + w += codepointWidthAt(cp, w); } return w; } @@ -93,16 +166,28 @@ fn takeVisible(s: []const u8, max: usize) usize { var w: usize = 0; var i: usize = 0; while (i < s.len) { - if (isAnsiAt(s, i)) { i = skipAnsi(s, i); continue; } + if (skipEscape(s, i)) |next| { + i = next; + continue; + } const n = std.unicode.utf8ByteSequenceLength(s[i]) catch 1; const adv = @min(n, s.len - i); - if (w + 1 > max) break; + const cp = std.unicode.utf8Decode(s[i .. i + adv]) catch '?'; + const cp_w = codepointWidthAt(cp, w); + if (w + cp_w > max) break; i += adv; - w += 1; + w += cp_w; } return i; } +fn maxLineVisibleWidth(s: []const u8) usize { + var max: usize = 0; + var it = std.mem.splitScalar(u8, s, '\n'); + while (it.next()) |line| max = @max(max, visibleWidth(line)); + return max; +} + /// ANSI-aware greedy wrapping. The input may contain CSI styling escapes. pub fn wrapStyled(buf: []const u8, width: usize, out: *std.ArrayList(u8), alloc: Allocator) !void { const w = @max(width, 1); @@ -128,6 +213,49 @@ pub fn wrapStyled(buf: []const u8, width: usize, out: *std.ArrayList(u8), alloc: } } +const TableCell = struct { + const Align = enum { left, center, right }; + + text: []const u8, + alignment: Align = .left, + + fn deinit(self: *TableCell, alloc: Allocator) void { + alloc.free(self.text); + } +}; + +const TableRow = struct { + cells: std.ArrayList(TableCell) = .empty, + is_header: bool = false, + + fn deinit(self: *TableRow, alloc: Allocator) void { + for (self.cells.items) |*cell| cell.deinit(alloc); + self.cells.deinit(alloc); + } +}; + +const CellLines = struct { + lines: std.ArrayList([]const u8) = .empty, + + fn deinit(self: *CellLines, alloc: Allocator) void { + for (self.lines.items) |line| alloc.free(line); + self.lines.deinit(alloc); + } +}; + +const ListState = struct { + ordered: bool, + next: usize = 1, +}; + +const LinkState = struct { + href: []const u8, + + fn deinit(self: *LinkState, alloc: Allocator) void { + alloc.free(self.href); + } +}; + pub const Renderer = struct { alloc: Allocator, width: usize, @@ -138,20 +266,46 @@ pub const Renderer = struct { list_depth: usize = 0, list_item_depth: usize = 0, list_item_first_paragraph: bool = false, - list_item_depth_stack: [64]usize = [_]usize{0} ** 64, - list_item_first_stack: [64]bool = [_]bool{false} ** 64, + list_stack: [max_list_depth]ListState = undefined, + list_item_depth_stack: [max_list_depth]usize = [_]usize{0} ** max_list_depth, + list_item_first_stack: [max_list_depth]bool = [_]bool{false} ** max_list_depth, + list_item_marker_stack: [max_list_depth][32]u8 = undefined, + list_item_marker_len_stack: [max_list_depth]usize = [_]usize{0} ** max_list_depth, list_item_stack_len: usize = 0, + list_item_marker: [32]u8 = undefined, + list_item_marker_len: usize = 0, heading_level: usize = 0, + quote_depth: usize = 0, + table_rows: std.ArrayList(TableRow) = .empty, + current_table_row: ?TableRow = null, + in_table: bool = false, + in_table_cell: bool = false, + current_cell_is_header: bool = false, + current_cell_align: TableCell.Align = .left, + style_stack: [max_style_depth][]const u8 = undefined, + style_stack_len: usize = 0, + current_link: ?LinkState = null, err: ?anyerror = null, pub fn render(self: *Renderer, src: []const u8) !void { self.buf = .empty; defer self.buf.deinit(self.alloc); + defer self.clearLinks(); self.err = null; + self.in_code_block = false; + self.list_depth = 0; + self.list_item_depth = 0; + self.list_item_first_paragraph = false; + self.list_item_stack_len = 0; + self.list_item_marker_len = 0; + self.heading_level = 0; + self.quote_depth = 0; + self.style_stack_len = 0; + self.clearTable(); var parser: c.MD_PARSER = std.mem.zeroes(c.MD_PARSER); parser.abi_version = 0; - parser.flags = c.MD_FLAG_TABLES | c.MD_FLAG_STRIKETHROUGH | c.MD_FLAG_PERMISSIVEURLAUTOLINKS; + parser.flags = c.MD_FLAG_TABLES | c.MD_FLAG_TASKLISTS | c.MD_FLAG_STRIKETHROUGH | c.MD_FLAG_PERMISSIVEURLAUTOLINKS; parser.enter_block = enterBlock; parser.leave_block = leaveBlock; parser.enter_span = enterSpan; @@ -168,7 +322,9 @@ pub const Renderer = struct { } } - fn add(self: *Renderer, s: []const u8) !void { try self.buf.appendSlice(self.alloc, s); } + fn add(self: *Renderer, s: []const u8) !void { + try self.buf.appendSlice(self.alloc, s); + } fn appendLine(self: *Renderer, s: []const u8) !void { const line = try self.alloc.dupe(u8, s); @@ -182,9 +338,22 @@ pub const Renderer = struct { try self.appendLine(""); } + fn quotePrefix(self: *Renderer, out: *std.ArrayList(u8)) !void { + const quote = theme.default.fg(.dim); + var i: usize = 0; + while (i < self.quote_depth) : (i += 1) { + try out.appendSlice(self.alloc, quote.open()); + try out.appendSlice(self.alloc, "┃ "); + try out.appendSlice(self.alloc, quote.close()); + } + } + fn flushParagraph(self: *Renderer) !void { const text = std.mem.trim(u8, self.buf.items, " \t\n"); - if (text.len == 0) { self.buf.clearRetainingCapacity(); return; } + if (text.len == 0) { + self.buf.clearRetainingCapacity(); + return; + } if (self.list_item_depth > 0) { var first_prefix: std.ArrayList(u8) = .empty; @@ -193,12 +362,14 @@ pub const Renderer = struct { defer cont_prefix.deinit(self.alloc); const indent = (self.list_item_depth - 1) * 2; + try self.quotePrefix(&first_prefix); + try self.quotePrefix(&cont_prefix); try first_prefix.appendNTimes(self.alloc, ' ', indent); - try cont_prefix.appendNTimes(self.alloc, ' ', indent + 2); + try cont_prefix.appendNTimes(self.alloc, ' ', indent + self.list_item_marker_len); if (self.list_item_first_paragraph) { - try first_prefix.appendSlice(self.alloc, "• "); + try first_prefix.appendSlice(self.alloc, self.list_item_marker[0..self.list_item_marker_len]); } else { - try first_prefix.appendNTimes(self.alloc, ' ', 2); + try first_prefix.appendNTimes(self.alloc, ' ', self.list_item_marker_len); } var wrapped: std.ArrayList(u8) = .empty; @@ -217,11 +388,21 @@ pub const Renderer = struct { } self.list_item_first_paragraph = false; } else { + var prefix: std.ArrayList(u8) = .empty; + defer prefix.deinit(self.alloc); + try self.quotePrefix(&prefix); var wrapped: std.ArrayList(u8) = .empty; defer wrapped.deinit(self.alloc); - try wrapStyled(text, self.width, &wrapped, self.alloc); + const wrap_width = if (self.width > visibleWidth(prefix.items)) self.width - visibleWidth(prefix.items) else 1; + try wrapStyled(text, wrap_width, &wrapped, self.alloc); var it = std.mem.splitScalar(u8, wrapped.items, '\n'); - while (it.next()) |line| try self.appendLine(line); + while (it.next()) |line| { + var tmp: std.ArrayList(u8) = .empty; + defer tmp.deinit(self.alloc); + try tmp.appendSlice(self.alloc, prefix.items); + try tmp.appendSlice(self.alloc, line); + try self.appendLine(tmp.items); + } } self.buf.clearRetainingCapacity(); } @@ -242,31 +423,284 @@ pub const Renderer = struct { self.buf.clearRetainingCapacity(); } - fn fail(self: *Renderer, e: anyerror) c_int { self.err = e; return 1; } + fn appendTableCell(self: *Renderer) !void { + const text = std.mem.trim(u8, self.buf.items, " \t\n"); + const row = if (self.current_table_row) |*row| row else return; + const owned = try self.alloc.dupe(u8, text); + errdefer self.alloc.free(owned); + try row.cells.append(self.alloc, .{ .text = owned, .alignment = self.current_cell_align }); + row.is_header = row.is_header or self.current_cell_is_header; + self.buf.clearRetainingCapacity(); + self.in_table_cell = false; + self.current_cell_is_header = false; + self.current_cell_align = .left; + } + + fn appendTableBorder( + self: *Renderer, + widths: []const usize, + left: []const u8, + join: []const u8, + right: []const u8, + ) !void { + var line: std.ArrayList(u8) = .empty; + defer line.deinit(self.alloc); + try line.appendSlice(self.alloc, left); + for (widths, 0..) |w, i| { + var n: usize = 0; + while (n < w + 2) : (n += 1) try line.appendSlice(self.alloc, "─"); + try line.appendSlice(self.alloc, if (i + 1 == widths.len) right else join); + } + try self.appendLine(line.items); + } + + fn fitTableWidths(self: *Renderer, widths: []usize) void { + if (widths.len == 0) return; + const overhead = widths.len * 3 + 1; + const budget = if (self.width > overhead) self.width - overhead else widths.len; + for (widths) |*w| w.* = @max(w.*, 1); + while (true) { + var sum: usize = 0; + var biggest_i: usize = 0; + var biggest: usize = 0; + for (widths, 0..) |w, i| { + sum += w; + if (w > biggest) { + biggest = w; + biggest_i = i; + } + } + if (sum <= budget or biggest <= 1) break; + widths[biggest_i] -= 1; + } + } + + fn appendPaddedCell(self: *Renderer, line: *std.ArrayList(u8), text: []const u8, width: usize, alignment: TableCell.Align) !void { + const pad = width -| visibleWidth(text); + const left_pad: usize, const right_pad: usize = switch (alignment) { + .left => .{ 0, pad }, + .right => .{ pad, 0 }, + .center => .{ pad / 2, pad - pad / 2 }, + }; + try line.append(self.alloc, ' '); + try line.appendNTimes(self.alloc, ' ', left_pad); + try line.appendSlice(self.alloc, text); + try line.appendNTimes(self.alloc, ' ', right_pad + 1); + } + + fn renderTableRow(self: *Renderer, row: TableRow, widths: []const usize) !void { + var cells: std.ArrayList(CellLines) = .empty; + defer { + for (cells.items) |*cell| cell.deinit(self.alloc); + cells.deinit(self.alloc); + } + + try cells.ensureTotalCapacity(self.alloc, widths.len); + var row_height: usize = 1; + for (widths, 0..) |w, i| { + var cell_lines: CellLines = .{}; + errdefer cell_lines.deinit(self.alloc); + const text = if (i < row.cells.items.len) row.cells.items[i].text else ""; + var wrapped: std.ArrayList(u8) = .empty; + defer wrapped.deinit(self.alloc); + try wrapStyled(text, w, &wrapped, self.alloc); + var it = std.mem.splitScalar(u8, wrapped.items, '\n'); + while (it.next()) |part| try cell_lines.lines.append(self.alloc, try self.alloc.dupe(u8, part)); + row_height = @max(row_height, cell_lines.lines.items.len); + cells.appendAssumeCapacity(cell_lines); + } + + var line_i: usize = 0; + while (line_i < row_height) : (line_i += 1) { + var line: std.ArrayList(u8) = .empty; + defer line.deinit(self.alloc); + try line.appendSlice(self.alloc, "│"); + for (widths, 0..) |w, i| { + const text = if (line_i < cells.items[i].lines.items.len) cells.items[i].lines.items[line_i] else ""; + const alignment: TableCell.Align = if (i < row.cells.items.len) row.cells.items[i].alignment else .left; + try self.appendPaddedCell(&line, text, w, alignment); + try line.appendSlice(self.alloc, "│"); + } + try self.appendLine(line.items); + } + } + + fn renderTable(self: *Renderer) !void { + if (self.table_rows.items.len == 0) return; + + var cols: usize = 0; + for (self.table_rows.items) |row| cols = @max(cols, row.cells.items.len); + if (cols == 0) return; + + var widths = try self.alloc.alloc(usize, cols); + defer self.alloc.free(widths); + @memset(widths, 1); + for (self.table_rows.items) |row| { + for (row.cells.items, 0..) |cell, i| { + widths[i] = @max(widths[i], maxLineVisibleWidth(cell.text)); + } + } + self.fitTableWidths(widths); + + try self.appendTableBorder(widths, "┌", "┬", "┐"); + for (self.table_rows.items, 0..) |row, i| { + try self.renderTableRow(row, widths); + if (row.is_header and i + 1 < self.table_rows.items.len) { + try self.appendTableBorder(widths, "├", "┼", "┤"); + } + } + try self.appendTableBorder(widths, "└", "┴", "┘"); + } + + fn clearTable(self: *Renderer) void { + for (self.table_rows.items) |*row| row.deinit(self.alloc); + self.table_rows.deinit(self.alloc); + self.table_rows = .empty; + if (self.current_table_row) |*row| row.deinit(self.alloc); + self.current_table_row = null; + self.in_table = false; + self.in_table_cell = false; + self.current_cell_is_header = false; + self.current_cell_align = .left; + self.buf.clearRetainingCapacity(); + } + + fn clearLinks(self: *Renderer) void { + if (self.current_link) |*link| link.deinit(self.alloc); + self.current_link = null; + } + + fn pushStyle(self: *Renderer, open_seq: []const u8) !void { + if (open_seq.len == 0) return; + if (self.style_stack_len < self.style_stack.len) { + self.style_stack[self.style_stack_len] = open_seq; + self.style_stack_len += 1; + } + try self.add(open_seq); + } + + fn popStyle(self: *Renderer) !void { + if (self.style_stack_len > 0) self.style_stack_len -= 1; + try self.add(theme.reset); + for (self.style_stack[0..self.style_stack_len]) |open_seq| try self.add(open_seq); + } + + fn appendAttribute(self: *Renderer, attr: c.MD_ATTRIBUTE) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(self.alloc); + try appendDecodedEntities(&out, self.alloc, attr.text[0..@intCast(attr.size)]); + return try out.toOwnedSlice(self.alloc); + } + + fn pushLink(self: *Renderer, href: []const u8) !void { + self.clearLinks(); + const owned = try self.alloc.dupe(u8, href); + errdefer self.alloc.free(owned); + self.current_link = .{ .href = owned }; + try self.add("\x1b]8;;"); + try self.add(href); + try self.add("\x1b\\"); + try self.pushStyle("\x1b[4m"); + } + + fn popLink(self: *Renderer) !void { + try self.popStyle(); + var link = self.current_link orelse return; + self.current_link = null; + defer link.deinit(self.alloc); + try self.add("\x1b]8;;\x1b\\"); + try self.add(" ("); + try self.add(link.href); + try self.add(")"); + } + + fn fail(self: *Renderer, e: anyerror) c_int { + self.err = e; + return 1; + } fn enterBlock(t: c.MD_BLOCKTYPE, detail: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) c_int { const self: *Renderer = @ptrCast(@alignCast(userdata.?)); switch (t) { + c.MD_BLOCK_QUOTE => { + self.flushParagraph() catch |e| return self.fail(e); + self.quote_depth += 1; + }, + c.MD_BLOCK_TABLE => { + self.flushParagraph() catch |e| return self.fail(e); + self.clearTable(); + self.in_table = true; + }, + c.MD_BLOCK_TR => { + if (self.in_table) self.current_table_row = .{}; + }, + c.MD_BLOCK_TH, c.MD_BLOCK_TD => { + if (self.in_table) { + self.buf.clearRetainingCapacity(); + self.in_table_cell = true; + self.current_cell_is_header = t == c.MD_BLOCK_TH; + const d: *c.MD_BLOCK_TD_DETAIL = @ptrCast(@alignCast(detail.?)); + self.current_cell_align = switch (d.@"align") { + c.MD_ALIGN_RIGHT => .right, + c.MD_ALIGN_CENTER => .center, + else => .left, + }; + } + }, c.MD_BLOCK_H => { + if (self.in_table) return 0; self.flushParagraph() catch |e| return self.fail(e); const d: *c.MD_BLOCK_H_DETAIL = @ptrCast(@alignCast(detail.?)); self.heading_level = @intCast(d.level); self.add(theme.default.fg(.welcome).open()) catch |e| return self.fail(e); - var i: usize = 0; while (i < self.heading_level) : (i += 1) self.add("#") catch |e| return self.fail(e); + var i: usize = 0; + while (i < self.heading_level) : (i += 1) self.add("#") catch |e| return self.fail(e); self.add(" ") catch |e| return self.fail(e); }, c.MD_BLOCK_P => {}, - c.MD_BLOCK_CODE => { self.in_code_block = true; self.buf.clearRetainingCapacity(); }, - c.MD_BLOCK_UL, c.MD_BLOCK_OL => self.list_depth += 1, + c.MD_BLOCK_CODE => if (!self.in_table) { + self.in_code_block = true; + self.buf.clearRetainingCapacity(); + }, + c.MD_BLOCK_UL => { + if (self.list_depth < self.list_stack.len) { + self.list_stack[self.list_depth] = .{ .ordered = false }; + } + self.list_depth += 1; + }, + c.MD_BLOCK_OL => { + const d: *c.MD_BLOCK_OL_DETAIL = @ptrCast(@alignCast(detail.?)); + if (self.list_depth < self.list_stack.len) { + self.list_stack[self.list_depth] = .{ .ordered = true, .next = @intCast(d.start) }; + } + self.list_depth += 1; + }, c.MD_BLOCK_LI => { self.flushParagraph() catch |e| return self.fail(e); if (self.list_item_stack_len < self.list_item_depth_stack.len) { self.list_item_depth_stack[self.list_item_stack_len] = self.list_item_depth; self.list_item_first_stack[self.list_item_stack_len] = self.list_item_first_paragraph; + self.list_item_marker_stack[self.list_item_stack_len] = self.list_item_marker; + self.list_item_marker_len_stack[self.list_item_stack_len] = self.list_item_marker_len; self.list_item_stack_len += 1; } self.list_item_depth = self.list_depth; self.list_item_first_paragraph = true; + const li: *c.MD_BLOCK_LI_DETAIL = @ptrCast(@alignCast(detail.?)); + const list_i = if (self.list_depth == 0) 0 else self.list_depth - 1; + if (list_i < self.list_stack.len and self.list_stack[list_i].ordered) { + const n = self.list_stack[list_i].next; + self.list_stack[list_i].next += 1; + const marker = std.fmt.bufPrint(&self.list_item_marker, "{d}. ", .{n}) catch "?. "; + self.list_item_marker_len = marker.len; + } else if (li.is_task != 0) { + const mark = if (li.task_mark == 'x' or li.task_mark == 'X') "☑ " else "☐ "; + @memcpy(self.list_item_marker[0..mark.len], mark); + self.list_item_marker_len = mark.len; + } else { + @memcpy(self.list_item_marker[0.."• ".len], "• "); + self.list_item_marker_len = "• ".len; + } }, c.MD_BLOCK_HR => self.appendLine("────────") catch |e| return self.fail(e), else => {}, @@ -278,13 +712,35 @@ pub const Renderer = struct { _ = detail; const self: *Renderer = @ptrCast(@alignCast(userdata.?)); switch (t) { + c.MD_BLOCK_QUOTE => { + self.flushParagraph() catch |e| return self.fail(e); + if (self.quote_depth > 0) self.quote_depth -= 1; + self.appendBlank() catch |e| return self.fail(e); + }, + c.MD_BLOCK_TABLE => { + self.renderTable() catch |e| return self.fail(e); + self.clearTable(); + self.appendBlank() catch |e| return self.fail(e); + }, + c.MD_BLOCK_TR => { + if (self.in_table) { + const row = self.current_table_row orelse return 0; + self.current_table_row = null; + self.table_rows.append(self.alloc, row) catch |e| return self.fail(e); + } + }, + c.MD_BLOCK_TH, c.MD_BLOCK_TD => { + if (self.in_table_cell) self.appendTableCell() catch |e| return self.fail(e); + }, c.MD_BLOCK_H => { + if (self.in_table) return 0; self.add(theme.default.fg(.welcome).close()) catch |e| return self.fail(e); self.flushParagraph() catch |e| return self.fail(e); self.appendBlank() catch |e| return self.fail(e); self.heading_level = 0; }, c.MD_BLOCK_P => { + if (self.in_table) return 0; self.flushParagraph() catch |e| return self.fail(e); if (self.list_item_depth == 0) self.appendBlank() catch |e| return self.fail(e); }, @@ -294,9 +750,12 @@ pub const Renderer = struct { self.list_item_stack_len -= 1; self.list_item_depth = self.list_item_depth_stack[self.list_item_stack_len]; self.list_item_first_paragraph = self.list_item_first_stack[self.list_item_stack_len]; + self.list_item_marker = self.list_item_marker_stack[self.list_item_stack_len]; + self.list_item_marker_len = self.list_item_marker_len_stack[self.list_item_stack_len]; } else { self.list_item_depth = 0; self.list_item_first_paragraph = false; + self.list_item_marker_len = 0; } }, c.MD_BLOCK_CODE => { @@ -314,24 +773,36 @@ pub const Renderer = struct { } fn enterSpan(t: c.MD_SPANTYPE, detail: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) c_int { - _ = detail; const self: *Renderer = @ptrCast(@alignCast(userdata.?)); const s = switch (t) { c.MD_SPAN_STRONG => "\x1b[1m", c.MD_SPAN_EM => "\x1b[3m", c.MD_SPAN_CODE => theme.default.fg(.tool_header).open(), - c.MD_SPAN_A => "\x1b[4m", c.MD_SPAN_DEL => "\x1b[9m", else => "", }; - self.add(s) catch |e| return self.fail(e); + if (t == c.MD_SPAN_A) { + const d: *c.MD_SPAN_A_DETAIL = @ptrCast(@alignCast(detail.?)); + const href = self.appendAttribute(d.href) catch |e| return self.fail(e); + defer self.alloc.free(href); + self.pushLink(href) catch |e| return self.fail(e); + } else { + self.pushStyle(s) catch |e| return self.fail(e); + } return 0; } fn leaveSpan(t: c.MD_SPANTYPE, detail: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) c_int { - _ = t; _ = detail; + _ = detail; const self: *Renderer = @ptrCast(@alignCast(userdata.?)); - self.add(theme.reset) catch |e| return self.fail(e); + if (t == c.MD_SPAN_A) { + self.popLink() catch |e| return self.fail(e); + } else switch (t) { + c.MD_SPAN_STRONG, c.MD_SPAN_EM, c.MD_SPAN_CODE, c.MD_SPAN_DEL => { + self.popStyle() catch |e| return self.fail(e); + }, + else => {}, + } return 0; } @@ -341,20 +812,63 @@ pub const Renderer = struct { switch (t) { c.MD_TEXT_BR, c.MD_TEXT_SOFTBR => self.add("\n") catch |e| return self.fail(e), c.MD_TEXT_NULLCHAR => self.add("�") catch |e| return self.fail(e), - c.MD_TEXT_ENTITY => self.add(decodeEntity(s)) catch |e| return self.fail(e), + c.MD_TEXT_ENTITY => appendDecodedEntities(&self.buf, self.alloc, s) catch |e| return self.fail(e), else => self.add(s) catch |e| return self.fail(e), } return 0; } }; -fn decodeEntity(s: []const u8) []const u8 { - if (std.mem.eql(u8, s, "&")) return "&"; - if (std.mem.eql(u8, s, "<")) return "<"; - if (std.mem.eql(u8, s, ">")) return ">"; - if (std.mem.eql(u8, s, """)) return "\""; - if (std.mem.eql(u8, s, "'")) return "'"; - return s; +fn appendCodepoint(out: *std.ArrayList(u8), alloc: Allocator, cp: u21) !void { + var buf: [4]u8 = undefined; + const n = std.unicode.utf8Encode(cp, &buf) catch { + try out.appendSlice(alloc, "�"); + return; + }; + try out.appendSlice(alloc, buf[0..n]); +} + +fn appendDecodedEntity(out: *std.ArrayList(u8), alloc: Allocator, entity: []const u8) !bool { + if (std.mem.eql(u8, entity, "&")) { + try out.append(alloc, '&'); + } else if (std.mem.eql(u8, entity, "<")) { + try out.append(alloc, '<'); + } else if (std.mem.eql(u8, entity, ">")) { + try out.append(alloc, '>'); + } else if (std.mem.eql(u8, entity, """)) { + try out.append(alloc, '"'); + } else if (std.mem.eql(u8, entity, "'")) { + try out.append(alloc, '\''); + } else if (std.mem.eql(u8, entity, " ")) { + try out.appendSlice(alloc, "\u{00a0}"); + } else if (entity.len >= 4 and entity[0] == '&' and entity[1] == '#' and entity[entity.len - 1] == ';') { + const body = entity[2 .. entity.len - 1]; + const cp64 = if (body.len >= 2 and (body[0] == 'x' or body[0] == 'X')) + std.fmt.parseInt(u21, body[1..], 16) catch return false + else + std.fmt.parseInt(u21, body, 10) catch return false; + try appendCodepoint(out, alloc, cp64); + } else { + return false; + } + return true; +} + +fn appendDecodedEntities(out: *std.ArrayList(u8), alloc: Allocator, text: []const u8) !void { + var i: usize = 0; + while (i < text.len) { + if (text[i] == '&') { + if (std.mem.indexOfScalarPos(u8, text, i, ';')) |semi| { + const entity = text[i .. semi + 1]; + if (try appendDecodedEntity(out, alloc, entity)) { + i = semi + 1; + continue; + } + } + } + try out.append(alloc, text[i]); + i += 1; + } } const testing = std.testing; @@ -371,6 +885,14 @@ test "wrapStyled wraps plain text" { try testing.expectEqualStrings("the quick\nbrown fox", out.items); } +test "wrapStyled preserves hard tabs while counting tab stops" { + var out: std.ArrayList(u8) = .empty; + defer out.deinit(testing.allocator); + try wrapStyled("\treturn err", 14, &out, testing.allocator); + try testing.expectEqualStrings("\treturn\nerr", out.items); + try testing.expectEqual(@as(usize, 11), visibleWidth("\tfoo")); +} + test "Renderer uses MD4C for common markdown" { var out: std.ArrayList([]const u8) = .empty; defer { @@ -446,3 +968,135 @@ test "Renderer preserves nested list indentation" { try testing.expectEqualStrings(" • child", out.items[1]); try testing.expectEqualStrings("• next", out.items[2]); } + +test "Renderer renders markdown tables with aligned columns" { + var out: std.ArrayList([]const u8) = .empty; + defer { + for (out.items) |l| testing.allocator.free(l); + out.deinit(testing.allocator); + } + var r: Renderer = .{ .alloc = testing.allocator, .width = 120, .out_lines = &out }; + try r.render( + \\| Option | Where logic lives | Touches sliderule? | Verdict | + \\|--------|-------------------|--------------------|---------| + \\| A | Rewrite AST in Go | No | Works today | + ); + try testing.expectEqual(@as(usize, 5), out.items.len); + try testing.expect(std.mem.indexOf(u8, out.items[0], "┌") != null); + try testing.expect(std.mem.indexOf(u8, out.items[2], "├") != null); + try testing.expect(std.mem.indexOf(u8, out.items[4], "└") != null); + const option_sep = std.mem.indexOf(u8, out.items[1], "│ Where").?; + try testing.expectEqual(option_sep, std.mem.indexOf(u8, out.items[3], "│ Rewrite").?); + const verdict_sep = std.mem.indexOf(u8, out.items[1], "│ Verdict").?; + try testing.expectEqual(verdict_sep, std.mem.indexOf(u8, out.items[3], "│ Works").?); +} + +test "Renderer honors markdown table alignment" { + var out: std.ArrayList([]const u8) = .empty; + defer { + for (out.items) |l| testing.allocator.free(l); + out.deinit(testing.allocator); + } + var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; + try r.render( + \\| Left | Center | Right | + \\|:-----|:------:|------:| + \\| x | y | z | + ); + try testing.expect(std.mem.indexOf(u8, out.items[3], "│ x │") != null); + try testing.expect(std.mem.indexOf(u8, out.items[3], "│ y │") != null); + try testing.expect(std.mem.indexOf(u8, out.items[3], "│ z │") != null); +} + +test "Renderer wraps wide markdown table cells to fit width" { + var out: std.ArrayList([]const u8) = .empty; + defer { + for (out.items) |l| testing.allocator.free(l); + out.deinit(testing.allocator); + } + var r: Renderer = .{ .alloc = testing.allocator, .width = 24, .out_lines = &out }; + try r.render( + \\| Short | Long | + \\|-------|------| + \\| A | one two three four five | + ); + try testing.expect(out.items.len > 5); + for (out.items) |line| try testing.expect(visibleWidth(line) <= 24); +} + +test "Renderer renders blockquotes with quote prefix" { + var out: std.ArrayList([]const u8) = .empty; + defer { + for (out.items) |l| testing.allocator.free(l); + out.deinit(testing.allocator); + } + var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; + try r.render("> quoted text"); + try testing.expectEqual(@as(usize, 1), out.items.len); + try testing.expect(std.mem.indexOf(u8, out.items[0], "┃ ") != null); + try testing.expect(std.mem.indexOf(u8, out.items[0], "quoted text") != null); +} + +test "Renderer numbers ordered lists from source start" { + var out: std.ArrayList([]const u8) = .empty; + defer { + for (out.items) |l| testing.allocator.free(l); + out.deinit(testing.allocator); + } + var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; + try r.render("3. three\n1. four\n1. five"); + try testing.expectEqual(@as(usize, 3), out.items.len); + try testing.expect(std.mem.indexOf(u8, out.items[0], "3. three") != null); + try testing.expect(std.mem.indexOf(u8, out.items[1], "4. four") != null); + try testing.expect(std.mem.indexOf(u8, out.items[2], "5. five") != null); +} + +test "Renderer renders task list markers" { + var out: std.ArrayList([]const u8) = .empty; + defer { + for (out.items) |l| testing.allocator.free(l); + out.deinit(testing.allocator); + } + var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; + try r.render("- [ ] todo\n- [x] done"); + try testing.expectEqual(@as(usize, 2), out.items.len); + try testing.expect(std.mem.indexOf(u8, out.items[0], "☐ todo") != null); + try testing.expect(std.mem.indexOf(u8, out.items[1], "☑ done") != null); +} + +test "Renderer shows link targets and emits OSC 8 hyperlink" { + var out: std.ArrayList([]const u8) = .empty; + defer { + for (out.items) |l| testing.allocator.free(l); + out.deinit(testing.allocator); + } + var r: Renderer = .{ .alloc = testing.allocator, .width = 120, .out_lines = &out }; + try r.render("[site](https://example.com)"); + try testing.expect(std.mem.indexOf(u8, out.items[0], "\x1b]8;;https://example.com\x1b\\") != null); + try testing.expect(std.mem.indexOf(u8, out.items[0], "site") != null); + try testing.expect(std.mem.indexOf(u8, out.items[0], "(https://example.com)") != null); +} + +test "Renderer restores parent inline style after nested link" { + var out: std.ArrayList([]const u8) = .empty; + defer { + for (out.items) |l| testing.allocator.free(l); + out.deinit(testing.allocator); + } + var r: Renderer = .{ .alloc = testing.allocator, .width = 120, .out_lines = &out }; + try r.render("**before [site](https://example.com) after**"); + try testing.expect(std.mem.indexOf(u8, out.items[0], "\x1b[0m\x1b[1m") != null); + try testing.expect(std.mem.indexOf(u8, out.items[0], " after") != null); +} + +test "Renderer decodes numeric entities and counts wide glyphs" { + var out: std.ArrayList([]const u8) = .empty; + defer { + for (out.items) |l| testing.allocator.free(l); + out.deinit(testing.allocator); + } + var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; + try r.render("🤖 © &"); + try testing.expect(std.mem.indexOf(u8, out.items[0], "🤖 © &") != null); + try testing.expectEqual(@as(usize, 6), visibleWidth("🤖 © &")); +} 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); +} diff --git a/src/tui_engine.zig b/src/tui_engine.zig index 4924347..a779747 100644 --- a/src/tui_engine.zig +++ b/src/tui_engine.zig @@ -80,6 +80,17 @@ pub fn visibleWidth(line: []const u8) usize { i += skipEscape(line[i..]); continue; } + if (b == '\t') { + const rem = cols % 8; + cols += if (rem == 0) 8 else 8 - rem; + i += 1; + continue; + } + if (b == '\r') { + cols = 0; + i += 1; + continue; + } // Count one column per UTF-8 codepoint start byte. const seq_len = std.unicode.utf8ByteSequenceLength(b) catch 1; cols += 1; @@ -1010,6 +1021,9 @@ test "visibleWidth strips CSI and counts codepoints" { try testing.expectEqual(@as(usize, 5), visibleWidth("hello")); try testing.expectEqual(@as(usize, 5), visibleWidth("\x1b[2mhello\x1b[0m")); try testing.expectEqual(@as(usize, 0), visibleWidth("\x1b[0m")); + try testing.expectEqual(@as(usize, 8), visibleWidth("\t")); + try testing.expectEqual(@as(usize, 9), visibleWidth("a\tX")); + try testing.expectEqual(@as(usize, 9), visibleWidth("prepainted\r\tX")); // CURSOR_MARKER (APC string) is zero-width. try testing.expectEqual(@as(usize, 2), visibleWidth("a" ++ CURSOR_MARKER ++ "b")); // multibyte UTF-8 counts one column per codepoint. -- cgit v1.3