From 9d3960324ee2620361c563e3fb2462034dccbb55 Mon Sep 17 00:00:00 2001 From: akemmanuel Date: Thu, 25 Dec 2025 13:39:37 -0300 Subject: [PATCH] feat: add built-in markdown table formatting --- packages/opencode/src/format/text.ts | 219 +++++++++++++++++++++ packages/opencode/test/format/text.test.ts | 129 ++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 packages/opencode/src/format/text.ts create mode 100644 packages/opencode/test/format/text.test.ts diff --git a/packages/opencode/src/format/text.ts b/packages/opencode/src/format/text.ts new file mode 100644 index 00000000000..ba8d95927f9 --- /dev/null +++ b/packages/opencode/src/format/text.ts @@ -0,0 +1,219 @@ +export namespace TextFormat { + // Width cache for performance optimization + const widthCache = new Map() + let cacheOperationCount = 0 + + /** + * Formats markdown tables in the given text + */ + export function formatMarkdownTables(text: string): string { + const lines = text.split("\n") + const result: string[] = [] + let i = 0 + + while (i < lines.length) { + const line = lines[i] + + if (isTableRow(line)) { + const tableLines: string[] = [line] + i++ + + while (i < lines.length && isTableRow(lines[i])) { + tableLines.push(lines[i]) + i++ + } + + if (isValidTable(tableLines)) { + result.push(...formatTable(tableLines)) + } else { + result.push(...tableLines) + result.push("") + } + } else { + result.push(line) + i++ + } + } + + incrementOperationCount() + return result.join("\n") + } + + function isTableRow(line: string): boolean { + const trimmed = line.trim() + return trimmed.startsWith("|") && trimmed.endsWith("|") && trimmed.split("|").length > 2 + } + + function isSeparatorRow(line: string): boolean { + const trimmed = line.trim() + if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) return false + const cells = trimmed.split("|").slice(1, -1) + return cells.length > 0 && cells.every((cell) => /^\s*:?-+:?\s*$/.test(cell)) + } + + function isValidTable(lines: string[]): boolean { + if (lines.length < 2) return false + + const rows = lines.map((line) => + line + .split("|") + .slice(1, -1) + .map((cell) => cell.trim()), + ) + + if (rows.length === 0 || rows[0].length === 0) return false + + const firstRowCellCount = rows[0].length + const allSameColumnCount = rows.every((row) => row.length === firstRowCellCount) + if (!allSameColumnCount) return false + + const hasSeparator = lines.some((line) => isSeparatorRow(line)) + return hasSeparator + } + + function formatTable(lines: string[]): string[] { + const separatorIndices = new Set() + for (let i = 0; i < lines.length; i++) { + if (isSeparatorRow(lines[i])) separatorIndices.add(i) + } + + const rows = lines.map((line) => + line + .split("|") + .slice(1, -1) + .map((cell) => cell.trim()), + ) + + if (rows.length === 0) return lines + + const colCount = Math.max(...rows.map((row) => row.length)) + + const colAlignments: Array<"left" | "center" | "right"> = Array(colCount).fill("left") + for (const rowIndex of separatorIndices) { + const row = rows[rowIndex] + for (let col = 0; col < row.length; col++) { + colAlignments[col] = getAlignment(row[col]) + } + } + + const colWidths: number[] = Array(colCount).fill(3) + for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { + if (separatorIndices.has(rowIndex)) continue + const row = rows[rowIndex] + for (let col = 0; col < row.length; col++) { + const displayWidth = calculateDisplayWidth(row[col]) + colWidths[col] = Math.max(colWidths[col], displayWidth) + } + } + + return rows.map((row, rowIndex) => { + const cells: string[] = [] + for (let col = 0; col < colCount; col++) { + const cell = row[col] ?? "" + const align = colAlignments[col] + + if (separatorIndices.has(rowIndex)) { + cells.push(formatSeparatorCell(colWidths[col], align)) + } else { + cells.push(padCell(cell, colWidths[col], align)) + } + } + return "| " + cells.join(" | ") + " |" + }) + } + + function getAlignment(delimiterCell: string): "left" | "center" | "right" { + const trimmed = delimiterCell.trim() + const hasLeftColon = trimmed.startsWith(":") + const hasRightColon = trimmed.endsWith(":") + + if (hasLeftColon && hasRightColon) return "center" + if (hasRightColon) return "right" + return "left" + } + + function calculateDisplayWidth(text: string): number { + if (widthCache.has(text)) { + return widthCache.get(text)! + } + + const width = getStringWidth(text) + widthCache.set(text, width) + return width + } + + function getStringWidth(text: string): number { + // Strip markdown symbols for concealment mode + // Users with concealment ON don't see **, *, ~~, ` but DO see markdown inside `code` + + // CRITICAL: Content inside backticks should PRESERVE inner markdown symbols + // because concealment treats them as literal text, not markdown + + // Step 1: Extract and protect inline code content + const codeBlocks: string[] = [] + let textWithPlaceholders = text.replace(/`(.+?)`/g, (_match, content) => { + codeBlocks.push(content) + return `\x00CODE${codeBlocks.length - 1}\x00` + }) + + // Step 2: Strip markdown from non-code parts + let visualText = textWithPlaceholders + let previousText = "" + + while (visualText !== previousText) { + previousText = visualText + visualText = visualText + .replace(/\*\*\*(.+?)\*\*\*/g, "$1") // ***bold+italic*** -> text + .replace(/\*\*(.+?)\*\*/g, "$1") // **bold** -> bold + .replace(/\*(.+?)\*/g, "$1") // *italic* -> italic + .replace(/~~(.+?)~~/g, "$1") // ~~strike~~ -> strike + .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "$1") // ![alt](url) -> alt (OpenTUI shows only alt text) + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)") // [text](url) -> text (url) + } + + // Step 3: Restore code content (with its original markdown preserved) + visualText = visualText.replace(/\x00CODE(\d+)\x00/g, (_match, index) => { + return codeBlocks[parseInt(index)] + }) + + // Use Bun's stringWidth if available, otherwise fallback to simple length + if (typeof Bun !== "undefined" && Bun.stringWidth) { + return Bun.stringWidth(visualText) + } + return visualText.length + } + + function padCell(text: string, width: number, align: "left" | "center" | "right"): string { + const displayWidth = calculateDisplayWidth(text) + const totalPadding = Math.max(0, width - displayWidth) + + if (align === "center") { + const leftPad = Math.floor(totalPadding / 2) + const rightPad = totalPadding - leftPad + return " ".repeat(leftPad) + text + " ".repeat(rightPad) + } else if (align === "right") { + return " ".repeat(totalPadding) + text + } else { + return text + " ".repeat(totalPadding) + } + } + + function formatSeparatorCell(width: number, align: "left" | "center" | "right"): string { + if (align === "center") return ":" + "-".repeat(Math.max(1, width - 2)) + ":" + if (align === "right") return "-".repeat(Math.max(1, width - 1)) + ":" + return "-".repeat(width) + } + + function incrementOperationCount() { + cacheOperationCount++ + + if (cacheOperationCount > 100 || widthCache.size > 1000) { + cleanupCache() + } + } + + function cleanupCache() { + widthCache.clear() + cacheOperationCount = 0 + } +} diff --git a/packages/opencode/test/format/text.test.ts b/packages/opencode/test/format/text.test.ts new file mode 100644 index 00000000000..86d21d909d7 --- /dev/null +++ b/packages/opencode/test/format/text.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "bun:test" +import { TextFormat } from "@/format/text" + +describe("TextFormat", () => { + describe("formatMarkdownTables", () => { + it("should format a simple table", () => { + const input = ` +| Name | Age | City | +|------|-----|------| +| John | 30 | NYC | +| Jane | 25 | LA | +` + const result = TextFormat.formatMarkdownTables(input) + expect(result).toContain("| Name | Age | City |") + expect(result).toContain("| John | 30 | NYC |") + expect(result).toContain("| Jane | 25 | LA |") + }) + + it("should format a table with alignment", () => { + const input = ` +| Left | Center | Right | +|:-----|:------:|------:| +| L1 | C1 | R1 | +| L2 | C2 | R2 | +` + const result = TextFormat.formatMarkdownTables(input) + expect(result).toContain("| Left | Center | Right |") + expect(result).toContain("| ---- | :----: | ----: |") + expect(result).toContain("| L1 | C1 | R1 |") + }) + + it("should not modify non-table content", () => { + const input = ` +Some text + +| A | B | +|---|---| +| 1 | 2 | + +More text +` + const result = TextFormat.formatMarkdownTables(input) + expect(result).toContain("Some text") + expect(result).toContain("More text") + expect(result).toContain("| A | B |") + }) + + it("should format a table with alignment", () => { + const input = ` +| Left | Center | Right | +|:-----|:------:|------:| +| L1 | C1 | R1 | +| L2 | C2 | R2 | +` + const result = TextFormat.formatMarkdownTables(input) + expect(result).toContain("| Left | Center | Right |") + expect(result).toContain("| ---- | :----: | ----: |") + expect(result).toContain("| L1 | C1 | R1 |") + }) + + it("should not modify non-table content", () => { + const input = ` +Some text + +| A | B | +|---|---| +| 1 | 2 | + +More text +` + const result = TextFormat.formatMarkdownTables(input) + expect(result).toContain("Some text") + expect(result).toContain("More text") + expect(result).toContain("| A | B |") + }) + + it("should not modify non-table content", () => { + const input = ` +Some text + +| A | B | +|---|---| +| 1 | 2 | + +More text +` + const result = TextFormat.formatMarkdownTables(input) + expect(result).toContain("Some text") + expect(result).toContain("More text") + expect(result).toContain("| A | B |") + }) + + it("should handle invalid tables gracefully", () => { + const input = ` +| A | B | +|---| +| 1 | 2 | +` + const result = TextFormat.formatMarkdownTables(input) + expect(result).toContain("") + }) + + it("should handle tables with markdown in cells", () => { + const input = ` +| Name | Description | +|------|-------------| +| **Bold** | *Italic* | +| code | normal | +` + const result = TextFormat.formatMarkdownTables(input) + expect(result).toContain("**Bold**") + expect(result).toContain("*Italic*") + expect(result).toContain("code") + }) + + it("should format a table with emojis", () => { + const input = ` +| Item | Status | +|------|--------| +| ✅ | Done | +| 🚧 | Work | +` + const result = TextFormat.formatMarkdownTables(input) + expect(result).toContain("✅") + expect(result).toContain("🚧") + expect(result).toContain("| Item | Status |") + }) + }) +})