From 66cd508a41824589fb6d03ad4352923d1c0a86b4 Mon Sep 17 00:00:00 2001
From: Rach Pradhan <54503978+justrach@users.noreply.github.com>
Date: Wed, 8 Jul 2026 20:53:41 +0800
Subject: [PATCH] feat: render markdown pipe tables in the repl TUI and GUI
chat
TUI (first, per harness-first rule): renderMarkdown in repl.zig now
detects | pipe tables and draws them with box-drawing characters,
wrapping cell text to fit the terminal width (width_hint cached from
the last rendered frame; 0 assumes 100 cols).
GUI: table rendering extracted from MarkdownRenderer into a dedicated
ChatTable component (alignment, header styling unchanged) with tests.
Co-Authored-By: blackfloofie <265516171+blackfloofie@users.noreply.github.com>
---
.../chat/markdown/ChatTable.test.tsx | 43 +++
.../components/chat/markdown/ChatTable.tsx | 121 +++++++
.../chat/markdown/MarkdownRenderer.tsx | 56 +---
src/repl.zig | 306 +++++++++++++++++-
4 files changed, 476 insertions(+), 50 deletions(-)
create mode 100644 gui/src/components/chat/markdown/ChatTable.test.tsx
create mode 100644 gui/src/components/chat/markdown/ChatTable.tsx
diff --git a/gui/src/components/chat/markdown/ChatTable.test.tsx b/gui/src/components/chat/markdown/ChatTable.test.tsx
new file mode 100644
index 00000000..40ae9b9d
--- /dev/null
+++ b/gui/src/components/chat/markdown/ChatTable.test.tsx
@@ -0,0 +1,43 @@
+import { test, expect } from "bun:test";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { ChatTable, chatTableLayoutFor } from "./ChatTable";
+import { MarkdownRenderer } from "./MarkdownRenderer";
+
+const header = ["Impact", "Site"];
+const rows = [
+ ["high", "repl.zig:700"],
+ ["medium", "main.zig:14048"],
+];
+
+test("wide layout renders a real table", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("
{
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).not.toContain(" {
+ expect(chatTableLayoutFor(800, 4)).toBe("table");
+ expect(chatTableLayoutFor(300, 4)).toBe("records");
+ expect(chatTableLayoutFor(0, 4)).toBe("table"); // unmeasured → default wide
+});
+
+test("markdown pipe tables flow through ChatTable (SSR default = table)", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain(" 0 && containerPx < columnCount * MIN_COL_PX
+ ? "records"
+ : "table";
+}
+
+interface ChatTableProps {
+ header: ReactNode[];
+ align: TableAlign[];
+ rows: ReactNode[][];
+ /** Test/SSR escape hatch; normally decided by measuring the container. */
+ forceLayout?: ChatTableLayout;
+}
+
+/**
+ * Adaptive chat table: a regular grid table when the pane is wide enough,
+ * collapsing to one record card per row (`Label: value` lines) when it isn't.
+ * Mirrors the TUI renderer's box-drawing table and its narrow record fallback
+ * (src/repl.zig renderTable/renderRecords), so both surfaces degrade the same
+ * way. Chat tiles resize independently of the window (dockview), so the
+ * decision measures the container, not the viewport.
+ */
+export function ChatTable({ header, align, rows, forceLayout }: ChatTableProps) {
+ const containerRef = useRef(null);
+ const [measured, setMeasured] = useState("table");
+ const layout = forceLayout ?? measured;
+
+ useLayoutEffect(() => {
+ if (forceLayout || typeof ResizeObserver === "undefined") return;
+ const el = containerRef.current;
+ if (!el) return;
+ const update = () =>
+ setMeasured(chatTableLayoutFor(el.clientWidth, header.length));
+ update();
+ const observer = new ResizeObserver(update);
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [forceLayout, header.length]);
+
+ const alignClass = (a: TableAlign) =>
+ a === "center" ? "text-center" : a === "right" ? "text-right" : undefined;
+
+ if (layout === "records") {
+ return (
+
+ {rows.map((row, rowIndex) => (
+
+ {row.map((cell, cellIndex) => (
+
+
-
+ {header[cellIndex]}
+
+ - {cell}
+
+ ))}
+
+ ))}
+
+ );
+ }
+
+ return (
+
+
+
+
+ {header.map((cell, index) => (
+ |
+ {cell}
+ |
+ ))}
+
+
+
+ {rows.map((row, rowIndex) => (
+
+ {row.map((cell, cellIndex) => (
+ |
+ {cell}
+ |
+ ))}
+
+ ))}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/gui/src/components/chat/markdown/MarkdownRenderer.tsx b/gui/src/components/chat/markdown/MarkdownRenderer.tsx
index bdd097b8..74cf0bf9 100644
--- a/gui/src/components/chat/markdown/MarkdownRenderer.tsx
+++ b/gui/src/components/chat/markdown/MarkdownRenderer.tsx
@@ -10,10 +10,11 @@ import {
sanitizeChatMarkdownHref,
} from "../utils/chatLinks";
import { openFilePathFromChat, openUrlFromChat } from "../utils/chatOpen";
+import { ChatTable } from "./ChatTable";
import { CodeBlock } from "./CodeBlock";
import { MermaidDiagram } from "./MermaidDiagram";
import { dropRedundantCodeHeadings, parseMarkdown } from "./parser";
-import type { MdBlock, MdInline, MdListItem, TableAlign } from "./types";
+import type { MdBlock, MdInline, MdListItem } from "./types";
const headingClassName = cn("m-0 font-medium text-current", CHAT_BODY_TEXT_CLASS);
@@ -111,50 +112,17 @@ function renderTable(
block: Extract,
workspacePath?: string | null,
): ReactNode {
- const alignClass = (align: TableAlign) =>
- align === "center" ? "text-center" : align === "right" ? "text-right" : undefined;
-
+ const cellNode = (cell: MdInline[]) => (
+
+ {renderInline(cell, workspacePath)}
+
+ );
return (
-
-
-
-
- {block.header.map((cell, index) => (
- |
-
- {renderInline(cell, workspacePath)}
-
- |
- ))}
-
-
-
- {block.rows.map((row, rowIndex) => (
-
- {row.map((cell, cellIndex) => (
- |
-
- {renderInline(cell, workspacePath)}
-
- |
- ))}
-
- ))}
-
-
-
+ row.map(cellNode))}
+ />
);
}
diff --git a/src/repl.zig b/src/repl.zig
index ec748207..5fc70d50 100644
--- a/src/repl.zig
+++ b/src/repl.zig
@@ -229,6 +229,7 @@ pub const Model = struct {
sel_cur_row: ?usize = null,
view_start: usize = 0,
view_rows: usize = 0,
+ last_term_width: usize = 0, // cached each frame for post-hoc markdown layout
visible_text: std.array_list.Managed([]const u8) = undefined, // owned plaintext of visible rows
dump_next: bool = false,
quit_requested: bool = false,
@@ -375,7 +376,7 @@ pub const Model = struct {
}
if (job.result) |r| {
self.chars_out += r.len;
- if (renderMarkdown(self.alloc, r)) |rendered| {
+ if (renderMarkdown(self.alloc, r, self.last_term_width)) |rendered| {
self.alloc.free(r);
self.pushOwned(.assistant, rendered) catch self.alloc.free(rendered);
} else |_| {
@@ -687,6 +688,7 @@ pub const Model = struct {
/// Render at `term_width` x `term_height`; `now_ms` drives animations. Input
/// box is pinned to the bottom; conversation fills from the top.
pub fn render(self: *Model, gpa: std.mem.Allocator, term_width: usize, term_height: usize, now_ms: u64) ![]const u8 {
+ self.last_term_width = term_width;
var arena_state = std.heap.ArenaAllocator.init(gpa);
defer arena_state.deinit();
const a = arena_state.allocator();
@@ -860,17 +862,25 @@ const HELP_CALC =
/// Render a markdown string to ANSI for display — approximates the harness's
/// streamed renderer: fenced code blocks (left bar), inline `code`, **bold**,
-/// # headers, and - bullets. Temporaries live in a local arena; the result is
-/// owned by `gpa`.
-fn renderMarkdown(gpa: std.mem.Allocator, src: []const u8) ![]const u8 {
+/// # headers, - bullets, and | pipe tables (box-drawing, wrapped to fit
+/// `width_hint` columns; 0 = assume 100). Temporaries live in a local arena;
+/// the result is owned by `gpa`.
+fn renderMarkdown(gpa: std.mem.Allocator, src: []const u8, width_hint: usize) ![]const u8 {
var arena_state = std.heap.ArenaAllocator.init(gpa);
defer arena_state.deinit();
const a = arena_state.allocator();
var out = std.array_list.Managed(u8).init(a);
+
+ var line_list = std.array_list.Managed([]const u8).init(a);
+ var it = std.mem.splitScalar(u8, src, '\n');
+ while (it.next()) |l| try line_list.append(l);
+ const lines = line_list.items;
+
var in_fence = false;
- var lines = std.mem.splitScalar(u8, src, '\n');
var first = true;
- while (lines.next()) |line| {
+ var idx: usize = 0;
+ while (idx < lines.len) : (idx += 1) {
+ const line = lines[idx];
if (!first) try out.append('\n');
first = false;
const t = std.mem.trimStart(u8, line, " ");
@@ -886,6 +896,13 @@ fn renderMarkdown(gpa: std.mem.Allocator, src: []const u8) ![]const u8 {
try out.appendSlice(try (zz.Style{}).fg(.cyan).render(a, line));
continue;
}
+ if (isTableRow(t) and idx + 1 < lines.len and isTableSep(std.mem.trim(u8, lines[idx + 1], " \t"))) {
+ var end = idx;
+ while (end < lines.len and isTableRow(std.mem.trimStart(u8, lines[end], " "))) end += 1;
+ try renderTable(&out, a, lines[idx..end], width_hint);
+ idx = end - 1;
+ continue;
+ }
if (std.mem.startsWith(u8, t, "#")) {
var h = t;
while (h.len > 0 and h[0] == '#') h = h[1..];
@@ -902,6 +919,231 @@ fn renderMarkdown(gpa: std.mem.Allocator, src: []const u8) ![]const u8 {
return gpa.dupe(u8, out.items);
}
+fn isTableRow(t: []const u8) bool {
+ return t.len >= 2 and t[0] == '|';
+}
+
+/// `|---|:--:|` style alignment row: pipes/colons/spaces only, dashes required.
+fn isTableSep(t: []const u8) bool {
+ if (t.len == 0 or t[0] != '|') return false;
+ var dash = false;
+ for (t) |c| switch (c) {
+ '|', ':', ' ', '\t' => {},
+ '-' => dash = true,
+ else => return false,
+ };
+ return dash;
+}
+
+/// Inline markdown with `code`/**bold** markers stripped — exactly what
+/// renderInline makes visible, unstyled. Cell layout is computed from this.
+fn plainInline(a: std.mem.Allocator, line: []const u8) ![]const u8 {
+ var out = std.array_list.Managed(u8).init(a);
+ var i: usize = 0;
+ while (i < line.len) {
+ const c = line[i];
+ if (c == '`') {
+ if (std.mem.indexOfScalarPos(u8, line, i + 1, '`')) |end| {
+ try out.appendSlice(line[i + 1 .. end]);
+ i = end + 1;
+ continue;
+ }
+ } else if (c == '*' and i + 1 < line.len and line[i + 1] == '*') {
+ if (std.mem.indexOfPos(u8, line, i + 2, "**")) |end| {
+ try out.appendSlice(line[i + 2 .. end]);
+ i = end + 2;
+ continue;
+ }
+ }
+ try out.append(c);
+ i += 1;
+ }
+ return out.items;
+}
+
+fn dispWidth(s: []const u8) usize {
+ var n: usize = 0;
+ var i: usize = 0;
+ while (i < s.len) {
+ i += std.unicode.utf8ByteSequenceLength(s[i]) catch 1;
+ n += 1;
+ }
+ return n;
+}
+
+/// Word-wrap `s` to `w` display columns; words longer than `w` hard-split at
+/// codepoint boundaries. Never emits leading/trailing spaces on a line.
+fn wrapCell(a: std.mem.Allocator, s: []const u8, w: usize) ![]const []const u8 {
+ var lines = std.array_list.Managed([]const u8).init(a);
+ var cur = std.array_list.Managed(u8).init(a);
+ var cur_w: usize = 0;
+ var words = std.mem.tokenizeScalar(u8, s, ' ');
+ while (words.next()) |word| {
+ var rem = word;
+ while (dispWidth(rem) > w) {
+ if (cur_w > 0) {
+ try lines.append(try a.dupe(u8, cur.items));
+ cur.clearRetainingCapacity();
+ cur_w = 0;
+ }
+ var bytes: usize = 0;
+ var cnt: usize = 0;
+ while (cnt < w and bytes < rem.len) {
+ bytes += std.unicode.utf8ByteSequenceLength(rem[bytes]) catch 1;
+ cnt += 1;
+ }
+ try lines.append(try a.dupe(u8, rem[0..bytes]));
+ rem = rem[bytes..];
+ }
+ const ww = dispWidth(rem);
+ if (ww == 0) continue;
+ if (cur_w > 0 and cur_w + 1 + ww > w) {
+ try lines.append(try a.dupe(u8, cur.items));
+ cur.clearRetainingCapacity();
+ cur_w = 0;
+ }
+ if (cur_w > 0) {
+ try cur.append(' ');
+ cur_w += 1;
+ }
+ try cur.appendSlice(rem);
+ cur_w += ww;
+ }
+ if (cur_w > 0 or lines.items.len == 0) try lines.append(try a.dupe(u8, cur.items));
+ return lines.items;
+}
+
+fn tableRule(out: *std.array_list.Managed(u8), a: std.mem.Allocator, widths: []const usize, l: []const u8, m: []const u8, r: []const u8) !void {
+ var buf = std.array_list.Managed(u8).init(a);
+ try buf.appendSlice(l);
+ for (widths, 0..) |w, i| {
+ for (0..w + 2) |_| try buf.appendSlice("─");
+ try buf.appendSlice(if (i + 1 == widths.len) r else m);
+ }
+ try out.appendSlice(try (zz.Style{}).fg(.brightBlack).render(a, buf.items));
+}
+
+/// Narrow-table fallback: one record per data row — bold `Header: value`
+/// lines with wrapped continuations indented, a brightBlack rule between
+/// records. Used when the box form cannot fit `budget` columns.
+fn renderRecords(out: *std.array_list.Managed(u8), a: std.mem.Allocator, rows: []const []const []const u8, budget: usize) !void {
+ const header = rows[0];
+ var rule_buf = std.array_list.Managed(u8).init(a);
+ const rule_w = @max(@as(usize, 16), @min(budget, 40));
+ for (0..rule_w) |_| try rule_buf.appendSlice("─");
+ var first_line = true;
+ for (rows[1..], 0..) |cells, ri| {
+ if (ri > 0) {
+ try out.append('\n');
+ try out.appendSlice(try (zz.Style{}).fg(.brightBlack).render(a, rule_buf.items));
+ }
+ for (header, 0..) |label, i| {
+ const value = if (i < cells.len) cells[i] else "";
+ const lw = dispWidth(label);
+ const segs = try wrapCell(a, value, @max(@as(usize, 16), budget -| (lw + 2)));
+ if (!first_line) try out.append('\n');
+ first_line = false;
+ try out.appendSlice(try (zz.Style{}).bold(true).render(a, label));
+ try out.appendSlice(": ");
+ if (segs.len > 0) try out.appendSlice(segs[0]);
+ if (segs.len > 1) for (segs[1..]) |seg| {
+ try out.append('\n');
+ try out.appendSlice(" ");
+ try out.appendSlice(seg);
+ };
+ }
+ }
+}
+
+
+/// Render `| a | b |` source rows (row 1 = alignment separator) as a
+/// box-drawing table: bold header, ├─┼─┤ rules between rows, cells
+/// word-wrapped so the whole table fits `width_hint` columns.
+fn renderTable(out: *std.array_list.Managed(u8), a: std.mem.Allocator, raw_rows: []const []const u8, width_hint: usize) !void {
+ var rows = std.array_list.Managed([]const []const u8).init(a);
+ for (raw_rows, 0..) |raw, ri| {
+ if (ri == 1) continue; // alignment separator row
+ var body = std.mem.trim(u8, raw, " \t");
+ if (body.len > 0 and body[0] == '|') body = body[1..];
+ if (body.len > 0 and body[body.len - 1] == '|') body = body[0 .. body.len - 1];
+ var cells = std.array_list.Managed([]const u8).init(a);
+ var cit = std.mem.splitScalar(u8, body, '|');
+ while (cit.next()) |c| try cells.append(try plainInline(a, std.mem.trim(u8, c, " \t")));
+ try rows.append(cells.items);
+ }
+ if (rows.items.len == 0 or rows.items[0].len == 0) return;
+ const ncols = rows.items[0].len;
+
+ const widths = try a.alloc(usize, ncols);
+ @memset(widths, 1);
+ for (rows.items) |cells| {
+ for (cells, 0..) |c, i| {
+ if (i < ncols) widths[i] = @max(widths[i], dispWidth(c));
+ }
+ }
+ const budget = (if (width_hint == 0) @as(usize, 100) else @max(width_hint, 40)) -| 8;
+ const overhead = ncols * 3 + 1;
+ while (true) {
+ var total: usize = overhead;
+ for (widths) |w| total += w;
+ if (total <= budget) break;
+ var wi: usize = 0;
+ var wmax: usize = 0;
+ for (widths, 0..) |w, i| {
+ if (w > wmax) {
+ wmax = w;
+ wi = i;
+ }
+ }
+ if (wmax <= 8) break; // column floor reached — record fallback below
+ widths[wi] = wmax - 1;
+ }
+
+ var total: usize = overhead;
+ for (widths) |w| total += w;
+ if (total > budget and rows.items.len >= 2) {
+ // Even at the column floor the box form overflows the pane — fall back
+ // to one record per row, mirroring the harness's narrow rendering.
+ try renderRecords(out, a, rows.items, budget);
+ return;
+ }
+
+ try tableRule(out, a, widths, "┌", "┬", "┐");
+ for (rows.items, 0..) |cells, ri| {
+ const wrapped = try a.alloc([]const []const u8, ncols);
+ var height: usize = 1;
+ for (0..ncols) |i| {
+ wrapped[i] = try wrapCell(a, if (i < cells.len) cells[i] else "", widths[i]);
+ height = @max(height, wrapped[i].len);
+ }
+ for (0..height) |li| {
+ try out.append('\n');
+ for (0..ncols) |i| {
+ try out.appendSlice(try (zz.Style{}).fg(.brightBlack).render(a, "│"));
+ try out.append(' ');
+ const seg = if (li < wrapped[i].len) wrapped[i][li] else "";
+ if (seg.len > 0) {
+ if (ri == 0) {
+ try out.appendSlice(try (zz.Style{}).bold(true).render(a, seg));
+ } else {
+ try out.appendSlice(seg);
+ }
+ }
+ var p = dispWidth(seg);
+ while (p < widths[i]) : (p += 1) try out.append(' ');
+ try out.append(' ');
+ }
+ try out.appendSlice(try (zz.Style{}).fg(.brightBlack).render(a, "│"));
+ }
+ try out.append('\n');
+ if (ri + 1 == rows.items.len) {
+ try tableRule(out, a, widths, "└", "┴", "┘");
+ } else {
+ try tableRule(out, a, widths, "├", "┼", "┤");
+ }
+ }
+}
+
fn renderInline(out: *std.array_list.Managed(u8), a: std.mem.Allocator, line: []const u8) !void {
var i: usize = 0;
while (i < line.len) {
@@ -1273,3 +1515,55 @@ test "model: empty Enter force-interrupts when a steer line is queued" {
while (m.pending != null and !m.pending.?.done.load(.acquire)) {}
if (m.pending != null) m.finishJob();
}
+
+test "renderMarkdown: pipe table renders as box-drawing" {
+ const gpa = std.testing.allocator;
+ const md = "before\n| Impact | Site |\n|---|---|\n| high | `repl.zig:700` |\n| medium | **main.zig** |\nafter";
+ const out = try renderMarkdown(gpa, md, 80);
+ defer gpa.free(out);
+ try std.testing.expect(std.mem.indexOf(u8, out, "┌") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "┼") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "└") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "repl.zig:700") != null); // backticks stripped in cells
+ try std.testing.expect(std.mem.indexOf(u8, out, "|---") == null); // separator row consumed
+ try std.testing.expect(std.mem.indexOf(u8, out, "before") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "after") != null);
+}
+
+test "renderMarkdown: table cells wrap to the width budget" {
+ const gpa = std.testing.allocator;
+ var arena_state = std.heap.ArenaAllocator.init(gpa);
+ defer arena_state.deinit();
+ const a = arena_state.allocator();
+ const md = "| K | V |\n|---|---|\n| x | this is a very long cell that must wrap across multiple lines to stay inside a narrow table |";
+ const out = try renderMarkdown(gpa, md, 48);
+ defer gpa.free(out);
+ const plain = stripControl(a, out);
+ var it = std.mem.splitScalar(u8, plain, '\n');
+ var cell_rows: usize = 0;
+ while (it.next()) |line| {
+ try std.testing.expect(dispWidth(line) <= 48);
+ if (std.mem.indexOf(u8, line, "│") != null) cell_rows += 1;
+ }
+ try std.testing.expect(cell_rows >= 3); // long cell spans several visual lines
+}
+
+test "renderMarkdown: lone pipe line is not a table" {
+ const gpa = std.testing.allocator;
+ const out = try renderMarkdown(gpa, "| just text with pipes |\nno separator", 80);
+ defer gpa.free(out);
+ try std.testing.expect(std.mem.indexOf(u8, out, "┌") == null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "| just text with pipes |") != null);
+}
+
+test "renderMarkdown: too-wide table falls back to record layout" {
+ const gpa = std.testing.allocator;
+ const md = "| Impact | Site | Finding | Fix safe? |\n|---|---|---|---|\n| high | repl.zig:700 | full scrollback re-styled and re-allocated every single frame | needs care |\n| medium | main.zig:14048 | never-reset session arena grows RSS forever | no |";
+ const out = try renderMarkdown(gpa, md, 44);
+ defer gpa.free(out);
+ try std.testing.expect(std.mem.indexOf(u8, out, "┌") == null); // box form abandoned
+ try std.testing.expect(std.mem.indexOf(u8, out, "Impact") != null); // labels repeated per record
+ try std.testing.expect(std.mem.indexOf(u8, out, "────") != null); // rule between records
+ try std.testing.expect(std.mem.indexOf(u8, out, "repl.zig:700") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "main.zig:14048") != null);
+}