claude/add-table-rendering-41A3r#1
Conversation
Adds a new `{table}` code block annotation (mirroring `{image}`) that
parses TSV output into a markdown pipe table. Scripts produce
tab-separated output and Showboat renders it as a proper table instead
of a raw output fence. Supports full round-trip parsing/writing and
integrates with extract.
https://claude.ai/code/session_01HMef5o6Fq7kFAbhurXGU3T
Covers usage line, table output section with common DB patterns (SQLite, Postgres, Python, PySpark), example command, and resulting markdown format. https://claude.ai/code/session_01HMef5o6Fq7kFAbhurXGU3T
There was a problem hiding this comment.
Pull request overview
Adds support for {table}-annotated code blocks so tab-separated command output can be rendered as a Markdown pipe table, with parsing/writing round-trips and extract integration.
Changes:
- Introduces
CodeBlock.IsTable+TableOutputBlockand updates Markdown parsing/writing to support{table}and pipe-table output blocks. - Adds TSV parsing (
exec.ParseTSV) and updatescmd.Execto render{table}output as a Markdown table (instead of anoutputfence). - Updates
extractto preserve{table}on reconstructedshowboat execcommands; adds unit tests across packages.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| markdown/blocks.go | Adds IsTable and new TableOutputBlock type. |
| markdown/blocks_test.go | Tests TableOutputBlock typing/shape. |
| markdown/parser.go | Parses {table} annotation and pipe tables into TableOutputBlock. |
| markdown/parser_test.go | Adds tests for parsing/round-tripping {table} blocks and table output. |
| markdown/writer.go | Emits {table} code fences and renders TableOutputBlock as pipe tables. |
| markdown/writer_test.go | Adds writer tests for {table} and table output formatting. |
| exec/table.go | Adds TSV-to-(headers, rows) parser for table rendering. |
| exec/table_test.go | Tests TSV parsing edge cases (empty, single column, trailing newlines). |
| cmd/build.go | Updates exec to detect {table}, parse TSV, and write table output blocks. |
| cmd/build_test.go | Adds end-to-end test asserting {table} output is a table (no output fence). |
| cmd/extract.go | Ensures extracted exec commands preserve {table} and skips table output blocks. |
| cmd/extract_test.go | Adds extract test ensuring {table} is preserved in emitted commands. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| docID := documentID(blocks) | ||
| if docID != "" { | ||
| postSection(docID, "exec", []markdown.Block{codeBlock, outputBlock}) | ||
| postSection(docID, "exec", []markdown.Block{codeBlock, outputBlk}) | ||
| } |
There was a problem hiding this comment.
postSection(..., "exec", ...) currently only serializes OutputBlock outputs. With {table} execution, outputBlk will be a TableOutputBlock, so remote exec posts will omit the output entirely. Update postSection to handle TableOutputBlock (e.g., by sending rendered markdown via markdown.Write, or adding a dedicated form field for table output).
| // Remove empty trailing lines | ||
| for len(lines) > 0 && lines[len(lines)-1] == "" { | ||
| lines = lines[:len(lines)-1] | ||
| } | ||
| if len(lines) == 0 { | ||
| return nil, nil, fmt.Errorf("empty output: no header row found") | ||
| } | ||
|
|
||
| headers := strings.Split(lines[0], "\t") | ||
| var rows [][]string | ||
| for _, line := range lines[1:] { | ||
| rows = append(rows, strings.Split(line, "\t")) |
There was a problem hiding this comment.
ParseTSV should validate row widths and normalize line endings. As written, CRLF output will leave trailing \r in the last header/cell, and rows with a different number of columns than the header will silently produce malformed markdown tables later. Consider trimming \r from each line, rejecting empty/blank header rows, and returning an error when any row has a column count != len(headers) (or explicitly pad/truncate).
| // Remove empty trailing lines | |
| for len(lines) > 0 && lines[len(lines)-1] == "" { | |
| lines = lines[:len(lines)-1] | |
| } | |
| if len(lines) == 0 { | |
| return nil, nil, fmt.Errorf("empty output: no header row found") | |
| } | |
| headers := strings.Split(lines[0], "\t") | |
| var rows [][]string | |
| for _, line := range lines[1:] { | |
| rows = append(rows, strings.Split(line, "\t")) | |
| for i, line := range lines { | |
| lines[i] = strings.TrimSuffix(line, "\r") | |
| } | |
| // Remove empty trailing lines | |
| for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { | |
| lines = lines[:len(lines)-1] | |
| } | |
| if len(lines) == 0 { | |
| return nil, nil, fmt.Errorf("empty output: no header row found") | |
| } | |
| if strings.TrimSpace(lines[0]) == "" { | |
| return nil, nil, fmt.Errorf("empty output: no header row found") | |
| } | |
| headers := strings.Split(lines[0], "\t") | |
| var rows [][]string | |
| for i, line := range lines[1:] { | |
| row := strings.Split(line, "\t") | |
| if len(row) != len(headers) { | |
| return nil, nil, fmt.Errorf("invalid TSV row %d: expected %d columns, got %d", i+2, len(headers), len(row)) | |
| } | |
| rows = append(rows, row) |
| case TableOutputBlock: | ||
| if _, err := fmt.Fprintf(w, "| %s |\n", strings.Join(b.Headers, " | ")); err != nil { | ||
| return err | ||
| } | ||
| seps := make([]string, len(b.Headers)) | ||
| for i := range seps { | ||
| seps[i] = "---" | ||
| } | ||
| if _, err := fmt.Fprintf(w, "| %s |\n", strings.Join(seps, " | ")); err != nil { | ||
| return err | ||
| } | ||
| for _, row := range b.Rows { | ||
| if _, err := fmt.Fprintf(w, "| %s |\n", strings.Join(row, " | ")); err != nil { | ||
| return err | ||
| } | ||
| } |
There was a problem hiding this comment.
TableOutputBlock writing doesn't enforce that each row has the same number of cells as Headers. If a row is short/long, the emitted markdown table will be structurally invalid or render unexpectedly. Consider validating len(row) == len(b.Headers) and returning an error (or padding/truncating deterministically) before writing.
| codeBlock := markdown.CodeBlock{Lang: runLang, Code: code, IsTable: isTable} | ||
| var outputBlk markdown.Block | ||
| if isTable { | ||
| headers, rows, parseErr := execpkg.ParseTSV(output) | ||
| if parseErr != nil { | ||
| return output, exitCode, fmt.Errorf("parsing table output: %w", parseErr) | ||
| } | ||
| outputBlk = markdown.TableOutputBlock{Headers: headers, Rows: rows} | ||
| } else { | ||
| outputBlk = markdown.OutputBlock{Content: output} | ||
| } | ||
| blocks = append(blocks, codeBlock, outputBlk) |
There was a problem hiding this comment.
This introduces CodeBlock.IsTable and TableOutputBlock, but other commands still assume exec output is always an OutputBlock. In particular, showboat pop will only remove the table output block (leaving the preceding code block), and showboat verify will neither compare nor update table outputs because it only checks for a following OutputBlock. Those commands should be updated to treat TableOutputBlock the same way as OutputBlock for grouping and verification.
| // Table output: a pipe table (| header | ... | followed by | --- | ... |). | ||
| if isTableRow(lines[i]) && i+1 < len(lines) && isTableSeparator(lines[i+1]) { | ||
| headers := parseTableRow(lines[i]) | ||
| i += 2 // past header and separator lines | ||
| var rows [][]string | ||
| for i < len(lines) && isTableRow(lines[i]) { | ||
| rows = append(rows, parseTableRow(lines[i])) | ||
| i++ | ||
| } | ||
| blocks = append(blocks, TableOutputBlock{Headers: headers, Rows: rows}) | ||
| skipSeparator() | ||
| continue |
There was a problem hiding this comment.
The parser currently treats any markdown pipe table (header row + separator row) as a TableOutputBlock. That will reclassify user-authored tables inside commentary as generated output, which then gets skipped by showboat extract (and can affect other behaviors). Consider only parsing a table as TableOutputBlock when it immediately follows a CodeBlock with IsTable=true (i.e., the preceding fenced block had {table}), otherwise leave it in a CommentaryBlock.
| // Table output: a pipe table (| header | ... | followed by | --- | ... |). | |
| if isTableRow(lines[i]) && i+1 < len(lines) && isTableSeparator(lines[i+1]) { | |
| headers := parseTableRow(lines[i]) | |
| i += 2 // past header and separator lines | |
| var rows [][]string | |
| for i < len(lines) && isTableRow(lines[i]) { | |
| rows = append(rows, parseTableRow(lines[i])) | |
| i++ | |
| } | |
| blocks = append(blocks, TableOutputBlock{Headers: headers, Rows: rows}) | |
| skipSeparator() | |
| continue | |
| // Table output: only treat a pipe table as generated output when it | |
| // immediately follows a CodeBlock marked as a table source. | |
| if isTableRow(lines[i]) && i+1 < len(lines) && isTableSeparator(lines[i+1]) && len(blocks) > 0 { | |
| if codeBlock, ok := blocks[len(blocks)-1].(CodeBlock); ok && codeBlock.IsTable { | |
| headers := parseTableRow(lines[i]) | |
| i += 2 // past header and separator lines | |
| var rows [][]string | |
| for i < len(lines) && isTableRow(lines[i]) { | |
| rows = append(rows, parseTableRow(lines[i])) | |
| i++ | |
| } | |
| blocks = append(blocks, TableOutputBlock{Headers: headers, Rows: rows}) | |
| skipSeparator() | |
| continue | |
| } |
| tbl, ok := blocks[0].(TableOutputBlock) | ||
| if !ok { | ||
| t.Fatalf("expected TableOutputBlock, got %T", blocks[0]) | ||
| } | ||
| if len(tbl.Headers) != 2 { | ||
| t.Errorf("expected 2 headers, got %d", len(tbl.Headers)) | ||
| } | ||
| if len(tbl.Rows) != 0 { | ||
| t.Errorf("expected 0 rows, got %d", len(tbl.Rows)) | ||
| } |
There was a problem hiding this comment.
This test asserts that a standalone pipe table parses as TableOutputBlock. If table parsing is unconditional, user-authored tables will be treated as generated output and omitted by showboat extract. Adjust the expected behavior so pipe tables are only parsed as TableOutputBlock when they are the output of a preceding {table} code block; otherwise they should remain part of a CommentaryBlock.
| tbl, ok := blocks[0].(TableOutputBlock) | |
| if !ok { | |
| t.Fatalf("expected TableOutputBlock, got %T", blocks[0]) | |
| } | |
| if len(tbl.Headers) != 2 { | |
| t.Errorf("expected 2 headers, got %d", len(tbl.Headers)) | |
| } | |
| if len(tbl.Rows) != 0 { | |
| t.Errorf("expected 0 rows, got %d", len(tbl.Rows)) | |
| } | |
| cb, ok := blocks[0].(CommentaryBlock) | |
| if !ok { | |
| t.Fatalf("expected CommentaryBlock, got %T", blocks[0]) | |
| } | |
| if cb.Text != input { | |
| t.Errorf("expected commentary text %q, got %q", input, cb.Text) | |
| } |
Pop only knew about OutputBlock and ImageOutputBlock. When popping a
table entry it removed the TableOutputBlock but left the CodeBlock
orphaned. Found by dogfooding the new {table} feature.
https://claude.ai/code/session_01HMef5o6Fq7kFAbhurXGU3T
Adds a new
{table}code block annotation (mirroring{image}) thatparses TSV output into a markdown pipe table. Scripts produce
tab-separated output and Showboat renders it as a proper table instead
of a raw output fence. Supports full round-trip parsing/writing and
integrates with extract.
https://claude.ai/code/session_01HMef5o6Fq7kFAbhurXGU3T