sqlite: also emit an ASCII table so the TUI shows columns
The OpenCode TUI renders tool output through a literal <text> node (packages/tui/.../session/index.tsx, GenericTool), so a GFM table becomes a wall of pipes. The web/desktop client passes the same output through the markdown renderer and draws a proper grid, so a GFM table is the most compact form there. The previous fix emitted a markdown table and assumed the TUI would render it. It does not. The fix: ship both. The ASCII table is space- padded, word-wraps long cells, and stays aligned even when the TUI shows only its collapsed 3-line preview; the markdown table follows and is what the web/desktop client renders with a proper grid. - New rowsToAsciiTable: cellToAscii, truncateForCell, wrapCell, visibleWidth, padRight. NULL -> <null>, BLOB -> <BLOB nB>, Date -> ISO, BigInt -> string. Cell width capped at 40 chars by default (configurable). Single words longer than the cap are hard-wrapped. - sqlite_query's format=table path now emits the ASCII table first and the markdown table second, with a blank line between. - README explains why the duplication and how to read both. End-to-end verified via Code Mode (no model in the loop) after opencode service restart picked up the new transform.
This commit is contained in:
@@ -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
|
The model should be told (or already knows) that this prefix means "the
|
||||||
operation failed; do not pretend it succeeded."
|
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
|
||||||
|
`<text>` 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
|
||||||
|
`<text>` 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
|
## File layout
|
||||||
|
|
||||||
This plugin is a single directory:
|
This plugin is a single directory:
|
||||||
|
|||||||
@@ -129,6 +129,131 @@ function rowsToMarkdownTable(columns: string[], rows: Record<string, unknown>[])
|
|||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render the same data as a plain ASCII monospace table.
|
||||||
|
*
|
||||||
|
* OpenCode's TUI renders tool `content` through a literal `<text>` 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 `<null>`. 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<string, unknown>[], 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 → "<null>". */
|
||||||
|
function cellToAscii(value: unknown, maxCell: number): string {
|
||||||
|
if (value === null || value === undefined) return "<null>"
|
||||||
|
if (value instanceof Buffer) return `<BLOB ${value.length}B>`
|
||||||
|
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 `<BLOB ${obj.__bytes__ ?? 0}B>`
|
||||||
|
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 <text> 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
|
/** 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
|
* user can see what the model actually executed, including the bound
|
||||||
* values. Long SQL is truncated with an ellipsis at a word boundary. */
|
* values. Long SQL is truncated with an ellipsis at a word boundary. */
|
||||||
@@ -822,6 +947,19 @@ export default Plugin.define({
|
|||||||
` Bytes used: ${bytes}.`,
|
` Bytes used: ${bytes}.`,
|
||||||
)
|
)
|
||||||
if (format === "table" || format === "both") {
|
if (format === "table" || format === "both") {
|
||||||
|
// The OpenCode TUI renders tool output through a literal
|
||||||
|
// <text> 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("")
|
||||||
out.push(rowsToMarkdownTable(columns, rows))
|
out.push(rowsToMarkdownTable(columns, rows))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user