Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions packages/opencode/src/format/text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
export namespace TextFormat {
// Width cache for performance optimization
const widthCache = new Map<string, number>()
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("<!-- table not formatted: invalid structure -->")
}
} 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<number>()
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
}
}
129 changes: 129 additions & 0 deletions packages/opencode/test/format/text.test.ts
Original file line number Diff line number Diff line change
@@ -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("<!-- table not formatted: invalid structure -->")
})

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 |")
})
})
})