From a33df7114dcf255364ed468c8ef84b0065650b3b Mon Sep 17 00:00:00 2001 From: srbhr Date: Wed, 15 Apr 2026 17:05:55 +0530 Subject: [PATCH 1/3] fix: make table output fit terminal width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table formatter had no concept of terminal width — nested objects (addresses, line items) were dumped as raw JSON, pushing columns to hundreds of characters. Now columns are capped, truncated with ellipsis, and budgeted to fit the terminal. Complex nested columns are auto-pruned when there are too many to display readably. Scalar fields are sorted before complex ones for better default column ordering. --- go.mod | 2 +- internal/output/formatter_test.go | 115 ++++++++++++++++++++++++++ internal/output/rows.go | 24 +++++- internal/output/table.go | 129 ++++++++++++++++++++++++++++-- 4 files changed, 260 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index b821d9d..8bc6053 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( charm.land/bubbletea/v2 v2.0.2 charm.land/lipgloss/v2 v2.0.2 github.com/charmbracelet/huh v0.6.0 + github.com/charmbracelet/x/term v0.2.2 github.com/hashicorp/go-retryablehttp v0.7.8 github.com/pb33f/libopenapi v0.34.2 github.com/spf13/cobra v1.10.2 @@ -28,7 +29,6 @@ require ( github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect diff --git a/internal/output/formatter_test.go b/internal/output/formatter_test.go index f6e42c3..270adb2 100644 --- a/internal/output/formatter_test.go +++ b/internal/output/formatter_test.go @@ -258,6 +258,121 @@ func TestCSVFormatterComplexValues(t *testing.T) { } } +// TestTruncate verifies the truncate helper. +func TestTruncate(t *testing.T) { + cases := []struct { + name string + s string + max int + want string + }{ + {"fits", "hello", 10, "hello"}, + {"exact", "hello", 5, "hello"}, + {"trunc", "hello world", 5, "hell\u2026"}, + {"min", "hello", 1, "h"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := truncate(tc.s, tc.max) + if got != tc.want { + t.Errorf("truncate(%q, %d) = %q, want %q", tc.s, tc.max, got, tc.want) + } + }) + } +} + +// TestBudgetColumns verifies column budget distribution. +func TestBudgetColumns(t *testing.T) { + t.Run("fits", func(t *testing.T) { + widths := []int{10, 10, 10} + budgetColumns(widths, 80) + total := widths[0] + widths[1] + widths[2] + 4 // 2 gaps * 2 + if total > 80 { + t.Errorf("expected total <= 80, got %d", total) + } + // Should be unchanged since they fit. + if widths[0] != 10 || widths[1] != 10 || widths[2] != 10 { + t.Errorf("expected no shrinking, got %v", widths) + } + }) + + t.Run("shrinks widest", func(t *testing.T) { + widths := []int{10, 50, 10} + budgetColumns(widths, 40) + total := widths[0] + widths[1] + widths[2] + 4 + if total > 40 { + t.Errorf("expected total <= 40, got %d", total) + } + }) + + t.Run("respects minimum", func(t *testing.T) { + widths := []int{10, 10, 10} + budgetColumns(widths, 10) // impossibly tight + for _, w := range widths { + if w < colMin { + t.Errorf("column shrunk below colMin: %d", w) + } + } + }) +} + +// TestTableFormatterTruncatesLongValues verifies table output truncates long nested values. +func TestTableFormatterTruncatesLongValues(t *testing.T) { + var buf bytes.Buffer + f := &TableFormatter{w: &buf, fields: []string{"id", "address"}} + + longAddress := map[string]any{ + "line1": "122 E Houston St", + "city": "San Antonio", + "state": "TX", + "postal_code": "78205", + "country": "US", + } + resp := &spec.APIResponse{ + StatusCode: 200, + Success: true, + Data: []any{ + map[string]any{"id": "1", "address": longAddress}, + }, + } + + if err := f.Format(resp); err != nil { + t.Fatalf("Format returned error: %v", err) + } + + out := buf.String() + lines := strings.Split(strings.TrimSpace(out), "\n") + for _, line := range lines { + // Strip ANSI escape sequences and measure rune count (display width). + plain := stripAnsi(line) + runeCount := len([]rune(plain)) + if runeCount > 120 { // generous default terminal width + t.Errorf("line exceeds 120 display chars (%d): %s", runeCount, plain) + } + } +} + +// stripAnsi removes ANSI escape sequences for measuring plain text width. +func stripAnsi(s string) string { + var out strings.Builder + inEsc := false + for _, r := range s { + if r == '\033' { + inEsc = true + continue + } + if inEsc { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + inEsc = false + } + continue + } + out.WriteRune(r) + } + return out.String() +} + // TestExtractRows verifies the extractRows helper handles different data shapes. func TestExtractRows(t *testing.T) { t.Run("slice of any", func(t *testing.T) { diff --git a/internal/output/rows.go b/internal/output/rows.go index b8d506a..90ca479 100644 --- a/internal/output/rows.go +++ b/internal/output/rows.go @@ -4,6 +4,7 @@ package output import ( "encoding/json" "fmt" + "sort" ) // extractRows normalizes data into a slice of row maps and an ordered list of @@ -28,14 +29,15 @@ func extractRows(data any, selectedFields []string) ([]map[string]any, []string) case map[string]any: rows = []map[string]any{v} default: - // Unsupported shape — return a single row with the stringified value. + // Unsupported shape -- return a single row with the stringified value. rows = []map[string]any{{"value": fmt.Sprintf("%v", data)}} } // Determine column order. fields := selectedFields if len(fields) == 0 && len(rows) > 0 { - // Auto-detect from the first row, preserving insertion order. + // Auto-detect from the first row. Sort scalars before complex types + // so the most useful columns appear first in the table. seen := map[string]bool{} for k := range rows[0] { if !seen[k] { @@ -43,11 +45,29 @@ func extractRows(data any, selectedFields []string) ([]map[string]any, []string) seen[k] = true } } + first := rows[0] + sort.SliceStable(fields, func(i, j int) bool { + iComplex := isComplex(first[fields[i]]) + jComplex := isComplex(first[fields[j]]) + if iComplex != jComplex { + return !iComplex // scalars first + } + return false // preserve relative order + }) } return rows, fields } +// isComplex returns true for maps and slices (nested/array values). +func isComplex(v any) bool { + switch v.(type) { + case map[string]any, []any: + return true + } + return false +} + // formatValue converts a value to a human-readable string suitable for table // and CSV output. Maps and slices are serialized as compact JSON instead of // Go's default fmt representation. Nil values render as an empty string. diff --git a/internal/output/table.go b/internal/output/table.go index d8e0173..3301136 100644 --- a/internal/output/table.go +++ b/internal/output/table.go @@ -4,11 +4,21 @@ package output import ( "fmt" "io" + "os" "strings" + "unicode/utf8" "charm.land/lipgloss/v2" "github.com/apideck-io/cli/internal/spec" "github.com/apideck-io/cli/internal/ui" + "github.com/charmbracelet/x/term" +) + +const ( + colGap = 2 // spaces between columns + colAbsMax = 40 // hard cap per column before budget distribution + colMin = 6 // minimum usable column width + defaultTTY = 120 ) // TableFormatter formats an APIResponse as a styled terminal table. @@ -26,10 +36,17 @@ func (f *TableFormatter) Format(resp *spec.APIResponse) error { return nil } + // When fields weren't explicitly selected, drop complex (nested object/array) + // columns if there are too many columns to display readably. + if len(f.fields) == 0 && len(rows) > 0 { + tw := termWidth() + fields = pruneComplexFields(fields, rows[0], tw) + } + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(ui.ColorPrimary()) dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim()) - // Calculate column widths (minimum = header width). + // Calculate natural column widths (minimum = header width), capped at colAbsMax. colWidths := make([]int, len(fields)) for i, h := range fields { colWidths[i] = len(h) @@ -42,18 +59,26 @@ func (f *TableFormatter) Format(resp *spec.APIResponse) error { } } } + for i := range colWidths { + if colWidths[i] > colAbsMax { + colWidths[i] = colAbsMax + } + } + + // Shrink columns to fit the terminal width. + budgetColumns(colWidths, termWidth()) // Build and print header. headerCells := make([]string, len(fields)) for i, h := range fields { - headerCells[i] = headerStyle.Render(padRight(h, colWidths[i])) + headerCells[i] = headerStyle.Render(padRight(truncate(h, colWidths[i]), colWidths[i])) } fmt.Fprintln(f.w, strings.Join(headerCells, " ")) // Separator line. sepParts := make([]string, len(fields)) for i, w := range colWidths { - sepParts[i] = dimStyle.Render(strings.Repeat("─", w)) + sepParts[i] = dimStyle.Render(strings.Repeat("\u2500", w)) } fmt.Fprintln(f.w, strings.Join(sepParts, " ")) @@ -62,7 +87,7 @@ func (f *TableFormatter) Format(resp *spec.APIResponse) error { cells := make([]string, len(fields)) for i, field := range fields { cell := formatValue(row[field]) - cells[i] = padRight(cell, colWidths[i]) + cells[i] = padRight(truncate(cell, colWidths[i]), colWidths[i]) } fmt.Fprintln(f.w, strings.Join(cells, " ")) } @@ -70,10 +95,100 @@ func (f *TableFormatter) Format(resp *spec.APIResponse) error { return nil } -// padRight pads s with trailing spaces until it reaches width w. +// termWidth returns the current terminal width, falling back to defaultTTY. +func termWidth() int { + w, _, err := term.GetSize(os.Stdout.Fd()) + if err != nil || w <= 0 { + return defaultTTY + } + return w +} + +// pruneComplexFields removes complex (nested object/array) fields when the +// total number of columns would make the table unreadable. It keeps all scalar +// fields and only adds complex fields if there's room for at least colReadable +// characters per column. +func pruneComplexFields(fields []string, sampleRow map[string]any, tw int) []string { + const colReadable = 10 // minimum chars per column to be useful + + // Separate scalar and complex fields. + var scalar, complex_ []string + for _, f := range fields { + if isComplex(sampleRow[f]) { + complex_ = append(complex_, f) + } else { + scalar = append(scalar, f) + } + } + + // Start with scalars. Add complex fields one by one if they fit. + result := scalar + for _, f := range complex_ { + n := len(result) + 1 + available := tw - colGap*(n-1) + if available/n >= colReadable { + result = append(result, f) + } + } + + // If we pruned something, the result is already good. + // If nothing was pruned, return original to preserve order. + if len(result) == len(fields) { + return fields + } + return result +} + +// budgetColumns shrinks column widths so the total table fits within maxWidth. +// It repeatedly trims the widest column by one until the table fits, but never +// shrinks a column below colMin. +func budgetColumns(widths []int, maxWidth int) { + n := len(widths) + if n == 0 { + return + } + gapTotal := colGap * (n - 1) + + for { + total := gapTotal + for _, w := range widths { + total += w + } + if total <= maxWidth { + return + } + + // Find the widest column that is still above colMin. + widest := -1 + for i, w := range widths { + if w > colMin && (widest == -1 || w > widths[widest]) { + widest = i + } + } + if widest == -1 { + return // all columns at minimum, nothing more to shrink + } + widths[widest]-- + } +} + +// truncate shortens s to fit within max runes, adding "\u2026" when trimmed. +func truncate(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + if max <= 1 { + return string(runes[:max]) + } + return string(runes[:max-1]) + "\u2026" +} + +// padRight pads s with trailing spaces until it reaches display width w. func padRight(s string, w int) string { - if len(s) >= w { + n := utf8.RuneCountInString(s) + if n >= w { return s } - return s + strings.Repeat(" ", w-len(s)) + return s + strings.Repeat(" ", w-n) } From b47a587e1eabe8f97be20de992193554cd747989 Mon Sep 17 00:00:00 2001 From: samz Date: Wed, 15 Apr 2026 13:52:26 +0100 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- internal/output/formatter_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/output/formatter_test.go b/internal/output/formatter_test.go index 270adb2..add5b91 100644 --- a/internal/output/formatter_test.go +++ b/internal/output/formatter_test.go @@ -331,7 +331,7 @@ func TestTableFormatterTruncatesLongValues(t *testing.T) { } resp := &spec.APIResponse{ StatusCode: 200, - Success: true, + "Success": true, Data: []any{ map[string]any{"id": "1", "address": longAddress}, }, @@ -341,14 +341,15 @@ func TestTableFormatterTruncatesLongValues(t *testing.T) { t.Fatalf("Format returned error: %v", err) } + maxWidth := termWidth() out := buf.String() lines := strings.Split(strings.TrimSpace(out), "\n") for _, line := range lines { // Strip ANSI escape sequences and measure rune count (display width). plain := stripAnsi(line) runeCount := len([]rune(plain)) - if runeCount > 120 { // generous default terminal width - t.Errorf("line exceeds 120 display chars (%d): %s", runeCount, plain) + if runeCount > maxWidth { + t.Errorf("line exceeds %d display chars (%d): %s", maxWidth, runeCount, plain) } } } From 9cf275debdcc65ff6c7f06ce618912b4e55f3f90 Mon Sep 17 00:00:00 2001 From: srbhr Date: Thu, 16 Apr 2026 01:44:50 +0530 Subject: [PATCH 3/3] fix: use consistent rune-based width and injectable terminal size Address PR review feedback: - Column width calculation now uses utf8.RuneCountInString instead of len() (byte count) so non-ASCII text is measured correctly. - Terminal width is derived from the formatter's io.Writer (when it is an *os.File TTY) instead of always reading os.Stdout. A widthFn field allows tests to inject a fixed width, eliminating environment dependence. - Fix padRight comment to accurately say "runes" instead of "display width". - TestTableFormatterTruncatesLongValues now uses an injected width of 80 so it is deterministic regardless of the caller's terminal size. --- internal/output/formatter_test.go | 16 ++++++++----- internal/output/table.go | 38 +++++++++++++++++++------------ 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/internal/output/formatter_test.go b/internal/output/formatter_test.go index add5b91..6f3e8db 100644 --- a/internal/output/formatter_test.go +++ b/internal/output/formatter_test.go @@ -320,7 +320,12 @@ func TestBudgetColumns(t *testing.T) { // TestTableFormatterTruncatesLongValues verifies table output truncates long nested values. func TestTableFormatterTruncatesLongValues(t *testing.T) { var buf bytes.Buffer - f := &TableFormatter{w: &buf, fields: []string{"id", "address"}} + const testWidth = 80 + f := &TableFormatter{ + w: &buf, + fields: []string{"id", "address"}, + widthFn: func() int { return testWidth }, + } longAddress := map[string]any{ "line1": "122 E Houston St", @@ -331,7 +336,7 @@ func TestTableFormatterTruncatesLongValues(t *testing.T) { } resp := &spec.APIResponse{ StatusCode: 200, - "Success": true, + Success: true, Data: []any{ map[string]any{"id": "1", "address": longAddress}, }, @@ -341,15 +346,14 @@ func TestTableFormatterTruncatesLongValues(t *testing.T) { t.Fatalf("Format returned error: %v", err) } - maxWidth := termWidth() out := buf.String() lines := strings.Split(strings.TrimSpace(out), "\n") for _, line := range lines { - // Strip ANSI escape sequences and measure rune count (display width). + // Strip ANSI escape sequences and measure rune count. plain := stripAnsi(line) runeCount := len([]rune(plain)) - if runeCount > maxWidth { - t.Errorf("line exceeds %d display chars (%d): %s", maxWidth, runeCount, plain) + if runeCount > testWidth { + t.Errorf("line exceeds %d runes (%d): %s", testWidth, runeCount, plain) } } } diff --git a/internal/output/table.go b/internal/output/table.go index 3301136..28fde5e 100644 --- a/internal/output/table.go +++ b/internal/output/table.go @@ -23,8 +23,9 @@ const ( // TableFormatter formats an APIResponse as a styled terminal table. type TableFormatter struct { - w io.Writer - fields []string + w io.Writer + fields []string + widthFn func() int // optional; returns terminal width — nil means auto-detect } // Format writes the response data as a lipgloss-styled table. @@ -36,26 +37,27 @@ func (f *TableFormatter) Format(resp *spec.APIResponse) error { return nil } + tw := f.getWidth() + // When fields weren't explicitly selected, drop complex (nested object/array) // columns if there are too many columns to display readably. if len(f.fields) == 0 && len(rows) > 0 { - tw := termWidth() fields = pruneComplexFields(fields, rows[0], tw) } headerStyle := lipgloss.NewStyle().Bold(true).Foreground(ui.ColorPrimary()) dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim()) - // Calculate natural column widths (minimum = header width), capped at colAbsMax. + // Calculate natural column widths in runes (minimum = header width), capped at colAbsMax. colWidths := make([]int, len(fields)) for i, h := range fields { - colWidths[i] = len(h) + colWidths[i] = utf8.RuneCountInString(h) } for _, row := range rows { for i, field := range fields { cell := formatValue(row[field]) - if len(cell) > colWidths[i] { - colWidths[i] = len(cell) + if n := utf8.RuneCountInString(cell); n > colWidths[i] { + colWidths[i] = n } } } @@ -66,7 +68,7 @@ func (f *TableFormatter) Format(resp *spec.APIResponse) error { } // Shrink columns to fit the terminal width. - budgetColumns(colWidths, termWidth()) + budgetColumns(colWidths, tw) // Build and print header. headerCells := make([]string, len(fields)) @@ -95,13 +97,19 @@ func (f *TableFormatter) Format(resp *spec.APIResponse) error { return nil } -// termWidth returns the current terminal width, falling back to defaultTTY. -func termWidth() int { - w, _, err := term.GetSize(os.Stdout.Fd()) - if err != nil || w <= 0 { - return defaultTTY +// getWidth returns the terminal width to use for this formatter. +// If a custom widthFn was injected it is used; otherwise the width is derived +// from f.w when it is an *os.File TTY, falling back to defaultTTY. +func (f *TableFormatter) getWidth() int { + if f.widthFn != nil { + return f.widthFn() + } + if file, ok := f.w.(*os.File); ok { + if w, _, err := term.GetSize(file.Fd()); err == nil && w > 0 { + return w + } } - return w + return defaultTTY } // pruneComplexFields removes complex (nested object/array) fields when the @@ -184,7 +192,7 @@ func truncate(s string, max int) string { return string(runes[:max-1]) + "\u2026" } -// padRight pads s with trailing spaces until it reaches display width w. +// padRight pads s with trailing spaces until it reaches w runes. func padRight(s string, w int) string { n := utf8.RuneCountInString(s) if n >= w {