diff --git a/Screenshot_2026-05-23_195028.png b/Screenshot_2026-05-23_195028.png new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/components/MessageView/index.tsx b/src/ui/components/MessageView/index.tsx index dd0ddc5..093dbc2 100644 --- a/src/ui/components/MessageView/index.tsx +++ b/src/ui/components/MessageView/index.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Box, Text } from "ink"; -import { renderMarkdown } from "./markdown"; +import { renderMarkdown, renderMarkdownSegments } from "./markdown"; import { buildThinkingSummary, buildToolSummary, @@ -66,8 +66,19 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps - - {content ? {renderMarkdown(content)} : null} + + {content + ? renderMarkdownSegments(content, Math.max(20, contentWidth - 4)).map((seg, i) => { + if (seg.kind === "table") { + return ( + + {seg.body} + + ); + } + return {seg.body}; + }) + : null} ); diff --git a/src/ui/components/MessageView/markdown.ts b/src/ui/components/MessageView/markdown.ts index 11fb0ea..8c86534 100644 --- a/src/ui/components/MessageView/markdown.ts +++ b/src/ui/components/MessageView/markdown.ts @@ -1,22 +1,61 @@ import chalk from "chalk"; -export function renderMarkdown(text: string): string { - if (!text) { - return ""; - } +/** + * A rendered piece of markdown. Consumers should use `wrap="truncate-end"` for + * `table` segments and the default wrap mode for `text` segments so that Ink + * never breaks box-drawing lines at cell boundary spaces. + */ +export type MarkdownSegment = + | { kind: "text"; body: string } + | { kind: "table"; body: string } + | { kind: "code"; body: string; lang: string }; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Render markdown to a single string (backward-compatible). */ +export function renderMarkdown(text: string, maxWidth?: number): string { + return renderMarkdownSegments(text, maxWidth) + .map((s) => s.body) + .join(""); +} + +/** Render markdown, returning typed segments so the caller can choose the + right `` per segment. */ +export function renderMarkdownSegments(text: string, maxWidth?: number): MarkdownSegment[] { + if (!text) return []; + const segments: MarkdownSegment[] = []; const fenceSegments = splitByFences(text); - return fenceSegments - .map((segment) => { - if (segment.kind === "code") { - const langTag = segment.lang ? chalk.dim(`[${segment.lang}]`) + "\n" : ""; - return langTag + chalk.cyan(segment.body); + + for (const seg of fenceSegments) { + if (seg.kind === "code") { + const langTag = seg.lang ? chalk.dim(`[${seg.lang}]`) + "\n" : ""; + segments.push({ kind: "code", body: langTag + chalk.cyan(seg.body), lang: seg.lang }); + continue; + } + const blocks = splitTableBlocks(seg.body); + for (const b of blocks) { + if (b.kind === "table") { + segments.push({ kind: "table", body: renderTableBorder(b.rows, maxWidth) }); + } else { + const body = b.body + .split("\n") + .map((line) => renderInlineLine(line)) + .join("\n"); + if (body) segments.push({ kind: "text", body }); } - return renderInlineBlock(segment.body); - }) - .join(""); + } + } + + return segments; } +// --------------------------------------------------------------------------- +// Code fences +// --------------------------------------------------------------------------- + type FenceSegment = { kind: "text"; body: string } | { kind: "code"; lang: string; body: string }; function splitByFences(text: string): FenceSegment[] { @@ -28,35 +67,27 @@ function splitByFences(text: string): FenceSegment[] { let fenceBody: string[] = []; const flushText = () => { - if (buffer.length === 0) { - return; + if (buffer.length > 0) { + segments.push({ kind: "text", body: buffer.join("\n") }); + buffer = []; } - segments.push({ kind: "text", body: buffer.join("\n") }); - buffer = []; }; for (const line of lines) { - const fenceMatch = /^\s*```(\w*)\s*$/.exec(line); - if (fenceMatch) { + const m = /^\s*```(\w*)\s*$/.exec(line); + if (m) { if (!inFence) { flushText(); inFence = true; - fenceLang = fenceMatch[1] ?? ""; + fenceLang = m[1] ?? ""; fenceBody = []; } else { segments.push({ kind: "code", lang: fenceLang, body: fenceBody.join("\n") }); inFence = false; - fenceLang = ""; - fenceBody = []; } continue; } - - if (inFence) { - fenceBody.push(line); - } else { - buffer.push(line); - } + (inFence ? fenceBody : buffer).push(line); } if (inFence) { @@ -68,13 +99,238 @@ function splitByFences(text: string): FenceSegment[] { return segments; } -function renderInlineBlock(text: string): string { - return text - .split("\n") - .map((line) => renderInlineLine(line)) - .join("\n"); +// --------------------------------------------------------------------------- +// Table parsing +// --------------------------------------------------------------------------- + +type TableBlock = { kind: "text"; body: string } | { kind: "table"; rows: string[][] }; + +function splitTableBlocks(text: string): TableBlock[] { + const lines = text.split(/\r?\n/); + const blocks: TableBlock[] = []; + let buffer: string[] = []; + let tableRows: string[][] = []; + let inTable = false; + + const flushText = () => { + if (buffer.length > 0) { + blocks.push({ kind: "text", body: buffer.join("\n") }); + buffer = []; + } + }; + const flushTable = () => { + if (tableRows.length >= 2) { + blocks.push({ kind: "table", rows: tableRows }); + } else if (tableRows.length > 0) { + buffer.push(...tableRows.map((r) => r.join(" | "))); + } + tableRows = []; + }; + + const sepRe = /^\|?\s*:?[-]{3,}:?\s*(\|\s*:?[-]{3,}:?\s*)*\|?\s*$/; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + const nextTrimmed = (lines[i + 1] ?? "").trim(); + + // skip separator line + if (inTable && sepRe.test(trimmed) && tableRows.length === 1) continue; + + const isRow = /^\|.+\|$/.test(trimmed); + const isHeader = isRow && i + 1 < lines.length && sepRe.test(nextTrimmed); + + if (isHeader && !inTable) { + flushText(); + inTable = true; + tableRows = [ + trimmed + .split("|") + .filter(Boolean) + .map((s) => s.trim()), + ]; + continue; + } + + if (isRow && inTable) { + tableRows.push( + trimmed + .split("|") + .filter(Boolean) + .map((s) => s.trim()) + ); + continue; + } + + if (inTable && !isRow) { + flushTable(); + inTable = false; + } + buffer.push(line); + } + + return inTable ? [...blocks, ...flushTableResult(tableRows)] : [...blocks, ...flushTextOnly(buffer, tableRows)]; +} + +function flushTableResult(rows: string[][]): TableBlock[] { + if (rows.length >= 2) return [{ kind: "table", rows }]; + if (rows.length > 0) return [{ kind: "text", body: rows.map((r) => r.join(" | ")).join("\n") }]; + return []; +} + +function flushTextOnly(buffer: string[], tableRows: string[][]): TableBlock[] { + const result: TableBlock[] = []; + if (buffer.length > 0) result.push({ kind: "text", body: buffer.join("\n") }); + if (tableRows.length >= 2) result.push({ kind: "table", rows: tableRows }); + else if (tableRows.length > 0) result.push({ kind: "text", body: tableRows.map((r) => r.join(" | ")).join("\n") }); + return result; +} + +// --------------------------------------------------------------------------- +// Terminal visual width (CJK / emoji = 2 cols, ASCII = 1) +// --------------------------------------------------------------------------- + +function visualWidth(text: string): number { + let w = 0; + for (const ch of text) { + if (ch.length >= 2) { + w += 2; + continue; + } + const code = ch.codePointAt(0) ?? ch.charCodeAt(0); + w += isWideChar(code) ? 2 : 1; + } + return w; +} + +function isWideChar(code: number): boolean { + return ( + (code >= 0x1100 && code <= 0x115f) || // Hangul Jamo + (code >= 0x2329 && code <= 0x232a) || // Misc technical + (code >= 0x2e80 && code <= 0xa4cf) || // CJK Radicals, Kangxi, CJK all + (code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables + (code >= 0xf900 && code <= 0xfaff) || // CJK Compat + (code >= 0xfe10 && code <= 0xfe6f) || // CJK Compat Forms + (code >= 0xff00 && code <= 0xffe6) || // Fullwidth + (code >= 0x20000 && code <= 0x3fffd) || // CJK Ext B+ + (code >= 0x1f300 && code <= 0x1faff) || // Emoji & pictographs + (code >= 0x2600 && code <= 0x27bf) || // Misc Symbols + (code >= 0x2300 && code <= 0x23ff) || // Misc Technical + (code >= 0x2b00 && code <= 0x2bff) || // Misc Symbols & Arrows + (code >= 0x1f000 && code <= 0x1f02f) // Mahjong & Domino + ); +} + +// --------------------------------------------------------------------------- +// Table rendering +// --------------------------------------------------------------------------- + +function renderTableBorder(rows: string[][], maxWidth?: number): string { + if (rows.length === 0) return ""; + + const colCount = rows[0].length; + const calcW = (cs: number[]) => cs.reduce((a, b) => a + b + 2, 0) + cs.length + 1; + + // Ideal widths — longest word / 1.5 so cells can wrap in 2-3 lines + const ideal: number[] = Array.from({ length: colCount }, (_, i) => { + const texts = rows.map((r) => r[i] ?? ""); + const maxLine = Math.max(...texts.map((t) => visualWidth(t))); + const words = texts.flatMap((t) => t.split(/\s+/)); + const maxWord = Math.max(4, ...words.map((w) => visualWidth(w))); + return Math.max(maxWord + 2, Math.ceil(maxLine / 1.5)); + }); + + const colWidths = [...ideal]; + + // Shrink to fit terminal width + if (maxWidth != null && calcW(colWidths) > maxWidth) { + const narrow = new Set([0, 1, colCount - 2, colCount - 1]); // #, status, count, date + const MIN_NARROW = 6; + const MIN_CONTENT = 12; + const contentCols = Array.from({ length: colCount }, (_, i) => i).filter((i) => !narrow.has(i)); + + // Cap narrow columns first + for (const ci of narrow) colWidths[ci] = Math.min(colWidths[ci], MIN_NARROW); + + // Shrink until we fit + while (calcW(colWidths) > maxWidth) { + // Try narrow columns first + let shrunk = false; + for (const ci of narrow) { + if (colWidths[ci] > 4 && calcW(colWidths) > maxWidth) { + colWidths[ci]--; + shrunk = true; + } + } + if (shrunk) continue; + // Then content columns + const widest = contentCols.reduce((a, b) => (colWidths[a] > colWidths[b] ? a : b), contentCols[0]); + if (colWidths[widest] > MIN_CONTENT) colWidths[widest]--; + else break; + } + } + + // Word-wrap a single cell + const wrapCell = (text: string, width: number): string[] => { + if (!text) return [""]; + const lines: string[] = []; + let cur = ""; + const flush = () => { + if (cur.trim()) lines.push(cur.replace(/\s+$/, "")); + cur = ""; + }; + + for (const ch of text) { + const cw = visualWidth(ch); + if (visualWidth(cur) + cw > width) { + const lastSpace = cur.lastIndexOf(" "); + if (lastSpace > width / 3) { + const carry = cur.slice(lastSpace + 1); + cur = cur.slice(0, lastSpace); + flush(); + cur = carry + ch; + } else { + flush(); + cur = ch; + } + } else { + cur += ch; + } + } + if (cur.trim()) lines.push(cur.replace(/\s+$/, "")); + return lines.length > 0 ? lines : [""]; + }; + + const wrapped = rows.map((r) => r.map((c, ci) => wrapCell(c, colWidths[ci]))); + const heights = wrapped.map((wr) => Math.max(1, ...wr.map((lines) => lines.length))); + + const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - visualWidth(s))); + + const top = "┌" + colWidths.map((w) => "─".repeat(w + 2)).join("┬") + "┐"; + const hdr = "├" + colWidths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"; + const sep = "├" + colWidths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"; + const bot = "└" + colWidths.map((w) => "─".repeat(w + 2)).join("┴") + "┘"; + + const out: string[] = [top]; + + for (let ri = 0; ri < wrapped.length; ri++) { + const h = heights[ri]; + for (let li = 0; li < h; li++) { + const line = wrapped[ri].map((cellLines, ci) => " " + pad(cellLines[li] ?? "", colWidths[ci]) + " "); + out.push("│" + line.join("│") + "│"); + } + if (ri === 0 && rows.length > 1) out.push(hdr); + else if (ri < rows.length - 1) out.push(sep); + } + + out.push(bot); + return out.join("\n"); } +// --------------------------------------------------------------------------- +// Inline formatting (headings, lists, quotes, bold/italic/code) +// --------------------------------------------------------------------------- + function renderInlineLine(line: string): string { const headingMatch = /^(\s*)(#{1,6})\s+(.*)$/.exec(line); if (headingMatch) { @@ -105,9 +361,7 @@ function renderInlineLine(line: string): string { } function renderInlineSpans(text: string): string { - if (!text) { - return text; - } + if (!text) return text; let result = text; result = result.replace(/`([^`]+)`/g, (_, inner) => chalk.cyan(inner)); result = result.replace(/\*\*([^*]+)\*\*/g, (_, inner) => chalk.bold(inner)); diff --git a/src/ui/index.ts b/src/ui/index.ts index d899d4b..1348903 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -54,7 +54,7 @@ export { } from "./askUserQuestion"; export { readClipboardImage, type ClipboardImage } from "./clipboard"; export { buildLoadingText, type LoadingTextInput } from "./loadingText"; -export { renderMarkdown } from "./components/MessageView/markdown"; +export { renderMarkdown, renderMarkdownSegments, type MarkdownSegment } from "./components/MessageView/markdown"; export { EMPTY_BUFFER, insertText,