From 74536507dd5ce4eeaee5f3734e70a34e331e8438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 14 Jul 2026 22:32:07 +0200 Subject: [PATCH] md_term: add markdown table rendering --- src/agent/md_term.zig | 229 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 228 insertions(+), 1 deletion(-) diff --git a/src/agent/md_term.zig b/src/agent/md_term.zig index 9c7b49480..e001b7ded 100644 --- a/src/agent/md_term.zig +++ b/src/agent/md_term.zig @@ -32,10 +32,201 @@ pub fn render(w: *std.Io.Writer, src: []const u8) !void { } if (wrote_any) try w.writeByte('\n'); wrote_any = true; + if (!in_fence and isTableRow(line)) { + if (it.peek()) |next| if (isTableSeparator(next)) { + try renderTable(w, line, &it); + continue; + }; + } try renderLine(w, line, in_fence); } } +const LineIterator = std.mem.SplitIterator(u8, .scalar); + +const max_table_columns = 16; + +/// Cells are inline-rendered and padded to per-column visible width. Widths +/// count codepoints, so double-width glyphs (CJK, emoji) may misalign. +fn renderTable(w: *std.Io.Writer, header: []const u8, it: *LineIterator) !void { + var widths: [max_table_columns]usize = @splat(0); + var ncols: usize = 0; + var ok = measureRow(header, &widths, &ncols); + if (ok) { + var scan = it.*; + _ = scan.next(); + while (scan.peek()) |line| { + if (!isTableRow(line)) break; + _ = scan.next(); + if (!measureRow(line, &widths, &ncols)) { + ok = false; + break; + } + } + } + if (!ok or ncols == 0) return renderTableVerbatim(w, header, it); + + try emitRow(w, header, widths[0..ncols], true); + _ = it.next(); + try w.writeByte('\n'); + try emitSeparator(w, widths[0..ncols]); + while (it.peek()) |line| { + if (!isTableRow(line)) break; + _ = it.next(); + try w.writeByte('\n'); + try emitRow(w, line, widths[0..ncols], false); + } +} + +/// Fallback for tables the aligner can't measure (too many columns, or a +/// rendered cell overflowing its fixed buffer): rows pass through untouched. +fn renderTableVerbatim(w: *std.Io.Writer, header: []const u8, it: *LineIterator) !void { + try styled(w, header, ansi.bold); + while (it.peek()) |line| { + if (!isTableRow(line) and !isTableSeparator(line)) break; + _ = it.next(); + try w.writeByte('\n'); + if (isTableSeparator(line)) { + try styled(w, line, ansi.dim); + } else { + try w.writeAll(line); + } + } +} + +fn measureRow(row: []const u8, widths: *[max_table_columns]usize, ncols: *usize) bool { + var cells = cellIterator(row); + var col: usize = 0; + while (cells.next()) |cell| { + // A row's trailing `|` leaves one empty last segment; drop it. + if (cell.len == 0 and cells.pos >= cells.row.len) break; + if (col == max_table_columns) return false; + const width = cellDisplayWidth(cell) orelse return false; + widths[col] = @max(widths[col], width); + col += 1; + } + ncols.* = @max(ncols.*, col); + return true; +} + +fn emitRow(w: *std.Io.Writer, row: []const u8, widths: []const usize, is_header: bool) !void { + var cells = cellIterator(row); + for (widths) |width| { + try styled(w, "│", ansi.dim); + try w.writeByte(' '); + const cell = cells.next() orelse ""; + var used: usize = 0; + if (cell.len > 0) { + if (is_header) { + try w.writeAll(ansi.bold); + try renderInlineStyled(w, cell, &bold_style); + try w.writeAll(ansi.reset); + } else { + try renderInline(w, cell); + } + used = cellDisplayWidth(cell) orelse cell.len; + } + try w.splatByteAll(' ', width - used + 1); + } + try styled(w, "│", ansi.dim); +} + +fn emitSeparator(w: *std.Io.Writer, widths: []const usize) !void { + try w.writeAll(ansi.dim); + for (widths, 0..) |width, col| { + try w.writeAll(if (col == 0) "├" else "┼"); + for (0..width + 2) |_| try w.writeAll("─"); + } + try w.writeAll("┤"); + try w.writeAll(ansi.reset); +} + +const CellIterator = struct { + row: []const u8, + pos: usize, + + fn next(self: *CellIterator) ?[]const u8 { + if (self.pos >= self.row.len) return null; + var i = self.pos; + var in_code = false; + while (i < self.row.len) : (i += 1) { + switch (self.row[i]) { + '`' => in_code = !in_code, + '\\' => i += 1, + '|' => if (!in_code) break, + else => {}, + } + } + const end = @min(i, self.row.len); + const cell = std.mem.trim(u8, self.row[self.pos..end], " \t"); + self.pos = i + 1; + return cell; + } +}; + +fn cellIterator(row: []const u8) CellIterator { + const first_pipe = std.mem.indexOfScalar(u8, row, '|'); + return .{ .row = row, .pos = if (first_pipe) |p| p + 1 else 0 }; +} + +fn cellDisplayWidth(cell: []const u8) ?usize { + var buf: [512]u8 = undefined; + var fw: std.Io.Writer = .fixed(&buf); + renderInline(&fw, cell) catch return null; + return visibleWidth(fw.buffered()); +} + +/// Display columns of rendered output: escapes are zero, codepoints are one. +fn visibleWidth(s: []const u8) usize { + var n: usize = 0; + var i: usize = 0; + while (i < s.len) { + if (s[i] == 0x1b and i + 1 < s.len) { + switch (s[i + 1]) { + '[' => { + i += 2; + while (i < s.len and (s[i] < 0x40 or s[i] > 0x7e)) i += 1; + i += 1; + continue; + }, + ']' => { + i += 2; + while (i < s.len and s[i] != 0x07 and s[i] != 0x1b) i += 1; + i += @as(usize, if (i < s.len and s[i] == 0x1b) 2 else 1); + continue; + }, + '\\' => { + i += 2; + continue; + }, + else => {}, + } + } + if (s[i] & 0xc0 != 0x80) n += 1; + i += 1; + } + return n; +} + +fn isTableRow(line: []const u8) bool { + const trimmed = std.mem.trimLeft(u8, line, " \t"); + return trimmed.len >= 1 and trimmed[0] == '|'; +} + +/// A row of `-`, `:`, `|` and spaces with at least one dash and one pipe, +/// e.g. `| --- |:---:|`. +fn isTableSeparator(line: []const u8) bool { + var has_dash = false; + var has_pipe = false; + for (line) |c| switch (c) { + '-' => has_dash = true, + '|' => has_pipe = true, + ':', ' ', '\t' => {}, + else => return false, + }; + return has_dash and has_pipe; +} + fn renderLine(w: *std.Io.Writer, line: []const u8, in_fence: bool) !void { if (in_fence) { try styled(w, line, ansi.dim); @@ -178,7 +369,7 @@ fn styled(w: *std.Io.Writer, inner: []const u8, style: []const u8) !void { fn isEscapable(c: u8) bool { return switch (c) { - '*', '_', '`', '~', '[', ']', '(', ')', '\\' => true, + '*', '_', '`', '~', '[', ']', '(', ')', '|', '\\' => true, else => false, }; } @@ -287,6 +478,42 @@ test "md_term: horizontal rule" { try expectRender("---x", "---x"); } +test "md_term: tables align columns and style cells" { + const B = "\x1b[1m"; + const C = "\x1b[36m"; + const D = "\x1b[38;5;244m"; + const R = "\x1b[0m"; + const pipe = D ++ "│" ++ R; + + // Source is already padded; rendering keeps the alignment after + // stripping the cell markers. + try expectRender( + pipe ++ " " ++ B ++ "Tool" ++ R ++ " " ++ pipe ++ " " ++ B ++ "Use" ++ R ++ " " ++ pipe ++ "\n" ++ + D ++ "├──────┼─────┤" ++ R ++ "\n" ++ + pipe ++ " " ++ C ++ "goto" ++ R ++ " " ++ pipe ++ " nav " ++ pipe, + "| Tool | Use |\n|--------|-----|\n| `goto` | nav |", + ); + // Gemini-style unpadded source: columns are padded to the widest + // rendered cell. + try expectRender( + pipe ++ " " ++ B ++ "Tool" ++ R ++ " " ++ pipe ++ " " ++ B ++ "Usage" ++ R ++ " " ++ pipe ++ "\n" ++ + D ++ "├──────┼─────────────┤" ++ R ++ "\n" ++ + pipe ++ " goto " ++ pipe ++ " " ++ B ++ "Nav:" ++ R ++ " to url " ++ pipe, + "| Tool | Usage |\n| :--- | :--- |\n| goto | **Nav:** to url |", + ); + // A pipe line without a separator row underneath is not a table. + try expectRender("| just \x1b[1mtext\x1b[0m |", "| just **text** |"); +} + +test "md_term: overwide table falls back to verbatim rows" { + const header = "|a" ** 17 ++ "|"; + const sep = "|-" ** 17 ++ "|"; + try expectRender( + "\x1b[1m" ++ header ++ "\x1b[0m\n\x1b[38;5;244m" ++ sep ++ "\x1b[0m", + header ++ "\n" ++ sep, + ); +} + test "md_term: backslash escapes" { try expectRender("a * b", "a \\* b"); try expectRender("keep \\d and C:\\Users", "keep \\d and C:\\Users");