Skip to content

feat: add Go table output compatible with the legacy CLI, with browser-like column widths - #196

Open
pjcdawkins wants to merge 9 commits into
mainfrom
claude/cli-192-400e33
Open

pjcdawkins wants to merge 9 commits into
mainfrom
claude/cli-192-400e33

Conversation

@pjcdawkins

@pjcdawkins pjcdawkins commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Adds internal/table, a Go equivalent of the legacy Table service, as a building block for moving list/info commands to Go (starting with auth:info). Also changes how the legacy AdaptiveTable picks column widths, so that both CLIs wrap tables the same way.

Go table package

  • Table.AddFlags registers --format, --columns/-c and --no-header with the legacy descriptions. -c is skipped if the shorthand is taken.
  • Column selection supports repeated or comma/whitespace-separated values, %/* wildcards and + for the default columns, with the legacy Column not found and Invalid format errors.
  • csv, tsv and plain output matches the legacy PHP Table byte for byte (compared by running both on the same data), with ANSI escape sequences removed.
  • The table format uses the Symfony default border style and wraps cells to the terminal width. Headers and NoWrap columns aren't wrapped.
  • ANSI-styled cells are measured with charmbracelet/x/ansi. Styles and OSC 8 hyperlinks still open at the end of a cell's line are closed and re-opened on the next line, so they don't leak into borders or other cells.
  • RenderProperties covers the legacy renderSimple (Property/Value table).

Column widths (PHP and Go)

The legacy AdaptiveTable gave every column a fixed 10-character minimum, so it cut words even when the table could fit. Both sides now work more like a browser's automatic table layout:

  • A column's minimum width is its longest word plus any indentation, capped at 20. If the minimums don't fit, the cap is reduced down to 10.
  • Space above the minimums is shared in proportion to each column's maximum minus minimum width, with integer arithmetic so PHP and Go agree.
  • Go wraps with a port of PHP's wordwrap() (measuring display width and keeping ANSI sequences). It matched PHP on 100,000 random inputs, and the Go and PHP tables were identical in 410 generated cases.

This changes the wrapped table output of legacy commands. Two integration tests with wrapped tables at 120 columns were updated.

Not included: value formatting (PropertyFormatter, --date-fmt), table separator rows, and the deprecated-column helpers.

🤖 Generated with Claude Code

pjcdawkins and others added 4 commits October 1, 2026 22:37
Add internal/table, a Go equivalent of the legacy Table service, as a
building block for moving list/info commands to Go.

- AddFlags registers --format, --columns/-c and --no-header with the
  same descriptions as the legacy CLI.
- Column selection supports repeated or comma/whitespace-separated
  values, % and * wildcards, and "+" for the default columns, with the
  same "Column not found" and "Invalid format" errors.
- The csv, tsv and plain formats match the legacy output (checked
  against the PHP Table class), including quoting and LF line breaks.
- The table format uses the Symfony default border style, without the
  legacy column wrapping.
- RenderProperties covers the legacy renderSimple (Property/Value).

Value formatting (PropertyFormatter, --date-fmt) is left to callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…styles

Port the legacy AdaptiveTable column-width algorithm: columns get a
share of the terminal width in proportion to their natural width, but
never less than 10 characters (or their own width). Headers and
columns marked NoWrap are not wrapped. The width comes from $COLUMNS,
then the first standard stream that is a terminal, then 80.

Measure and wrap cells with charmbracelet/x/ansi (replacing
go-runewidth), so ANSI-styled text is aligned correctly. A style that
is still active at the end of a cell's line is reset there and
re-applied on the next line, so it does not leak into the borders or
other cells. The csv, tsv and plain formats strip ANSI sequences.

Wrapping matches the legacy output except that ansi.Wrap also breaks
after hyphens and drops leading spaces on wrapped lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A zero in an extended color (e.g. 38;2;0;255;0) was taken as a reset,
dropping other active styles on continuation lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@upsun-dispatch upsun-dispatch Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Changes suggested — 🟡 2 warnings · ⚪ 1 nitpick

🔍 Full review · 6 files reviewed

⚪ Nitpick

  • internal/table/table.go:110 — When two columns share a lower-cased name, indexes keeps the first column (if _, ok := indexes[name]; !ok). The legacy availableColumns overwrites with the last key ($availableColumns[strtolower($columnName)] = $key). Selecting that name therefore shows a different column's data than the legacy CLI does, which contradicts the parity goal.
Verification
  • csvCell quotes on ", \n and the delimiter before normalizing \R breaks, the same order as legacy Csv::formatCell with an LF line break.
  • plainCell uses the same [\r\n\t]+ → space replacement as legacy PlainFormat::formatCell.
  • splitColumns applies the + separation regexes only when exactly one value is given, matching legacy specifiedColumns.
  • wrapCell adds the leading indent to every wrapped line, matching legacy AdaptiveTable's preg_replace('/^/m', $indent, ...).
  • sgrResets skips the 2 or 4 sub-parameters of 38/48/58 extended colors, so 38;5;0 and 38;2;0;0;0 are not treated as resets.

The new internal/table/table_test.go covers the formats, column selection, wrapping and style isolation. Nothing covers DefaultColumns names that match no column, duplicate column names, or non-SGR escape sequences. I wrote a throwaway test against the package to reproduce the first two findings; the repository's own test target (make test / go test ./...) runs the new tests.

Review details
  • Commit: a8d8b2b
  • Model: claude-opus-5-5

Review 1 of 10 for this pull request · View the full run

Comment thread internal/table/table.go Outdated
Comment thread internal/table/adaptive.go
- A default column that matches no column now gives empty cells, as in
  the legacy CLI, instead of showing the first column's data.
- A duplicate column name refers to the last column with that name,
  like the legacy availableColumns().
- OSC 8 hyperlinks still open at the end of a cell's line are closed
  there and re-opened on the next line, like SGR styles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@upsun-dispatch
upsun-dispatch Bot dismissed their stale review October 2, 2026 00:20

Superseded: the latest Upsun Dispatch review no longer requests changes.

pjcdawkins and others added 3 commits October 2, 2026 01:32
Change the AdaptiveTable column-width algorithm, in both the legacy PHP
CLI and the Go table package, to be closer to a browser's automatic
table layout:

- A column's minimum width is its longest word, capped at 20 instead
  of a fixed 10, so words are not cut when the table can fit. If the
  minimums don't fit, the cap is reduced down to 10.
- Space above the minimums is shared in proportion to each column's
  maximum minus minimum width, using integer arithmetic so that PHP
  and Go give the same widths.

In Go, replace ansi.Wrap with a port of PHP's wordwrap() (measuring
display width and keeping ANSI sequences), so wrapping matches the
legacy output and doesn't break at hyphens. It matched PHP on 100,000
random ASCII inputs, and table output matched PHP's AdaptiveTable in
309 of 313 generated tables (the rest had duplicate column names).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wrap boundaries

- Indentation is repeated on each wrapped line, so add it to a cell's
  minimum width. PHP's wordwrap() threw a ValueError when the width
  left after the indentation was zero.
- Zero-width ANSI sequences no longer trigger a break in the Go
  wordwrap(), which produced extra lines for styled text that exactly
  filled the width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pjcdawkins pjcdawkins changed the title feat: add Go table output compatible with the legacy CLI feat: add Go table output compatible with the legacy CLI, with browser-like column widths Oct 2, 2026
@upsun-dispatch

upsun-dispatch Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

📋 PR Summary

This PR adds internal/table, a Go version of the legacy PHP Table service. It supports --format, --columns and --no-header, and its csv/tsv/plain/table output matches the legacy CLI. It also changes how both the PHP AdaptiveTable and the Go table pick column widths: they now work more like a browser's automatic table layout, so the two CLIs wrap tables the same way. The latest push caps a cell's minimum width at the cell's own width, so an indented first line followed by a longer unindented line can't make the column wider than its content. It also guards against an out-of-range index in the Go noWrap lookup.

Changes
Layer / File(s) Summary
Go table package
internal/table/table.go Adds the Table type with flag registration, column selection (wildcards, + for the default columns) and the csv/tsv/plain formats.
internal/table/render.go Renders the table format with Symfony-style borders, keeps ANSI styles and OSC 8 links intact across wrapped lines, and adds RenderProperties for Property/Value tables.
internal/table/adaptive.go Computes adaptive column widths and wraps cells with a port of PHP's wordwrap(). A cell's minimum width is now capped at its own width, and the noWrap lookup is bounds-checked.
internal/table/table_test.go Tests flags, formats, width allocation and wordwrap. Adds a test for multiline indented cells that checks the output stays within the maximum width.
Legacy column widths
legacy/src/Console/AdaptiveTable.php Replaces the fixed 10-character minimum with minimums based on each column's longest word (capped at 20, reduced down to 10 if needed) and shares the remaining space proportionally. Each cell's word width is now capped at the cell's width.
legacy/tests/Console/AdaptiveTableTest.php Updates the width tests and adds a test for a multiline indented cell.
Integration and housekeeping
integration-tests/activity_list_test.go Updates the expected wrapped table output to the new widths.
integration-tests/environment_deploy_test.go Updates the expected wrapped table output to the new widths.
go.mod Changes the dependency declaration for charmbracelet/x/ansi.
CLAUDE.md Adds contributor notes.

@upsun-dispatch upsun-dispatch Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Changes suggested — 🟡 1 warning

🔁 Incremental · 6 files reviewed

Verification
  • flexTotal is always positive in the share step: the early returns guarantee maxTotal > available > minTotal.
  • The new wordwrap cuts a too-wide grapheme only once cur > lastStart, so a wide character at a line start cannot loop or produce an empty line.
  • wrapCell and longestWordWidth both treat only leading ASCII spaces as indentation, and tabs are expanded in renderTable before either runs.
  • Both column-lookup paths in Render (the row filter and noWrap) now check the indexes key exists, and duplicate names map to the last column.

This change updates the unit tests in internal/table/table_test.go: new wrapping cases and TestWordwrap. It also updates legacy/tests/Console/AdaptiveTableTest.php and the expected tables in the integration tests for act and env:deploy. No test covers multi-line indented cells in the width algorithm, where the panic occurs. I reproduced it with a throwaway test.

Review details

Review 3 of 10 for this pull request · View the full run

Comment thread internal/table/adaptive.go
The indentation added to the longest word could make a multi-line
cell's minimum exceed its width, when the word is on a later,
unindented line. Negative shares then panicked in Go (slice bounds)
and gave wrong widths in PHP. A cell at its full width is not wrapped,
so its minimum is now capped at that width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@upsun-dispatch
upsun-dispatch Bot dismissed their stale review October 2, 2026 00:50

Superseded: the latest Upsun Dispatch review no longer requests changes.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant