Skip to content

Wrap last column to terminal width in CLI table formatter#11931

Open
zachcasper wants to merge 3 commits into
radius-project:mainfrom
zachcasper:line-wrapping
Open

Wrap last column to terminal width in CLI table formatter#11931
zachcasper wants to merge 3 commits into
radius-project:mainfrom
zachcasper:line-wrapping

Conversation

@zachcasper

@zachcasper zachcasper commented May 18, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes the misaligned/ugly CLI table output when a cell in the last column (typically DESCRIPTION) is longer than the terminal width. The previous text/tabwriter-based renderer let long lines overflow and wrap at the terminal boundary, breaking column alignment on continuation lines.

This PR replaces the table renderer with a custom implementation that:

  • Detects terminal width via golang.org/x/term.
  • Word-wraps content in the last column to fit the remaining width.
  • Pads continuation lines so they sit under the correct column.
  • Is rune-aware (UTF-8 safe) for both column padding and wrapping.
  • Falls back to no wrapping when the writer is not a terminal (e.g., piped output), preserving scriptability.

Example

Before:

$ rad resource-type show Radius.Security/secrets
TYPE                     NAMESPACE
Radius.Security/secrets  Radius.Security

DESCRIPTION:
...
API VERSION: 2025-08-01-preview

TOP-LEVEL PROPERTIES:

NAME         TYPE      REQUIRED  READ-ONLY  DESCRIPTION
application  string    false     false      (Optional) The Radius Application ID. `myApplication.id` for example.
data         object    true      false      (Required) Map of secret data. For example: `data: { username: { value: user1 } password: { value: pass }}`
environment  string    true      false      (Required) The Radius Environment ID. Typically set by the rad CLI. Typically value should be `environment`.
kind         string    false     false      (Optional) The kind of content of the secret. If not specified, generic is assumed. basicAuthentication, awsIRSA, and azureWorkloadIdentity should only be used for configuring authentication to OCI registries for storing Bicep templates. This will change in the future.

OBJECT PROPERTIES:

data

NAME      TYPE      REQUIRED  READ-ONLY  DESCRIPTION
encoding  string    false     false      (Optional) Content encoding of the value. If not specified, `string` is assumed.
value     string    true      false      (Required) The string value of the secret unless encoding is set to 'base64'.

After:

$ rad resource-type show Radius.Security/secrets
TYPE                     NAMESPACE
Radius.Security/secrets  Radius.Security

DESCRIPTION:
...
API VERSION: 2025-08-01-preview

TOP-LEVEL PROPERTIES:

NAME         TYPE      REQUIRED  READ-ONLY  DESCRIPTION
application  string    false     false      (Optional) The Radius Application ID. `myApplication.id` for example.
data         object    true      false      (Required) Map of secret data. For example: `data: { username: { value: user1 } password: { value: pass }}`
environment  string    true      false      (Required) The Radius Environment ID. Typically set by the rad CLI. Typically value should be `environment`.
kind         string    false     false      (Optional) The kind of content of the secret. If not specified, generic is assumed. basicAuthentication, awsIRSA,
                                            and azureWorkloadIdentity should only be used for configuring authentication to OCI registries for storing Bicep
                                            templates. This will change in the future.

OBJECT PROPERTIES:

data

NAME      TYPE      REQUIRED  READ-ONLY  DESCRIPTION
encoding  string    false     false      (Optional) Content encoding of the value. If not specified, `string` is assumed.
value     string    true      false      (Required) The string value of the secret unless encoding is set to 'base64'.

Type of change

  • This pull request fixes a bug in Radius and has an approved issue (issue link required).

Fixes: #9756

Contributor checklist

Please verify that the PR meets the following requirements, where applicable:

  • An overview of proposed schema changes is included in a linked GitHub issue.
    • Not applicable
  • A design document is added or updated under eng/design-notes/ in this repository, if new APIs are being introduced.
    • Not applicable
  • The design document has been reviewed and approved by Radius maintainers/approvers.
    • Not applicable
  • A PR for resource-types-contrib is created, if resource types or recipes are affected by the changes in this PR.
    • Not applicable
  • A PR for dashboard is created, if the Radius Dashboard is affected by the changes in this PR.
    • Not applicable
  • A PR for the documentation repository is created, if the changes in this PR affect the documentation or any user facing updates are made.
    • Not applicable

Copilot AI review requested due to automatic review settings May 18, 2026 19:26
@zachcasper zachcasper requested review from a team as code owners May 18, 2026 19:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the CLI table formatter to wrap long last-column values to the terminal width, improving readability for tables with long descriptions while preserving unwrapped output when terminal width is unknown.

Changes:

  • Replaced text/tabwriter usage with a custom table renderer.
  • Added last-column word wrapping, continuation-line padding, and related tests.
  • Promoted golang.org/x/term to a direct dependency.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
pkg/cli/output/table.go Implements terminal-width detection, custom column sizing, padding, and wrapping helpers.
pkg/cli/output/table_test.go Adds tests for wrapping behavior, unknown-width output, long words, Unicode, and whitespace preservation.
go.mod Marks golang.org/x/term as a direct dependency.

Comment thread pkg/cli/output/table.go Outdated
Comment thread pkg/cli/output/table.go Outdated
zachcasper added a commit to zachcasper/radius that referenced this pull request May 18, 2026
Address Copilot review feedback on radius-project#11931: replace utf8.RuneCountInString
with runewidth.StringWidth so that wide characters (CJK, emoji) and zero-width
combining marks are accounted for correctly in column alignment and last-column
wrapping. Long unbreakable words are now split at display-column boundaries
rather than rune-count boundaries.

- pkg/cli/output/table.go: switch padRight and wordWrap (and the slot/width
  computation) to use runewidth.StringWidth and runewidth.RuneWidth.
- pkg/cli/output/table_test.go: add Test_Table_WrapsWideCharactersByDisplayWidth
  exercising CJK, and update the Unicode wrap expectation to reflect that 🚀
  is two display columns wide.
- go.mod: promote github.com/mattn/go-runewidth to a direct dependency.
zachcasper added a commit to zachcasper/radius that referenced this pull request May 18, 2026
Address Copilot review feedback on radius-project#11931: replace utf8.RuneCountInString
with runewidth.StringWidth so that wide characters (CJK, emoji) and zero-width
combining marks are accounted for correctly in column alignment and last-column
wrapping. Long unbreakable words are now split at display-column boundaries
rather than rune-count boundaries.

- pkg/cli/output/table.go: switch padRight and wordWrap (and the slot/width
  computation) to use runewidth.StringWidth and runewidth.RuneWidth.
- pkg/cli/output/table_test.go: add Test_Table_WrapsWideCharactersByDisplayWidth
  exercising CJK, and update the Unicode wrap expectation to reflect that 🚀
  is two display columns wide.
- go.mod: promote github.com/mattn/go-runewidth to a direct dependency.

Signed-off-by: Zach Casper <zachcasper@microsoft.com>
@zachcasper zachcasper marked this pull request as draft May 18, 2026 19:55
@codecov

codecov Bot commented May 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.82353% with 22 lines in your changes missing coverage. Please review.
✅ Project coverage is 51.92%. Comparing base (cfc4de8) to head (e5f2ad0).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
pkg/cli/output/table.go 83.82% 12 Missing and 10 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #11931      +/-   ##
==========================================
+ Coverage   51.84%   51.92%   +0.08%     
==========================================
  Files         729      729              
  Lines       46024    46127     +103     
==========================================
+ Hits        23861    23953      +92     
- Misses      19888    19896       +8     
- Partials     2275     2278       +3     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Signed-off-by: Zach Casper <zachcasper@microsoft.com>
Address Copilot review feedback on radius-project#11931: replace utf8.RuneCountInString
with runewidth.StringWidth so that wide characters (CJK, emoji) and zero-width
combining marks are accounted for correctly in column alignment and last-column
wrapping. Long unbreakable words are now split at display-column boundaries
rather than rune-count boundaries.

- pkg/cli/output/table.go: switch padRight and wordWrap (and the slot/width
  computation) to use runewidth.StringWidth and runewidth.RuneWidth.
- pkg/cli/output/table_test.go: add Test_Table_WrapsWideCharactersByDisplayWidth
  exercising CJK, and update the Unicode wrap expectation to reflect that 🚀
  is two display columns wide.
- go.mod: promote github.com/mattn/go-runewidth to a direct dependency.

Signed-off-by: Zach Casper <zachcasper@microsoft.com>
Fixes golangci-lint ineffassign: currentWidth is reassigned a few lines later
from the long-word chunking loop, so the intermediate reset to 0 was dead.

Signed-off-by: Zach Casper <zachcasper@microsoft.com>
@radius-functional-tests

radius-functional-tests Bot commented May 26, 2026

Copy link
Copy Markdown

Radius functional test overview

🔍 Go to test action run

Click here to see the test run details
Name Value
Repository zachcasper/radius
Commit ref e5f2ad0
Unique ID func0316ef014e
Image tag pr-func0316ef014e
  • gotestsum 1.13.0
  • KinD: v0.29.0
  • Dapr: 1.14.4
  • Azure KeyVault CSI driver: 1.4.2
  • Azure Workload identity webhook: 1.3.0
  • Bicep recipe location ghcr.io/radius-project/dev/test/testrecipes/test-bicep-recipes/<name>:pr-func0316ef014e
  • Terraform recipe location http://tf-module-server.radius-test-tf-module-server.svc.cluster.local/<name>.zip (in cluster)
  • applications-rp test image location: ghcr.io/radius-project/dev/applications-rp:pr-func0316ef014e
  • dynamic-rp test image location: ghcr.io/radius-project/dev/dynamic-rp:pr-func0316ef014e
  • controller test image location: ghcr.io/radius-project/dev/controller:pr-func0316ef014e
  • ucp test image location: ghcr.io/radius-project/dev/ucpd:pr-func0316ef014e
  • deployment-engine test image location: ghcr.io/radius-project/deployment-engine:latest

Test Status

⌛ Building Radius and pushing container images for functional tests...
✅ Container images build succeeded
⌛ Publishing Bicep Recipes for functional tests...
✅ Recipe publishing succeeded
⌛ Starting ucp-cloud functional tests...
⌛ Starting corerp-cloud functional tests...
✅ ucp-cloud functional tests succeeded
✅ corerp-cloud functional tests succeeded

@zachcasper zachcasper marked this pull request as ready for review May 26, 2026 16:38

@brooke-hamilton brooke-hamilton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Automated review consolidating findings from two reviewer passes. Two reviewers independently flagged the TableColumnMinWidth clamp as the primary functional bug; other comments cover API hygiene, test coverage gaps, and small dead-code/style items.

Top priorities

  1. The available < TableColumnMinWidth clamp can still overflow the terminal — the very condition this PR is meant to fix.
  2. Format (the production entry point) is not exercised by any new test; all new tests bypass it via formatWithWidth.
  3. New tests do not use t.Parallel() and could be consolidated into a single table-driven test.

PR title: Accurate and descriptive — no change suggested.

Contributor doc impact: None. Internal renderer change only; no CLI surface, build, or workflow changes.

Comment thread pkg/cli/output/table.go
)

// terminalWidthForWriter is overridable for testing.
var terminalWidthForWriter = func(w io.Writer) int {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two concerns in this area:

  1. Dead exported constants above. TableTabSize, TablePadCharacter, and TableFlags were only used to configure text/tabwriter. After this change they have no callers in the workspace. Because they are exported, leaving them dangling pollutes the package API. Please remove them, or add a doc comment explaining they are retained for backwards compatibility.
  2. terminalWidthForWriter as a mutable package-level var. It's a test seam, but none of the new tests actually swap it (they all call formatWithWidth directly), so the seam is currently unused. Either drop it and test Format end-to-end with an *os.File pipe, or keep it and add a test that overrides it with t.Cleanup restoring the original. Also: width is only detected when the writer's concrete type is *os.File. Anything wrapping stdout (e.g., a color-enabling writer, bufio.Writer, a tee) silently disables wrapping. Please confirm the CLI's real stdout path is a raw *os.File; otherwise users on real terminals won't get the new behavior.

Comment thread pkg/cli/output/table.go
// fits in the remaining space, and wrap longer cells onto continuation lines.
lastContentWidth := slots[numCols-1] - TablePadSize
if terminalWidth > 0 {
nonLastSum := 0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

(High priority — flagged by both reviewers.) When nonLastSum + TableColumnMinWidth > terminalWidth, the last column is forced to TableColumnMinWidth and the rendered line will exceed the terminal width — the very thing this PR fixes. For example, if non-last columns consume 75 cells of an 80-cell terminal, available becomes 5, this clamp raises it to 10, and every line ends up 85 cells wide.

Consider available = max(1, terminalWidth-nonLastSum) so wrapping still occurs in narrow terminals, or — if you intentionally prefer a readable minimum at the cost of overflow — add a comment documenting that fixed-column-driven overflow can still occur. Either way, please add a regression test where non-last columns leave fewer than TableColumnMinWidth cells for the last column.

Comment thread pkg/cli/output/table.go
lastContentWidth = available
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two minor cleanups:

  1. if numCols > 0 is dead: numCols == len(options.Columns) and the function returns early at the top when len(options.Columns) == 0. Remove the guard.
  2. cells[lastIdx] = wrapped mutates the caller's cells slice. It's harmless today because each row is iterated once, but it's a non-obvious side effect on a closure parameter. Consider assigning into a local colCells variable, or document the contract on the closure.

Comment thread pkg/cli/output/table.go

return nil
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

When width <= 0, this returns []string{s} without splitting on newlines. That is correct (callers have already split on \n), but the public contract isn't obvious from the signature — please add a one-line comment to that effect.

Comment thread pkg/cli/output/table.go
space := runes[j:k]
spaceWidth := runewidth.StringWidth(string(space))
i = k

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Long-word splitter edge case: if width == 1 and a wide rune (display width 2) appears, the ci > chunkStart guard prevents emitting an empty chunk, so a one-rune chunk of display width 2 will be produced — exceeding width. This is unreachable today because available is clamped to TableColumnMinWidth == 10, but worth either an assertion comment or a wordWrap test with width == 1 to document the contract.

Also: ci here is a rune index because word is []rune, but the surrounding code uses []rune(s) conversions elsewhere — a one-line comment clarifying that ci is a rune index (not a byte index) would help future readers.

},
}

func Test_Table_WrapsLastColumnToTerminalWidth(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

None of the new tests (Test_Table_WrapsLastColumnToTerminalWidth, Test_Table_NoWrapWhenTerminalWidthUnknown, Test_Table_WrapsLongUnbreakableWord, Test_Table_AlignsAndWrapsUnicode, Test_Table_PreservesInternalWhitespace, Test_Table_WrapsWideCharactersByDisplayWidth) call t.Parallel(). Per the repo's Go instructions, tests without shared state should run in parallel. All of these are independent and safe to parallelize.

Additionally, they share an identical setup pattern (build []wrappableInput, create formatter + buffer, call formatWithWidth, compare to expected string). Please consolidate into a single table-driven Test_Table_Wrapping(t *testing.T) with sub-tests using t.Run + t.Parallel(). This eliminates duplication and makes adding cases trivial.

require.Equal(t, expected, buffer.String())
}

func Test_Table_NoWrapWhenTerminalWidthUnknown(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Every new test bypasses Format and calls the unexported formatWithWidth. Please add at least one test that drives Format with a non-*os.File writer (a bytes.Buffer) to verify the "unknown width → no wrap" path through the production entry point. This is also the simplest way to lift the terminalWidthForWriter coverage hole flagged by Codecov.

// terminal display width rather than rune count: wide characters (e.g. CJK ideographs)
// occupy two columns, so the column slot must grow accordingly and wrapping must occur
// before content exceeds the terminal width.
func Test_Table_WrapsWideCharactersByDisplayWidth(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Missing edge-case coverage worth adding:

  • Single-column table (numCols == 1) — exercises the lastContentWidth path with nonLastSum == 0.
  • Cell containing literal \n combined with wrapping — verifies the doc claim that embedded newlines still expand into multiple rows even when terminalWidth == 0.
  • Empty rows / empty last cell — verifies no spurious blank continuation lines.
  • Heading longer than the available last-column width — current code will wrap the heading; either assert this behavior or add a special case so headings are never wrapped.
  • Narrow terminal where non-last columns leave fewer than TableColumnMinWidth cells for the last column — regression test for the overflow issue flagged on table.go.

Comment thread go.mod
require (
github.com/mattn/go-runewidth v0.0.23
golang.org/x/term v0.43.0
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

golang.org/x/term v0.43.0 and github.com/mattn/go-runewidth v0.0.23 are correctly promoted to direct dependencies and removed from the indirect block. The diff stat shows no go.sum change — please confirm go mod tidy was run and go.sum is consistent before merge.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Incorrect line wrapping for property description in rad resource-type show

3 participants