From 9c8acb6baa04d9d0c7283e9f249cb38b55a81f99 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 3 Oct 2025 17:10:50 -0400 Subject: [PATCH 1/6] avoid line wrapping tables --- cmd/app.go | 6 -- cmd/proxies/helpers.go | 183 ++++++++++++++++++++++++++++++++++++++- cmd/proxies/list_test.go | 3 +- cmd/table.go | 170 ++++++++++++++++++++++++++++++++++++ 4 files changed, 352 insertions(+), 10 deletions(-) diff --git a/cmd/app.go b/cmd/app.go index 7c4dba8..7ddc933 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -80,9 +80,6 @@ func runAppList(cmd *cobra.Command, args []string) error { envVarsStr := "-" if len(app.EnvVars) > 0 { envVarsStr = strings.Join(lo.Keys(app.EnvVars), ", ") - if len(envVarsStr) > 50 { - envVarsStr = envVarsStr[:47] + "..." - } } actionsStr := "-" @@ -90,9 +87,6 @@ func runAppList(cmd *cobra.Command, args []string) error { actionsStr = strings.Join(lo.Map(app.Actions, func(a kernel.AppAction, _ int) string { return a.Name }), ", ") - if len(actionsStr) > 50 { - actionsStr = actionsStr[:47] + "..." - } } tableData = append(tableData, []string{ diff --git a/cmd/proxies/helpers.go b/cmd/proxies/helpers.go index 59b4853..b2d9b10 100644 --- a/cmd/proxies/helpers.go +++ b/cmd/proxies/helpers.go @@ -1,14 +1,193 @@ package proxies import ( + "unicode/utf8" + "github.com/pterm/pterm" ) -// PrintTableNoPad prints a table without padding +// PrintTableNoPad prints a table with intelligent column truncation to prevent wrapping. +// It detects terminal width and truncates cells that would cause line wrapping, +// adding "..." to indicate truncation. func PrintTableNoPad(data pterm.TableData, withRowSeparators bool) { - table := pterm.DefaultTable.WithHasHeader().WithData(data) + if len(data) == 0 { + return + } + + // Get terminal width + termWidth := pterm.GetTerminalWidth() + if termWidth <= 0 { + termWidth = 80 // fallback + } + + // Truncate data to fit terminal width + truncatedData := truncateTableData(data, termWidth) + + table := pterm.DefaultTable.WithHasHeader().WithData(truncatedData) if withRowSeparators { table = table.WithRowSeparator("-") } _ = table.Render() } + +// truncateTableData intelligently truncates table cells to fit within terminal width +func truncateTableData(data pterm.TableData, termWidth int) pterm.TableData { + if len(data) == 0 { + return data + } + + numCols := len(data[0]) + if numCols == 0 { + return data + } + + // Calculate separator space: " | " between each column (3 chars per separator) + separatorSpace := (numCols - 1) * 3 + + // Define minimum column widths (these are the bare minimum before aggressive truncation) + minWidths := make([]int, numCols) + for i := 0; i < numCols; i++ { + minWidths[i] = 8 // minimum 8 chars per column + } + + // Calculate natural widths (what each column would want) + naturalWidths := make([]int, numCols) + for colIdx := 0; colIdx < numCols; colIdx++ { + maxWidth := 0 + for _, row := range data { + if colIdx < len(row) { + cellWidth := utf8.RuneCountInString(row[colIdx]) + if cellWidth > maxWidth { + maxWidth = cellWidth + } + } + } + naturalWidths[colIdx] = maxWidth + } + + // Calculate available space for content + availableWidth := termWidth - separatorSpace - 2 // -2 for margins + + // Distribute width among columns + columnWidths := distributeColumnWidths(naturalWidths, minWidths, availableWidth) + + // Truncate cells based on calculated widths + result := make(pterm.TableData, len(data)) + for rowIdx, row := range data { + result[rowIdx] = make([]string, len(row)) + for colIdx, cell := range row { + if colIdx < len(columnWidths) { + result[rowIdx][colIdx] = truncateCell(cell, columnWidths[colIdx]) + } else { + result[rowIdx][colIdx] = cell + } + } + } + + return result +} + +// distributeColumnWidths calculates optimal width for each column +// The first column (ID) is always given its full natural width and never truncated +func distributeColumnWidths(naturalWidths, minWidths []int, availableWidth int) []int { + numCols := len(naturalWidths) + result := make([]int, numCols) + + // Start with natural widths + copy(result, naturalWidths) + + // Calculate total natural width needed + totalNatural := 0 + for _, w := range naturalWidths { + totalNatural += w + } + + // If natural widths fit, use them + if totalNatural <= availableWidth { + return result + } + + // Priority: Always give the ID column (first column) its full natural width + if numCols > 0 { + result[0] = naturalWidths[0] + availableWidth -= naturalWidths[0] + } + + // Now distribute remaining width among other columns (excluding ID) + if numCols <= 1 { + return result + } + + // Calculate needs for non-ID columns + otherCols := numCols - 1 + totalMinForOthers := 0 + totalNaturalForOthers := 0 + for i := 1; i < numCols; i++ { + totalMinForOthers += minWidths[i] + totalNaturalForOthers += naturalWidths[i] + } + + if totalNaturalForOthers <= availableWidth { + // Other columns fit naturally + for i := 1; i < numCols; i++ { + result[i] = naturalWidths[i] + } + return result + } + + if totalMinForOthers > availableWidth { + // Even minimums don't fit for other columns, distribute equally + for i := 1; i < numCols; i++ { + result[i] = availableWidth / otherCols + if result[i] < 5 { + result[i] = 5 // absolute minimum + } + } + return result + } + + // Give other columns minimum, then distribute remainder proportionally + remainingWidth := availableWidth - totalMinForOthers + remainingNeed := totalNaturalForOthers - totalMinForOthers + + for i := 1; i < numCols; i++ { + result[i] = minWidths[i] + if remainingNeed > 0 { + additionalNeed := naturalWidths[i] - minWidths[i] + additionalGrant := (additionalNeed * remainingWidth) / remainingNeed + result[i] += additionalGrant + } + } + + return result +} + +// truncateCell truncates a cell to maxWidth, adding "..." if truncated +func truncateCell(cell string, maxWidth int) string { + cellWidth := utf8.RuneCountInString(cell) + if cellWidth <= maxWidth { + return cell + } + + if maxWidth <= 3 { + // Too narrow for "...", just truncate + return truncateString(cell, maxWidth) + } + + // Truncate and add "..." + return truncateString(cell, maxWidth-3) + "..." +} + +// truncateString truncates a string to the specified number of runes +func truncateString(s string, maxRunes int) string { + if maxRunes <= 0 { + return "" + } + + runes := []rune(s) + if len(runes) <= maxRunes { + return s + } + + return string(runes[:maxRunes]) +} diff --git a/cmd/proxies/list_test.go b/cmd/proxies/list_test.go index a6b0eae..d091543 100644 --- a/cmd/proxies/list_test.go +++ b/cmd/proxies/list_test.go @@ -85,12 +85,11 @@ func TestProxyList_WithProxies(t *testing.T) { assert.Contains(t, output, "mobile-1") assert.Contains(t, output, "mobile") - assert.Contains(t, output, "Carrier: verizon") assert.Contains(t, output, "isp-1") assert.Contains(t, output, "-") // Empty name shows as "-" assert.Contains(t, output, "isp") - assert.Contains(t, output, "Country: EU") + assert.Contains(t, output, "EU") // Country value } func TestProxyList_Error(t *testing.T) { diff --git a/cmd/table.go b/cmd/table.go index d00906b..bd9f438 100644 --- a/cmd/table.go +++ b/cmd/table.go @@ -11,11 +11,19 @@ import ( // adding trailing padding spaces after the last column and does not add blank // padded lines to match multi-line cells in other columns. The last column may // contain multi-line content which will be printed as-is on following lines. +// It also intelligently truncates columns to prevent line wrapping. func printTableNoPad(data pterm.TableData, hasHeader bool) { if len(data) == 0 { return } + // Get terminal width and truncate data to fit + termWidth := pterm.GetTerminalWidth() + if termWidth <= 0 { + termWidth = 80 // fallback + } + data = truncateTableData(data, termWidth) + // Determine number of columns from the first row numCols := len(data[0]) if numCols == 0 { @@ -111,3 +119,165 @@ func printTableNoPad(data pterm.TableData, hasHeader bool) { pterm.Print(b.String()) } + +// truncateTableData intelligently truncates table cells to fit within terminal width +func truncateTableData(data pterm.TableData, termWidth int) pterm.TableData { + if len(data) == 0 { + return data + } + + numCols := len(data[0]) + if numCols == 0 { + return data + } + + // Calculate separator space: " | " between each column (3 chars per separator) + separatorSpace := (numCols - 1) * 3 + + // Define minimum column widths (these are the bare minimum before aggressive truncation) + minWidths := make([]int, numCols) + for i := 0; i < numCols; i++ { + minWidths[i] = 8 // minimum 8 chars per column + } + + // Calculate natural widths (what each column would want) + naturalWidths := make([]int, numCols) + for colIdx := 0; colIdx < numCols; colIdx++ { + maxWidth := 0 + for _, row := range data { + if colIdx < len(row) { + cellWidth := utf8.RuneCountInString(row[colIdx]) + if cellWidth > maxWidth { + maxWidth = cellWidth + } + } + } + naturalWidths[colIdx] = maxWidth + } + + // Calculate available space for content + availableWidth := termWidth - separatorSpace - 2 // -2 for margins + + // Distribute width among columns + columnWidths := distributeColumnWidths(naturalWidths, minWidths, availableWidth) + + // Truncate cells based on calculated widths + result := make(pterm.TableData, len(data)) + for rowIdx, row := range data { + result[rowIdx] = make([]string, len(row)) + for colIdx, cell := range row { + if colIdx < len(columnWidths) { + result[rowIdx][colIdx] = truncateCell(cell, columnWidths[colIdx]) + } else { + result[rowIdx][colIdx] = cell + } + } + } + + return result +} + +// distributeColumnWidths calculates optimal width for each column +// The first column (typically ID) is always given its full natural width and never truncated +func distributeColumnWidths(naturalWidths, minWidths []int, availableWidth int) []int { + numCols := len(naturalWidths) + result := make([]int, numCols) + + // Start with natural widths + copy(result, naturalWidths) + + // Calculate total natural width needed + totalNatural := 0 + for _, w := range naturalWidths { + totalNatural += w + } + + // If natural widths fit, use them + if totalNatural <= availableWidth { + return result + } + + // Priority: Always give the first column its full natural width + if numCols > 0 { + result[0] = naturalWidths[0] + availableWidth -= naturalWidths[0] + } + + // Now distribute remaining width among other columns (excluding first) + if numCols <= 1 { + return result + } + + // Calculate needs for non-first columns + otherCols := numCols - 1 + totalMinForOthers := 0 + totalNaturalForOthers := 0 + for i := 1; i < numCols; i++ { + totalMinForOthers += minWidths[i] + totalNaturalForOthers += naturalWidths[i] + } + + if totalNaturalForOthers <= availableWidth { + // Other columns fit naturally + for i := 1; i < numCols; i++ { + result[i] = naturalWidths[i] + } + return result + } + + if totalMinForOthers > availableWidth { + // Even minimums don't fit for other columns, distribute equally + for i := 1; i < numCols; i++ { + result[i] = availableWidth / otherCols + if result[i] < 5 { + result[i] = 5 // absolute minimum + } + } + return result + } + + // Give other columns minimum, then distribute remainder proportionally + remainingWidth := availableWidth - totalMinForOthers + remainingNeed := totalNaturalForOthers - totalMinForOthers + + for i := 1; i < numCols; i++ { + result[i] = minWidths[i] + if remainingNeed > 0 { + additionalNeed := naturalWidths[i] - minWidths[i] + additionalGrant := (additionalNeed * remainingWidth) / remainingNeed + result[i] += additionalGrant + } + } + + return result +} + +// truncateCell truncates a cell to maxWidth, adding "..." if truncated +func truncateCell(cell string, maxWidth int) string { + cellWidth := utf8.RuneCountInString(cell) + if cellWidth <= maxWidth { + return cell + } + + if maxWidth <= 3 { + // Too narrow for "...", just truncate + return truncateString(cell, maxWidth) + } + + // Truncate and add "..." + return truncateString(cell, maxWidth-3) + "..." +} + +// truncateString truncates a string to the specified number of runes +func truncateString(s string, maxRunes int) string { + if maxRunes <= 0 { + return "" + } + + runes := []rune(s) + if len(runes) <= maxRunes { + return s + } + + return string(runes[:maxRunes]) +} From 4bb36d5490a3897d9122e1f614c0d822f9ea2978 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 3 Oct 2025 17:17:41 -0400 Subject: [PATCH 2/6] Avoid truncating shorter columns --- cmd/proxies/helpers.go | 98 ++++++++++++++++++++++++---------------- cmd/proxies/list_test.go | 11 ++--- cmd/table.go | 98 ++++++++++++++++++++++++---------------- 3 files changed, 125 insertions(+), 82 deletions(-) diff --git a/cmd/proxies/helpers.go b/cmd/proxies/helpers.go index b2d9b10..e553286 100644 --- a/cmd/proxies/helpers.go +++ b/cmd/proxies/helpers.go @@ -51,12 +51,15 @@ func truncateTableData(data pterm.TableData, termWidth int) pterm.TableData { } // Calculate natural widths (what each column would want) + // Strip color codes to measure visible characters only naturalWidths := make([]int, numCols) for colIdx := 0; colIdx < numCols; colIdx++ { maxWidth := 0 for _, row := range data { if colIdx < len(row) { - cellWidth := utf8.RuneCountInString(row[colIdx]) + // Strip ANSI color codes to get visible character count + visibleText := pterm.RemoveColorFromString(row[colIdx]) + cellWidth := utf8.RuneCountInString(visibleText) if cellWidth > maxWidth { maxWidth = cellWidth } @@ -87,8 +90,9 @@ func truncateTableData(data pterm.TableData, termWidth int) pterm.TableData { return result } -// distributeColumnWidths calculates optimal width for each column -// The first column (ID) is always given its full natural width and never truncated +// distributeColumnWidths calculates optimal width for each column using a two-pass strategy: +// Pass 1: ID and short columns get their full natural width +// Pass 2: Long columns share the remaining space func distributeColumnWidths(naturalWidths, minWidths []int, availableWidth int) []int { numCols := len(naturalWidths) result := make([]int, numCols) @@ -107,55 +111,66 @@ func distributeColumnWidths(naturalWidths, minWidths []int, availableWidth int) return result } - // Priority: Always give the ID column (first column) its full natural width - if numCols > 0 { - result[0] = naturalWidths[0] - availableWidth -= naturalWidths[0] + // Define threshold for "short" columns (these get priority) + const shortColumnThreshold = 15 + + // Pass 1: Give ID (index 0) and short columns their full natural width + remainingWidth := availableWidth + longColumnIndices := []int{} + + for i := 0; i < numCols; i++ { + if i == 0 || naturalWidths[i] <= shortColumnThreshold { + // Short column or ID - give full natural width + result[i] = naturalWidths[i] + remainingWidth -= naturalWidths[i] + } else { + // Long column - defer to pass 2 + longColumnIndices = append(longColumnIndices, i) + } } - // Now distribute remaining width among other columns (excluding ID) - if numCols <= 1 { + // Pass 2: Distribute remaining space among long columns + if len(longColumnIndices) == 0 { return result } - // Calculate needs for non-ID columns - otherCols := numCols - 1 - totalMinForOthers := 0 - totalNaturalForOthers := 0 - for i := 1; i < numCols; i++ { - totalMinForOthers += minWidths[i] - totalNaturalForOthers += naturalWidths[i] + // Calculate how much long columns want + totalLongNatural := 0 + totalLongMin := 0 + for _, idx := range longColumnIndices { + totalLongNatural += naturalWidths[idx] + totalLongMin += minWidths[idx] } - if totalNaturalForOthers <= availableWidth { - // Other columns fit naturally - for i := 1; i < numCols; i++ { - result[i] = naturalWidths[i] + if totalLongNatural <= remainingWidth { + // Long columns fit naturally + for _, idx := range longColumnIndices { + result[idx] = naturalWidths[idx] } return result } - if totalMinForOthers > availableWidth { - // Even minimums don't fit for other columns, distribute equally - for i := 1; i < numCols; i++ { - result[i] = availableWidth / otherCols - if result[i] < 5 { - result[i] = 5 // absolute minimum + if totalLongMin > remainingWidth { + // Even minimums don't fit, distribute equally + for _, idx := range longColumnIndices { + result[idx] = remainingWidth / len(longColumnIndices) + if result[idx] < 5 { + result[idx] = 5 // absolute minimum } } return result } - // Give other columns minimum, then distribute remainder proportionally - remainingWidth := availableWidth - totalMinForOthers - remainingNeed := totalNaturalForOthers - totalMinForOthers + // Give long columns minimum, then distribute remainder proportionally + extraSpace := remainingWidth - totalLongMin + extraNeed := totalLongNatural - totalLongMin - for i := 1; i < numCols; i++ { - result[i] = minWidths[i] - if remainingNeed > 0 { - additionalNeed := naturalWidths[i] - minWidths[i] - additionalGrant := (additionalNeed * remainingWidth) / remainingNeed - result[i] += additionalGrant + for _, idx := range longColumnIndices { + result[idx] = minWidths[idx] + if extraNeed > 0 { + additionalNeed := naturalWidths[idx] - minWidths[idx] + additionalGrant := (additionalNeed * extraSpace) / extraNeed + result[idx] += additionalGrant } } @@ -163,19 +178,26 @@ func distributeColumnWidths(naturalWidths, minWidths []int, availableWidth int) } // truncateCell truncates a cell to maxWidth, adding "..." if truncated +// Handles ANSI color codes properly by measuring visible characters only func truncateCell(cell string, maxWidth int) string { - cellWidth := utf8.RuneCountInString(cell) + // Strip ANSI codes to measure visible width + visibleText := pterm.RemoveColorFromString(cell) + cellWidth := utf8.RuneCountInString(visibleText) + if cellWidth <= maxWidth { return cell } + // Cell needs truncation + // If the cell has color codes, we need to be careful about truncation + // For simplicity, strip colors, truncate, then return without color if maxWidth <= 3 { // Too narrow for "...", just truncate - return truncateString(cell, maxWidth) + return truncateString(visibleText, maxWidth) } // Truncate and add "..." - return truncateString(cell, maxWidth-3) + "..." + return truncateString(visibleText, maxWidth-3) + "..." } // truncateString truncates a string to the specified number of runes diff --git a/cmd/proxies/list_test.go b/cmd/proxies/list_test.go index d091543..aada9d8 100644 --- a/cmd/proxies/list_test.go +++ b/cmd/proxies/list_test.go @@ -64,24 +64,24 @@ func TestProxyList_WithProxies(t *testing.T) { assert.NoError(t, err) output := buf.String() - // Check table headers + // Check table headers (Config may be truncated in narrow terminals) assert.Contains(t, output, "ID") assert.Contains(t, output, "Name") assert.Contains(t, output, "Type") assert.Contains(t, output, "Protocol") - assert.Contains(t, output, "Config") + assert.Contains(t, output, "Status") - // Check proxy data + // Check proxy data - verify IDs and short columns are fully visible assert.Contains(t, output, "dc-1") assert.Contains(t, output, "https") // Protocol is shown - assert.Contains(t, output, "Country") + assert.Contains(t, output, "datacenter") assert.Contains(t, output, "res-1") + assert.Contains(t, output, "residential") assert.Contains(t, output, "custom-1") assert.Contains(t, output, "My Proxy") assert.Contains(t, output, "custom") - assert.Contains(t, output, "proxy") // Part of proxy.example.com, will be truncated assert.Contains(t, output, "mobile-1") assert.Contains(t, output, "mobile") @@ -89,7 +89,6 @@ func TestProxyList_WithProxies(t *testing.T) { assert.Contains(t, output, "isp-1") assert.Contains(t, output, "-") // Empty name shows as "-" assert.Contains(t, output, "isp") - assert.Contains(t, output, "EU") // Country value } func TestProxyList_Error(t *testing.T) { diff --git a/cmd/table.go b/cmd/table.go index bd9f438..b978765 100644 --- a/cmd/table.go +++ b/cmd/table.go @@ -141,12 +141,15 @@ func truncateTableData(data pterm.TableData, termWidth int) pterm.TableData { } // Calculate natural widths (what each column would want) + // Strip color codes to measure visible characters only naturalWidths := make([]int, numCols) for colIdx := 0; colIdx < numCols; colIdx++ { maxWidth := 0 for _, row := range data { if colIdx < len(row) { - cellWidth := utf8.RuneCountInString(row[colIdx]) + // Strip ANSI color codes to get visible character count + visibleText := pterm.RemoveColorFromString(row[colIdx]) + cellWidth := utf8.RuneCountInString(visibleText) if cellWidth > maxWidth { maxWidth = cellWidth } @@ -177,8 +180,9 @@ func truncateTableData(data pterm.TableData, termWidth int) pterm.TableData { return result } -// distributeColumnWidths calculates optimal width for each column -// The first column (typically ID) is always given its full natural width and never truncated +// distributeColumnWidths calculates optimal width for each column using a two-pass strategy: +// Pass 1: ID and short columns get their full natural width +// Pass 2: Long columns share the remaining space func distributeColumnWidths(naturalWidths, minWidths []int, availableWidth int) []int { numCols := len(naturalWidths) result := make([]int, numCols) @@ -197,55 +201,66 @@ func distributeColumnWidths(naturalWidths, minWidths []int, availableWidth int) return result } - // Priority: Always give the first column its full natural width - if numCols > 0 { - result[0] = naturalWidths[0] - availableWidth -= naturalWidths[0] + // Define threshold for "short" columns (these get priority) + const shortColumnThreshold = 15 + + // Pass 1: Give ID (index 0) and short columns their full natural width + remainingWidth := availableWidth + longColumnIndices := []int{} + + for i := 0; i < numCols; i++ { + if i == 0 || naturalWidths[i] <= shortColumnThreshold { + // Short column or ID - give full natural width + result[i] = naturalWidths[i] + remainingWidth -= naturalWidths[i] + } else { + // Long column - defer to pass 2 + longColumnIndices = append(longColumnIndices, i) + } } - // Now distribute remaining width among other columns (excluding first) - if numCols <= 1 { + // Pass 2: Distribute remaining space among long columns + if len(longColumnIndices) == 0 { return result } - // Calculate needs for non-first columns - otherCols := numCols - 1 - totalMinForOthers := 0 - totalNaturalForOthers := 0 - for i := 1; i < numCols; i++ { - totalMinForOthers += minWidths[i] - totalNaturalForOthers += naturalWidths[i] + // Calculate how much long columns want + totalLongNatural := 0 + totalLongMin := 0 + for _, idx := range longColumnIndices { + totalLongNatural += naturalWidths[idx] + totalLongMin += minWidths[idx] } - if totalNaturalForOthers <= availableWidth { - // Other columns fit naturally - for i := 1; i < numCols; i++ { - result[i] = naturalWidths[i] + if totalLongNatural <= remainingWidth { + // Long columns fit naturally + for _, idx := range longColumnIndices { + result[idx] = naturalWidths[idx] } return result } - if totalMinForOthers > availableWidth { - // Even minimums don't fit for other columns, distribute equally - for i := 1; i < numCols; i++ { - result[i] = availableWidth / otherCols - if result[i] < 5 { - result[i] = 5 // absolute minimum + if totalLongMin > remainingWidth { + // Even minimums don't fit, distribute equally + for _, idx := range longColumnIndices { + result[idx] = remainingWidth / len(longColumnIndices) + if result[idx] < 5 { + result[idx] = 5 // absolute minimum } } return result } - // Give other columns minimum, then distribute remainder proportionally - remainingWidth := availableWidth - totalMinForOthers - remainingNeed := totalNaturalForOthers - totalMinForOthers + // Give long columns minimum, then distribute remainder proportionally + extraSpace := remainingWidth - totalLongMin + extraNeed := totalLongNatural - totalLongMin - for i := 1; i < numCols; i++ { - result[i] = minWidths[i] - if remainingNeed > 0 { - additionalNeed := naturalWidths[i] - minWidths[i] - additionalGrant := (additionalNeed * remainingWidth) / remainingNeed - result[i] += additionalGrant + for _, idx := range longColumnIndices { + result[idx] = minWidths[idx] + if extraNeed > 0 { + additionalNeed := naturalWidths[idx] - minWidths[idx] + additionalGrant := (additionalNeed * extraSpace) / extraNeed + result[idx] += additionalGrant } } @@ -253,19 +268,26 @@ func distributeColumnWidths(naturalWidths, minWidths []int, availableWidth int) } // truncateCell truncates a cell to maxWidth, adding "..." if truncated +// Handles ANSI color codes properly by measuring visible characters only func truncateCell(cell string, maxWidth int) string { - cellWidth := utf8.RuneCountInString(cell) + // Strip ANSI codes to measure visible width + visibleText := pterm.RemoveColorFromString(cell) + cellWidth := utf8.RuneCountInString(visibleText) + if cellWidth <= maxWidth { return cell } + // Cell needs truncation + // If the cell has color codes, we need to be careful about truncation + // For simplicity, strip colors, truncate, then return without color if maxWidth <= 3 { // Too narrow for "...", just truncate - return truncateString(cell, maxWidth) + return truncateString(visibleText, maxWidth) } // Truncate and add "..." - return truncateString(cell, maxWidth-3) + "..." + return truncateString(visibleText, maxWidth-3) + "..." } // truncateString truncates a string to the specified number of runes From 01b7e204ea4487ad12a5ceaadb65e96d9ac4bffd Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 3 Oct 2025 17:20:52 -0400 Subject: [PATCH 3/6] Add status and last checked to kernel proxies get --- cmd/proxies/get.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cmd/proxies/get.go b/cmd/proxies/get.go index 73171a1..dd10c69 100644 --- a/cmd/proxies/get.go +++ b/cmd/proxies/get.go @@ -38,6 +38,21 @@ func (p ProxyCmd) Get(ctx context.Context, in ProxyGetInput) error { // Display type-specific config details rows = append(rows, getProxyConfigRows(item)...) + // Display status with color + status := string(item.Status) + if status == "" { + status = "-" + } else if status == "available" { + status = pterm.Green(status) + } else if status == "unavailable" { + status = pterm.Red(status) + } + rows = append(rows, []string{"Status", status}) + + // Display last checked timestamp + lastChecked := util.FormatLocal(item.LastChecked) + rows = append(rows, []string{"Last Checked", lastChecked}) + PrintTableNoPad(rows, true) return nil } From 3e7715207180986e59047a63e597cc115be335e8 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 3 Oct 2025 17:33:56 -0400 Subject: [PATCH 4/6] Clean up table function --- cmd/app.go | 4 +- cmd/browsers.go | 18 +- cmd/profiles.go | 6 +- cmd/proxies/create.go | 3 +- cmd/proxies/get.go | 3 +- cmd/proxies/list.go | 3 +- cmd/table.go | 302 +------------------ cmd/proxies/helpers.go => pkg/table/table.go | 114 ++++++- 8 files changed, 126 insertions(+), 327 deletions(-) rename cmd/proxies/helpers.go => pkg/table/table.go (64%) diff --git a/cmd/app.go b/cmd/app.go index 7ddc933..11d90ed 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -99,7 +99,7 @@ func runAppList(cmd *cobra.Command, args []string) error { }) } - printTableNoPad(tableData, true) + PrintTableNoPad(tableData, true) return nil } @@ -150,6 +150,6 @@ func runAppHistory(cmd *cobra.Command, args []string) error { } } - printTableNoPad(tableData, true) + PrintTableNoPad(tableData, true) return nil } diff --git a/cmd/browsers.go b/cmd/browsers.go index fd0152f..eef9a55 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -144,7 +144,7 @@ func (b BrowsersCmd) List(ctx context.Context) error { }) } - printTableNoPad(tableData, true) + PrintTableNoPad(tableData, true) return nil } @@ -208,7 +208,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { tableData = append(tableData, []string{"Profile", profVal}) } - printTableNoPad(tableData, true) + PrintTableNoPad(tableData, true) return nil } @@ -415,7 +415,7 @@ func (b BrowsersCmd) ReplaysList(ctx context.Context, in BrowsersReplaysListInpu for _, r := range *items { rows = append(rows, []string{r.ReplayID, util.FormatLocal(r.StartedAt), util.FormatLocal(r.FinishedAt), truncateURL(r.ReplayViewURL, 60)}) } - printTableNoPad(rows, true) + PrintTableNoPad(rows, true) return nil } @@ -440,7 +440,7 @@ func (b BrowsersCmd) ReplaysStart(ctx context.Context, in BrowsersReplaysStartIn return util.CleanedUpSdkError{Err: err} } rows := pterm.TableData{{"Property", "Value"}, {"Replay ID", res.ReplayID}, {"View URL", res.ReplayViewURL}, {"Started At", util.FormatLocal(res.StartedAt)}} - printTableNoPad(rows, true) + PrintTableNoPad(rows, true) return nil } @@ -563,7 +563,7 @@ func (b BrowsersCmd) ProcessExec(ctx context.Context, in BrowsersProcessExecInpu return util.CleanedUpSdkError{Err: err} } rows := pterm.TableData{{"Property", "Value"}, {"Exit Code", fmt.Sprintf("%d", res.ExitCode)}, {"Duration (ms)", fmt.Sprintf("%d", res.DurationMs)}} - printTableNoPad(rows, true) + PrintTableNoPad(rows, true) if res.StdoutB64 != "" { data, err := base64.StdEncoding.DecodeString(res.StdoutB64) if err != nil { @@ -625,7 +625,7 @@ func (b BrowsersCmd) ProcessSpawn(ctx context.Context, in BrowsersProcessSpawnIn return util.CleanedUpSdkError{Err: err} } rows := pterm.TableData{{"Property", "Value"}, {"Process ID", res.ProcessID}, {"PID", fmt.Sprintf("%d", res.Pid)}, {"Started At", util.FormatLocal(res.StartedAt)}} - printTableNoPad(rows, true) + PrintTableNoPad(rows, true) return nil } @@ -669,7 +669,7 @@ func (b BrowsersCmd) ProcessStatus(ctx context.Context, in BrowsersProcessStatus return util.CleanedUpSdkError{Err: err} } rows := pterm.TableData{{"Property", "Value"}, {"State", string(res.State)}, {"CPU %", fmt.Sprintf("%.2f", res.CPUPct)}, {"Mem Bytes", fmt.Sprintf("%d", res.MemBytes)}, {"Exit Code", fmt.Sprintf("%d", res.ExitCode)}} - printTableNoPad(rows, true) + PrintTableNoPad(rows, true) return nil } @@ -928,7 +928,7 @@ func (b BrowsersCmd) FSFileInfo(ctx context.Context, in BrowsersFSFileInfoInput) return util.CleanedUpSdkError{Err: err} } rows := pterm.TableData{{"Property", "Value"}, {"Path", res.Path}, {"Name", res.Name}, {"Mode", res.Mode}, {"IsDir", fmt.Sprintf("%t", res.IsDir)}, {"SizeBytes", fmt.Sprintf("%d", res.SizeBytes)}, {"ModTime", util.FormatLocal(res.ModTime)}} - printTableNoPad(rows, true) + PrintTableNoPad(rows, true) return nil } @@ -957,7 +957,7 @@ func (b BrowsersCmd) FSListFiles(ctx context.Context, in BrowsersFSListFilesInpu for _, f := range *res { rows = append(rows, []string{f.Mode, fmt.Sprintf("%d", f.SizeBytes), util.FormatLocal(f.ModTime), f.Name, f.Path}) } - printTableNoPad(rows, true) + PrintTableNoPad(rows, true) return nil } diff --git a/cmd/profiles.go b/cmd/profiles.go index 3243c35..5a78311 100644 --- a/cmd/profiles.go +++ b/cmd/profiles.go @@ -73,7 +73,7 @@ func (p ProfilesCmd) List(ctx context.Context) error { util.FormatLocal(prof.LastUsedAt), }) } - printTableNoPad(rows, true) + PrintTableNoPad(rows, true) return nil } @@ -96,7 +96,7 @@ func (p ProfilesCmd) Get(ctx context.Context, in ProfilesGetInput) error { rows = append(rows, []string{"Created At", util.FormatLocal(item.CreatedAt)}) rows = append(rows, []string{"Updated At", util.FormatLocal(item.UpdatedAt)}) rows = append(rows, []string{"Last Used At", util.FormatLocal(item.LastUsedAt)}) - printTableNoPad(rows, true) + PrintTableNoPad(rows, true) return nil } @@ -118,7 +118,7 @@ func (p ProfilesCmd) Create(ctx context.Context, in ProfilesCreateInput) error { rows = append(rows, []string{"Name", name}) rows = append(rows, []string{"Created At", util.FormatLocal(item.CreatedAt)}) rows = append(rows, []string{"Last Used At", util.FormatLocal(item.LastUsedAt)}) - printTableNoPad(rows, true) + PrintTableNoPad(rows, true) return nil } diff --git a/cmd/proxies/create.go b/cmd/proxies/create.go index fb4dbc6..c109bcb 100644 --- a/cmd/proxies/create.go +++ b/cmd/proxies/create.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/onkernel/cli/pkg/table" "github.com/onkernel/cli/pkg/util" "github.com/onkernel/kernel-go-sdk" "github.com/pterm/pterm" @@ -188,7 +189,7 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { } rows = append(rows, []string{"Protocol", protocol}) - PrintTableNoPad(rows, true) + table.PrintTableNoPad(rows, true) return nil } diff --git a/cmd/proxies/get.go b/cmd/proxies/get.go index dd10c69..49c89ee 100644 --- a/cmd/proxies/get.go +++ b/cmd/proxies/get.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/onkernel/cli/pkg/table" "github.com/onkernel/cli/pkg/util" "github.com/onkernel/kernel-go-sdk" "github.com/pterm/pterm" @@ -53,7 +54,7 @@ func (p ProxyCmd) Get(ctx context.Context, in ProxyGetInput) error { lastChecked := util.FormatLocal(item.LastChecked) rows = append(rows, []string{"Last Checked", lastChecked}) - PrintTableNoPad(rows, true) + table.PrintTableNoPad(rows, true) return nil } diff --git a/cmd/proxies/list.go b/cmd/proxies/list.go index f2c318e..e5a86f5 100644 --- a/cmd/proxies/list.go +++ b/cmd/proxies/list.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/onkernel/cli/pkg/table" "github.com/onkernel/cli/pkg/util" "github.com/onkernel/kernel-go-sdk" "github.com/pterm/pterm" @@ -68,7 +69,7 @@ func (p ProxyCmd) List(ctx context.Context) error { }) } - PrintTableNoPad(tableData, true) + table.PrintTableNoPad(tableData, true) return nil } diff --git a/cmd/table.go b/cmd/table.go index b978765..f3cd8a0 100644 --- a/cmd/table.go +++ b/cmd/table.go @@ -1,305 +1,11 @@ package cmd import ( - "strings" - "unicode/utf8" - + "github.com/onkernel/cli/pkg/table" "github.com/pterm/pterm" ) -// printTableNoPad renders a table similar to pterm.DefaultTable, but it avoids -// adding trailing padding spaces after the last column and does not add blank -// padded lines to match multi-line cells in other columns. The last column may -// contain multi-line content which will be printed as-is on following lines. -// It also intelligently truncates columns to prevent line wrapping. -func printTableNoPad(data pterm.TableData, hasHeader bool) { - if len(data) == 0 { - return - } - - // Get terminal width and truncate data to fit - termWidth := pterm.GetTerminalWidth() - if termWidth <= 0 { - termWidth = 80 // fallback - } - data = truncateTableData(data, termWidth) - - // Determine number of columns from the first row - numCols := len(data[0]) - if numCols == 0 { - return - } - - // Pre-compute max width per column for all but the last column - maxColWidths := make([]int, numCols) - for _, row := range data { - for colIdx := 0; colIdx < numCols && colIdx < len(row); colIdx++ { - if colIdx == numCols-1 { - continue - } - for _, line := range strings.Split(row[colIdx], "\n") { - if w := utf8.RuneCountInString(line); w > maxColWidths[colIdx] { - maxColWidths[colIdx] = w - } - } - } - } - - var b strings.Builder - sep := pterm.DefaultTable.Separator - sepStyled := pterm.ThemeDefault.TableSeparatorStyle.Sprint(sep) - - renderRow := func(row []string, styleHeader bool) { - // Build first-line-only for non-last columns; last column is full string - firstLineParts := make([]string, 0, numCols) - for colIdx := 0; colIdx < numCols; colIdx++ { - var cell string - if colIdx < len(row) { - cell = row[colIdx] - } - - if colIdx < numCols-1 { - // Only the first line for non-last columns - lines := strings.Split(cell, "\n") - first := "" - if len(lines) > 0 { - first = lines[0] - } - padCount := maxColWidths[colIdx] - utf8.RuneCountInString(first) - if padCount < 0 { - padCount = 0 - } - firstLineParts = append(firstLineParts, first+strings.Repeat(" ", padCount)) - } else { - // Last column: render the first line now; remaining lines after - lines := strings.Split(cell, "\n") - if len(lines) > 0 { - firstLineParts = append(firstLineParts, lines[0]) - } else { - firstLineParts = append(firstLineParts, "") - } - } - } - - line := strings.Join(firstLineParts[:numCols-1], sepStyled) - if numCols > 1 { - if line != "" { - line += sepStyled - } - line += firstLineParts[numCols-1] - } - - if styleHeader { - b.WriteString(pterm.ThemeDefault.TableHeaderStyle.Sprint(line)) - } else { - b.WriteString(line) - } - b.WriteString("\n") - - // Print remaining lines from the last column without alignment padding - if numCols > 0 { - var lastCell string - if len(row) >= numCols { - lastCell = row[numCols-1] - } - lines := strings.Split(lastCell, "\n") - if len(lines) > 1 { - rest := strings.Join(lines[1:], "\n") - if rest != "" { - b.WriteString(rest) - b.WriteString("\n") - } - } - } - } - - for idx, row := range data { - renderRow(row, hasHeader && idx == 0) - } - - pterm.Print(b.String()) -} - -// truncateTableData intelligently truncates table cells to fit within terminal width -func truncateTableData(data pterm.TableData, termWidth int) pterm.TableData { - if len(data) == 0 { - return data - } - - numCols := len(data[0]) - if numCols == 0 { - return data - } - - // Calculate separator space: " | " between each column (3 chars per separator) - separatorSpace := (numCols - 1) * 3 - - // Define minimum column widths (these are the bare minimum before aggressive truncation) - minWidths := make([]int, numCols) - for i := 0; i < numCols; i++ { - minWidths[i] = 8 // minimum 8 chars per column - } - - // Calculate natural widths (what each column would want) - // Strip color codes to measure visible characters only - naturalWidths := make([]int, numCols) - for colIdx := 0; colIdx < numCols; colIdx++ { - maxWidth := 0 - for _, row := range data { - if colIdx < len(row) { - // Strip ANSI color codes to get visible character count - visibleText := pterm.RemoveColorFromString(row[colIdx]) - cellWidth := utf8.RuneCountInString(visibleText) - if cellWidth > maxWidth { - maxWidth = cellWidth - } - } - } - naturalWidths[colIdx] = maxWidth - } - - // Calculate available space for content - availableWidth := termWidth - separatorSpace - 2 // -2 for margins - - // Distribute width among columns - columnWidths := distributeColumnWidths(naturalWidths, minWidths, availableWidth) - - // Truncate cells based on calculated widths - result := make(pterm.TableData, len(data)) - for rowIdx, row := range data { - result[rowIdx] = make([]string, len(row)) - for colIdx, cell := range row { - if colIdx < len(columnWidths) { - result[rowIdx][colIdx] = truncateCell(cell, columnWidths[colIdx]) - } else { - result[rowIdx][colIdx] = cell - } - } - } - - return result -} - -// distributeColumnWidths calculates optimal width for each column using a two-pass strategy: -// Pass 1: ID and short columns get their full natural width -// Pass 2: Long columns share the remaining space -func distributeColumnWidths(naturalWidths, minWidths []int, availableWidth int) []int { - numCols := len(naturalWidths) - result := make([]int, numCols) - - // Start with natural widths - copy(result, naturalWidths) - - // Calculate total natural width needed - totalNatural := 0 - for _, w := range naturalWidths { - totalNatural += w - } - - // If natural widths fit, use them - if totalNatural <= availableWidth { - return result - } - - // Define threshold for "short" columns (these get priority) - const shortColumnThreshold = 15 - - // Pass 1: Give ID (index 0) and short columns their full natural width - remainingWidth := availableWidth - longColumnIndices := []int{} - - for i := 0; i < numCols; i++ { - if i == 0 || naturalWidths[i] <= shortColumnThreshold { - // Short column or ID - give full natural width - result[i] = naturalWidths[i] - remainingWidth -= naturalWidths[i] - } else { - // Long column - defer to pass 2 - longColumnIndices = append(longColumnIndices, i) - } - } - - // Pass 2: Distribute remaining space among long columns - if len(longColumnIndices) == 0 { - return result - } - - // Calculate how much long columns want - totalLongNatural := 0 - totalLongMin := 0 - for _, idx := range longColumnIndices { - totalLongNatural += naturalWidths[idx] - totalLongMin += minWidths[idx] - } - - if totalLongNatural <= remainingWidth { - // Long columns fit naturally - for _, idx := range longColumnIndices { - result[idx] = naturalWidths[idx] - } - return result - } - - if totalLongMin > remainingWidth { - // Even minimums don't fit, distribute equally - for _, idx := range longColumnIndices { - result[idx] = remainingWidth / len(longColumnIndices) - if result[idx] < 5 { - result[idx] = 5 // absolute minimum - } - } - return result - } - - // Give long columns minimum, then distribute remainder proportionally - extraSpace := remainingWidth - totalLongMin - extraNeed := totalLongNatural - totalLongMin - - for _, idx := range longColumnIndices { - result[idx] = minWidths[idx] - if extraNeed > 0 { - additionalNeed := naturalWidths[idx] - minWidths[idx] - additionalGrant := (additionalNeed * extraSpace) / extraNeed - result[idx] += additionalGrant - } - } - - return result -} - -// truncateCell truncates a cell to maxWidth, adding "..." if truncated -// Handles ANSI color codes properly by measuring visible characters only -func truncateCell(cell string, maxWidth int) string { - // Strip ANSI codes to measure visible width - visibleText := pterm.RemoveColorFromString(cell) - cellWidth := utf8.RuneCountInString(visibleText) - - if cellWidth <= maxWidth { - return cell - } - - // Cell needs truncation - // If the cell has color codes, we need to be careful about truncation - // For simplicity, strip colors, truncate, then return without color - if maxWidth <= 3 { - // Too narrow for "...", just truncate - return truncateString(visibleText, maxWidth) - } - - // Truncate and add "..." - return truncateString(visibleText, maxWidth-3) + "..." -} - -// truncateString truncates a string to the specified number of runes -func truncateString(s string, maxRunes int) string { - if maxRunes <= 0 { - return "" - } - - runes := []rune(s) - if len(runes) <= maxRunes { - return s - } - - return string(runes[:maxRunes]) +// PrintTableNoPad is a wrapper around pkg/table.PrintTableNoPad for backwards compatibility +func PrintTableNoPad(data pterm.TableData, hasHeader bool) { + table.PrintTableNoPad(data, hasHeader) } diff --git a/cmd/proxies/helpers.go b/pkg/table/table.go similarity index 64% rename from cmd/proxies/helpers.go rename to pkg/table/table.go index e553286..99cad4b 100644 --- a/cmd/proxies/helpers.go +++ b/pkg/table/table.go @@ -1,33 +1,123 @@ -package proxies +package table import ( + "strings" "unicode/utf8" "github.com/pterm/pterm" ) -// PrintTableNoPad prints a table with intelligent column truncation to prevent wrapping. -// It detects terminal width and truncates cells that would cause line wrapping, -// adding "..." to indicate truncation. -func PrintTableNoPad(data pterm.TableData, withRowSeparators bool) { +// PrintTableNoPad renders a table similar to pterm.DefaultTable, but it avoids +// adding trailing padding spaces after the last column and does not add blank +// padded lines to match multi-line cells in other columns. The last column may +// contain multi-line content which will be printed as-is on following lines. +// It also intelligently truncates columns to prevent line wrapping. +func PrintTableNoPad(data pterm.TableData, hasHeader bool) { if len(data) == 0 { return } - // Get terminal width + // Get terminal width and truncate data to fit termWidth := pterm.GetTerminalWidth() if termWidth <= 0 { termWidth = 80 // fallback } + data = truncateTableData(data, termWidth) - // Truncate data to fit terminal width - truncatedData := truncateTableData(data, termWidth) + // Determine number of columns from the first row + numCols := len(data[0]) + if numCols == 0 { + return + } + + // Pre-compute max width per column for all but the last column + maxColWidths := make([]int, numCols) + for _, row := range data { + for colIdx := 0; colIdx < numCols && colIdx < len(row); colIdx++ { + if colIdx == numCols-1 { + continue + } + for _, line := range strings.Split(row[colIdx], "\n") { + if w := utf8.RuneCountInString(line); w > maxColWidths[colIdx] { + maxColWidths[colIdx] = w + } + } + } + } + + var b strings.Builder + sep := pterm.DefaultTable.Separator + sepStyled := pterm.ThemeDefault.TableSeparatorStyle.Sprint(sep) + + renderRow := func(row []string, styleHeader bool) { + // Build first-line-only for non-last columns; last column is full string + firstLineParts := make([]string, 0, numCols) + for colIdx := 0; colIdx < numCols; colIdx++ { + var cell string + if colIdx < len(row) { + cell = row[colIdx] + } + + if colIdx < numCols-1 { + // Only the first line for non-last columns + lines := strings.Split(cell, "\n") + first := "" + if len(lines) > 0 { + first = lines[0] + } + padCount := maxColWidths[colIdx] - utf8.RuneCountInString(first) + if padCount < 0 { + padCount = 0 + } + firstLineParts = append(firstLineParts, first+strings.Repeat(" ", padCount)) + } else { + // Last column: render the first line now; remaining lines after + lines := strings.Split(cell, "\n") + if len(lines) > 0 { + firstLineParts = append(firstLineParts, lines[0]) + } else { + firstLineParts = append(firstLineParts, "") + } + } + } - table := pterm.DefaultTable.WithHasHeader().WithData(truncatedData) - if withRowSeparators { - table = table.WithRowSeparator("-") + line := strings.Join(firstLineParts[:numCols-1], sepStyled) + if numCols > 1 { + if line != "" { + line += sepStyled + } + line += firstLineParts[numCols-1] + } + + if styleHeader { + b.WriteString(pterm.ThemeDefault.TableHeaderStyle.Sprint(line)) + } else { + b.WriteString(line) + } + b.WriteString("\n") + + // Print remaining lines from the last column without alignment padding + if numCols > 0 { + var lastCell string + if len(row) >= numCols { + lastCell = row[numCols-1] + } + lines := strings.Split(lastCell, "\n") + if len(lines) > 1 { + rest := strings.Join(lines[1:], "\n") + if rest != "" { + b.WriteString(rest) + b.WriteString("\n") + } + } + } + } + + for idx, row := range data { + renderRow(row, hasHeader && idx == 0) } - _ = table.Render() + + pterm.Print(b.String()) } // truncateTableData intelligently truncates table cells to fit within terminal width From d911c6582e14b87e0785abbe6e72b2efd317d531 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 3 Oct 2025 17:35:23 -0400 Subject: [PATCH 5/6] Alias apps --- cmd/app.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/app.go b/cmd/app.go index 11d90ed..58cc21e 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -11,9 +11,10 @@ import ( ) var appCmd = &cobra.Command{ - Use: "app", - Short: "Manage deployed applications", - Long: "Commands for managing deployed Kernel applications", + Use: "app", + Aliases: []string{"apps"}, + Short: "Manage deployed applications", + Long: "Commands for managing deployed Kernel applications", } // --- app list subcommand From ee81c6bc8dba6d243e8804062c7b0dd50b5ffdf8 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 3 Oct 2025 17:38:09 -0400 Subject: [PATCH 6/6] Fix padding --- pkg/table/table.go | 68 +++++++++++++--------------------------------- 1 file changed, 19 insertions(+), 49 deletions(-) diff --git a/pkg/table/table.go b/pkg/table/table.go index 99cad4b..a1ad832 100644 --- a/pkg/table/table.go +++ b/pkg/table/table.go @@ -30,15 +30,14 @@ func PrintTableNoPad(data pterm.TableData, hasHeader bool) { return } - // Pre-compute max width per column for all but the last column + // Pre-compute max width per column (including last column for proper alignment) maxColWidths := make([]int, numCols) for _, row := range data { for colIdx := 0; colIdx < numCols && colIdx < len(row); colIdx++ { - if colIdx == numCols-1 { - continue - } for _, line := range strings.Split(row[colIdx], "\n") { - if w := utf8.RuneCountInString(line); w > maxColWidths[colIdx] { + // Strip color codes for accurate width measurement + visibleLine := pterm.RemoveColorFromString(line) + if w := utf8.RuneCountInString(visibleLine); w > maxColWidths[colIdx] { maxColWidths[colIdx] = w } } @@ -50,67 +49,38 @@ func PrintTableNoPad(data pterm.TableData, hasHeader bool) { sepStyled := pterm.ThemeDefault.TableSeparatorStyle.Sprint(sep) renderRow := func(row []string, styleHeader bool) { - // Build first-line-only for non-last columns; last column is full string - firstLineParts := make([]string, 0, numCols) + // Build and pad all columns for proper alignment + parts := make([]string, 0, numCols) for colIdx := 0; colIdx < numCols; colIdx++ { var cell string if colIdx < len(row) { cell = row[colIdx] } - if colIdx < numCols-1 { - // Only the first line for non-last columns - lines := strings.Split(cell, "\n") - first := "" - if len(lines) > 0 { - first = lines[0] - } - padCount := maxColWidths[colIdx] - utf8.RuneCountInString(first) - if padCount < 0 { - padCount = 0 - } - firstLineParts = append(firstLineParts, first+strings.Repeat(" ", padCount)) - } else { - // Last column: render the first line now; remaining lines after - lines := strings.Split(cell, "\n") - if len(lines) > 0 { - firstLineParts = append(firstLineParts, lines[0]) - } else { - firstLineParts = append(firstLineParts, "") - } + // Get first line only + lines := strings.Split(cell, "\n") + first := "" + if len(lines) > 0 { + first = lines[0] } - } - line := strings.Join(firstLineParts[:numCols-1], sepStyled) - if numCols > 1 { - if line != "" { - line += sepStyled + // Pad to column width (measure visible chars, accounting for color codes) + visibleFirst := pterm.RemoveColorFromString(first) + padCount := maxColWidths[colIdx] - utf8.RuneCountInString(visibleFirst) + if padCount < 0 { + padCount = 0 } - line += firstLineParts[numCols-1] + parts = append(parts, first+strings.Repeat(" ", padCount)) } + line := strings.Join(parts, sepStyled) + if styleHeader { b.WriteString(pterm.ThemeDefault.TableHeaderStyle.Sprint(line)) } else { b.WriteString(line) } b.WriteString("\n") - - // Print remaining lines from the last column without alignment padding - if numCols > 0 { - var lastCell string - if len(row) >= numCols { - lastCell = row[numCols-1] - } - lines := strings.Split(lastCell, "\n") - if len(lines) > 1 { - rest := strings.Join(lines[1:], "\n") - if rest != "" { - b.WriteString(rest) - b.WriteString("\n") - } - } - } } for idx, row := range data {