diff --git a/plugins/sqlite/README.md b/plugins/sqlite/README.md index 27816bc..ad58347 100644 --- a/plugins/sqlite/README.md +++ b/plugins/sqlite/README.md @@ -220,6 +220,53 @@ Error: SQLite error SQLITE_CONSTRAINT_UNIQUE: UNIQUE constraint failed: users.em The model should be told (or already knows) that this prefix means "the operation failed; do not pretend it succeeded." +### Output on the TUI vs the web client + +The OpenCode web and desktop clients pass tool output through the +markdown renderer, but the TUI renders tool output as a literal +`` node (see `packages/tui/.../session/index.tsx` — the +`GenericTool` component). A GFM table in the TUI therefore shows up as a +wall of `|` characters and is hard to scan in the TUI's collapsed +3-line preview. + +To make the data legible in both clients, `sqlite_query` with +`format: "table"` (the default) ships the result twice: + +1. **First, a plain ASCII table** with space-padded columns. The TUI's + `` shows this as a real aligned table; web/desktop show the + same lines too (it is plain text, not markdown). +2. **Then, a fenced GFM table** in markdown form. The web/desktop + clients render this with a proper grid; the TUI ignores the markdown + syntax and shows the pipes as-is. Discard visually in the TUI; it is + still parseable markdown for any downstream tool that reads the + transcript. + +So a `SELECT * FROM users` in the TUI looks like: + +```text +SQL: SELECT * FROM users ORDER BY id params: [] +Returned 3 row(s). Bytes used: 96. + +id name age +-- ------- --- +1 Alice 30 +2 Bob 25 +3 Charlie 35 + +| id | name | age | +| --- | --- | --- | +| 1 | Alice | 30 | +| ... +``` + +The same call in the web client draws the second block as a bordered +grid and the first block as preformatted text. + +**TUI visibility tip:** the TUI also hides generic tool output by +default (`generic_tool_output_visibility` is `false`). Toggle it on with +the `session.toggle.generic_tool_output` keybind if you do not see the +output at all. + ## File layout This plugin is a single directory: diff --git a/plugins/sqlite/index.ts b/plugins/sqlite/index.ts index fd1ba86..b5c9f26 100644 --- a/plugins/sqlite/index.ts +++ b/plugins/sqlite/index.ts @@ -129,6 +129,131 @@ function rowsToMarkdownTable(columns: string[], rows: Record[]) ].join("\n") } +/** Render the same data as a plain ASCII monospace table. + * + * OpenCode's TUI renders tool `content` through a literal `` node, + * not the markdown renderer, so the GFM table above becomes a wall of + * pipes. Web/desktop, on the other hand, do render markdown and will + * draw the proper grid. To make the data legible in *both* clients we + * ship both: a fenced ascii block the TUI can show in its collapsed + * 3-line preview, and a fenced markdown block the web client turns + * into a proper table. + * + * Layout: every cell padded to the column's max width. `…` is appended + * to truncated cells. NULLs become ``. Long values are wrapped + * word-wise across multiple lines so a wide cell does not shove the + * rest of the row off the screen. */ +function rowsToAsciiTable(columns: string[], rows: Record[], maxCell = 40): string { + if (rows.length === 0) return "(empty result set)" + + // First, coerce every value to its single-line cell representation. + const allRows: string[][] = [columns.map((c) => truncateForCell(c, maxCell))] + for (const row of rows) { + allRows.push(columns.map((col) => cellToAscii(row[col], maxCell))) + } + + // Wrap long cells word-wise and pad every resulting sub-row to the + // column's max width, so the columns line up vertically. + const wrapped: string[][] = allRows.map((row) => { + const cellLines = row.map((cell) => wrapCell(cell, maxCell)) + const height = Math.max(...cellLines.map((lines) => lines.length)) + return cellLines.map((lines) => { + const padded = [...lines] + while (padded.length < height) padded.push("") + return padded + }) + }) + + // Column widths: the longest sub-line across all wrapped sub-rows. + const widths: number[] = columns.map((_, colIndex) => + Math.max(...wrapped.map((row) => Math.max(...row[colIndex].map((line) => visibleWidth(line))))), + ) + + const renderRow = (row: string[][]): string[] => { + const height = row[0]!.length + const lines: string[] = [] + for (let lineIdx = 0; lineIdx < height; lineIdx++) { + const cells = row.map((cell, i) => padRight(cell[lineIdx]!, widths[i]!)) + lines.push(cells.join(" ")) + } + return lines + } + + const headerLines = renderRow(wrapped[0]!) + const separator = widths.map((w) => "-".repeat(w)).join(" ") + const bodyLines = wrapped.slice(1).flatMap((row) => renderRow(row)) + return [...headerLines, separator, ...bodyLines].join("\n") +} + +/** Coerce a SQLite value to a one-line ASCII cell. NULL → "". */ +function cellToAscii(value: unknown, maxCell: number): string { + if (value === null || value === undefined) return "" + if (value instanceof Buffer) return `` + if (value instanceof Date) return value.toISOString() + if (typeof value === "bigint") return value.toString() + if (typeof value === "object" && value !== null) { + const obj = value as { __b64__?: string; __bytes__?: number; __bigint__?: string } + if (typeof obj.__b64__ === "string") return `` + if (typeof obj.__bigint__ === "string") return obj.__bigint__ + return JSON.stringify(value) + } + return String(value) +} + +/** Single-line, length-capped representation suitable for ascii cells. */ +function truncateForCell(text: string, maxCell: number): string { + const oneLine = text.replace(/\r?\n/g, " ") + if (oneLine.length > maxCell) return oneLine.slice(0, maxCell - 1) + "…" + return oneLine +} + +/** Wrap a long string at word boundaries into lines no longer than maxCell. */ +function wrapCell(text: string, maxCell: number): string[] { + const oneLine = text.replace(/\r?\n/g, " ").trim() + if (oneLine.length <= maxCell) return [oneLine] + const words = oneLine.split(/\s+/) + const out: string[] = [] + let buf = "" + for (const word of words) { + if (!buf) { + buf = word + continue + } + if (buf.length + 1 + word.length <= maxCell) { + buf += " " + word + } else { + out.push(buf) + buf = word + } + } + if (buf) out.push(buf) + // Hard-wrap any single word that exceeded maxCell on its own. + const result: string[] = [] + for (const line of out) { + if (line.length <= maxCell) { + result.push(line) + } else { + for (let i = 0; i < line.length; i += maxCell) { + result.push(line.slice(i, i + maxCell)) + } + } + } + return result +} + +/** Visible width — ASCII; wide CJK / emoji chars are over-counted but the + * TUI's uses a monospace font so this matches the visual. */ +function visibleWidth(text: string): number { + return [...text].length +} + +/** Pad with trailing spaces. */ +function padRight(text: string, width: number): string { + const w = visibleWidth(text) + if (w >= width) return text + return text + " ".repeat(width - w) +} + /** Build a one-line "SQL + params" echo. The TUI shows the line so the * user can see what the model actually executed, including the bound * values. Long SQL is truncated with an ellipsis at a word boundary. */ @@ -822,6 +947,19 @@ export default Plugin.define({ ` Bytes used: ${bytes}.`, ) if (format === "table" || format === "both") { + // The OpenCode TUI renders tool output through a literal + // node (see packages/tui/.../session/index.tsx), so a + // GFM table becomes a wall of pipes in the TUI's collapsed + // preview and is hard to scan. The web/desktop client does + // pass tool output through the markdown renderer, so a GFM + // table is also the most compact, structured form there. + // + // We ship both: the ASCII table first, so the TUI's first + // three lines already show the column headers and the + // separator; the markdown table comes after so the web + // client draws it with a proper grid. + out.push("") + out.push(rowsToAsciiTable(columns, rows)) out.push("") out.push(rowsToMarkdownTable(columns, rows)) }