diff --git a/cmd/app.go b/cmd/app.go index 7c4dba8..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 @@ -80,9 +81,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 +88,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{ @@ -105,7 +100,7 @@ func runAppList(cmd *cobra.Command, args []string) error { }) } - printTableNoPad(tableData, true) + PrintTableNoPad(tableData, true) return nil } @@ -156,6 +151,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 73171a1..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" @@ -38,7 +39,22 @@ func (p ProxyCmd) Get(ctx context.Context, in ProxyGetInput) error { // Display type-specific config details rows = append(rows, getProxyConfigRows(item)...) - PrintTableNoPad(rows, true) + // 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}) + + table.PrintTableNoPad(rows, true) return nil } diff --git a/cmd/proxies/helpers.go b/cmd/proxies/helpers.go deleted file mode 100644 index 59b4853..0000000 --- a/cmd/proxies/helpers.go +++ /dev/null @@ -1,14 +0,0 @@ -package proxies - -import ( - "github.com/pterm/pterm" -) - -// PrintTableNoPad prints a table without padding -func PrintTableNoPad(data pterm.TableData, withRowSeparators bool) { - table := pterm.DefaultTable.WithHasHeader().WithData(data) - if withRowSeparators { - table = table.WithRowSeparator("-") - } - _ = table.Render() -} 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/proxies/list_test.go b/cmd/proxies/list_test.go index a6b0eae..aada9d8 100644 --- a/cmd/proxies/list_test.go +++ b/cmd/proxies/list_test.go @@ -64,33 +64,31 @@ 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") - 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") } func TestProxyList_Error(t *testing.T) { diff --git a/cmd/table.go b/cmd/table.go index d00906b..f3cd8a0 100644 --- a/cmd/table.go +++ b/cmd/table.go @@ -1,113 +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. -func printTableNoPad(data pterm.TableData, hasHeader bool) { - if len(data) == 0 { - return - } - - // 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()) +// 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/pkg/table/table.go b/pkg/table/table.go new file mode 100644 index 0000000..a1ad832 --- /dev/null +++ b/pkg/table/table.go @@ -0,0 +1,275 @@ +package table + +import ( + "strings" + "unicode/utf8" + + "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 (including last column for proper alignment) + maxColWidths := make([]int, numCols) + for _, row := range data { + for colIdx := 0; colIdx < numCols && colIdx < len(row); colIdx++ { + for _, line := range strings.Split(row[colIdx], "\n") { + // Strip color codes for accurate width measurement + visibleLine := pterm.RemoveColorFromString(line) + if w := utf8.RuneCountInString(visibleLine); 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 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] + } + + // Get first line only + lines := strings.Split(cell, "\n") + first := "" + if len(lines) > 0 { + first = lines[0] + } + + // 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 + } + 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") + } + + 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]) +}