diff --git a/cmd/agent.go b/cmd/agent.go index c92ac8db..26fa564e 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -5,9 +5,9 @@ import ( "fmt" "os" "path/filepath" - "text/tabwriter" "github.com/GrayCodeAI/graycode-cli/internal/multiagent/agents" + "github.com/GrayCodeAI/graycode-cli/internal/theme" "github.com/spf13/cobra" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -86,8 +86,7 @@ func runAgentList(cmd *cobra.Command, _ []string) error { return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "NAME\tMODEL\tDESCRIPTION\n") + rows := make([][]string, 0, len(all)) for _, a := range all { model := a.Model if model == "" { @@ -100,9 +99,9 @@ func runAgentList(cmd *cobra.Command, _ []string) error { desc = string(runes[:50]) + "..." } } - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", a.Name, model, desc) + rows = append(rows, []string{a.Name, model, desc}) } - return w.Flush() + return theme.PrintTable(os.Stdout, []string{"NAME", "MODEL", "DESCRIPTION"}, rows) } func runAgentCreate(_ *cobra.Command, args []string) error { diff --git a/cmd/plugin_dynamic.go b/cmd/plugin_dynamic.go index 68bee4da..cc1fea74 100644 --- a/cmd/plugin_dynamic.go +++ b/cmd/plugin_dynamic.go @@ -6,10 +6,10 @@ import ( "image/color" "os" "path/filepath" - "text/tabwriter" "time" "github.com/GrayCodeAI/graycode-cli/internal/plugin" + "github.com/GrayCodeAI/graycode-cli/internal/theme" "github.com/spf13/cobra" ) @@ -104,21 +104,15 @@ var pluginStatusCmd = &cobra.Command{ return nil } - w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - if _, err := fmt.Fprintf(w, "%s\n", auditTint("NAME\tVERSION\tSTATE\tTOOLS\tHOOKS", textMuted)); err != nil { - return err - } + rows := make([][]string, 0, len(statuses)) for _, s := range statuses { - if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%d\n", - s.Name, s.Version, auditTint(string(s.State), pluginStateColor(s.State)), s.ToolCount, s.HookCount); err != nil { - return err - } + rows = append(rows, []string{ + s.Name, s.Version, + auditTint(string(s.State), pluginStateColor(s.State)), + fmt.Sprintf("%d", s.ToolCount), fmt.Sprintf("%d", s.HookCount), + }) } - if err := w.Flush(); err != nil { - return err - } - - return nil + return theme.PrintTable(cmd.OutOrStdout(), []string{"NAME", "VERSION", "STATE", "TOOLS", "HOOKS"}, rows) }, } @@ -396,23 +390,20 @@ var pluginLogsCmd = &cobra.Command{ return nil } - w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "TIME\tPLUGIN\tEVENT\tERROR\n") + rows := make([][]string, 0, len(collected)) for _, ev := range collected { errStr := "" if ev.Error != "" { errStr = truncateWithEllipsis(ev.Error, 50) } - _, _ = fmt.Fprintf( - w, "%s\t%s\t%s\t%s\n", + rows = append(rows, []string{ ev.Timestamp.Format("15:04:05"), ev.PluginName, ev.Type, errStr, - ) + }) } - _ = w.Flush() - return nil + return theme.PrintTable(cmd.OutOrStdout(), []string{"TIME", "PLUGIN", "EVENT", "ERROR"}, rows) }, } @@ -440,8 +431,7 @@ var pluginMarketplaceListCmd = &cobra.Command{ cmd.Println(auditTint("Add a source: graycode plugin marketplace add ", textMuted)) return nil } - w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "NAME\tREPO\tVERSION\tDESCRIPTION\n") + rows := make([][]string, 0, len(entries)) for _, e := range entries { desc := e.Description if len(desc) > 48 { @@ -450,9 +440,9 @@ var pluginMarketplaceListCmd = &cobra.Command{ desc = string(runes[:45]) + "..." } } - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.Name, e.Repo, e.Version, desc) + rows = append(rows, []string{e.Name, e.Repo, e.Version, desc}) } - return w.Flush() + return theme.PrintTable(cmd.OutOrStdout(), []string{"NAME", "REPO", "VERSION", "DESCRIPTION"}, rows) }, } diff --git a/cmd/search.go b/cmd/search.go index 2b8fee5e..35c6f741 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -4,9 +4,9 @@ import ( "fmt" "os" "strings" - "text/tabwriter" "github.com/GrayCodeAI/graycode-cli/internal/session" + "github.com/GrayCodeAI/graycode-cli/internal/theme" "github.com/spf13/cobra" ) @@ -61,8 +61,7 @@ func runSearch(_ *cobra.Command, args []string) error { return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "SESSION\tROLE\tMATCH\n") + rows := make([][]string, 0, len(results)) for _, r := range results { preview := r.Preview if len(preview) > 80 { @@ -72,9 +71,9 @@ func runSearch(_ *cobra.Command, args []string) error { } } preview = strings.ReplaceAll(preview, "\n", " ") - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", r.SessionID[:8], r.Role, preview) + rows = append(rows, []string{r.SessionID[:8], r.Role, preview}) } - return w.Flush() + return theme.PrintTable(os.Stdout, []string{"SESSION", "ROLE", "MATCH"}, rows) } func escapeJSON(s string) string { diff --git a/cmd/trust.go b/cmd/trust.go index 9b09fe9b..c96e9444 100644 --- a/cmd/trust.go +++ b/cmd/trust.go @@ -4,7 +4,8 @@ import ( "encoding/json" "fmt" "os" - "text/tabwriter" + + "github.com/GrayCodeAI/graycode-cli/internal/theme" "github.com/GrayCodeAI/graycode-cli/internal/flags" "github.com/GrayCodeAI/graycode-cli/internal/trust" @@ -106,19 +107,11 @@ var trustListCmd = &cobra.Command{ fmt.Println(string(out)) return nil } - w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - if _, err := fmt.Fprintln(w, "PATH\tTRUSTED_AT\tREASON"); err != nil { - return err - } + rows := make([][]string, 0, len(entries)) for _, e := range entries { - if _, err := fmt.Fprintf(w, "%s\t%s\t%s\n", e.Path, e.TrustedAt.Format("2006-01-02 15:04"), e.Reason); err != nil { - return err - } - } - if err := w.Flush(); err != nil { - return err + rows = append(rows, []string{e.Path, e.TrustedAt.Format("2006-01-02 15:04"), e.Reason}) } - return nil + return theme.PrintTable(cmd.OutOrStdout(), []string{"PATH", "TRUSTED_AT", "REASON"}, rows) }, } diff --git a/internal/theme/table.go b/internal/theme/table.go new file mode 100644 index 00000000..ef9ace3c --- /dev/null +++ b/internal/theme/table.go @@ -0,0 +1,95 @@ +// table.go — ANSI-safe aligned table rendering for CLI list output. +// +// text/tabwriter counts ANSI escape bytes as cell width, so any colored cell +// (header or value) shifts column alignment. This helper computes column +// widths on the ANSI-stripped visible width and pads accordingly, so callers +// can colorize cells freely without breaking the grid. + +package theme + +import ( + "fmt" + "io" + "regexp" + "strings" + "unicode/utf8" +) + +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// visibleWidth returns the display width of s with ANSI escape sequences +// stripped, so alignment is computed on what the terminal actually renders. +func visibleWidth(s string) int { + return utf8.RuneCountInString(ansiRe.ReplaceAllString(s, "")) +} + +// PrintTable writes an aligned table to w. The header row is rendered muted; +// data cells are written verbatim (callers may pre-colorize them). Column +// widths are derived from visible width, so colors never break alignment. +// +// A header is required; rows shorter than the header are left-blank in the +// missing columns and extra trailing cells are emitted unaligned. The result +// is deterministic and safe for non-TTY and --quiet output (muted header is +// plain when color is disabled). +func PrintTable(w io.Writer, header []string, rows [][]string) error { + if len(header) == 0 { + return nil + } + cols := len(header) + widths := make([]int, cols) + for i, h := range header { + widths[i] = visibleWidth(h) + } + for _, r := range rows { + for i, c := range r { + if i >= cols { + break + } + if vw := visibleWidth(c); vw > widths[i] { + widths[i] = vw + } + } + } + + writeRow := func(cells []string) error { + for i := 0; i < cols; i++ { + if i > 0 { + if _, err := fmt.Fprint(w, " "); err != nil { + return err + } + } + cell := "" + if i < len(cells) { + cell = cells[i] + } + if _, err := fmt.Fprint(w, cell); err != nil { + return err + } + // Pad all but the last column to the column width. Trailing cells + // beyond the header width are emitted unaligned. + if i < cols-1 { + if pad := widths[i] - visibleWidth(cell); pad > 0 { + if _, err := fmt.Fprint(w, strings.Repeat(" ", pad)); err != nil { + return err + } + } + } + } + _, err := fmt.Fprintln(w) + return err + } + + hc := make([]string, cols) + for i, h := range header { + hc[i] = Tint(h, ReportMuted) + } + if err := writeRow(hc); err != nil { + return err + } + for _, r := range rows { + if err := writeRow(r); err != nil { + return err + } + } + return nil +} diff --git a/internal/theme/table_test.go b/internal/theme/table_test.go new file mode 100644 index 00000000..1bf5b1a0 --- /dev/null +++ b/internal/theme/table_test.go @@ -0,0 +1,83 @@ +package theme + +import ( + "bytes" + "strings" + "testing" +) + +func stripANSI(s string) string { + return ansiRe.ReplaceAllString(s, "") +} + +func TestPrintTableAlignsColumns(t *testing.T) { + var buf bytes.Buffer + err := PrintTable( + &buf, + []string{"NAME", "VERSION", "STATE"}, + [][]string{ + {"foo", "1.0", "active"}, + {"verylongname", "2.0", "enabled"}, + }, + ) + if err != nil { + t.Fatalf("PrintTable returned error: %v", err) + } + got := stripANSI(buf.String()) + want := "" + + "NAME VERSION STATE\n" + + "foo 1.0 active\n" + + "verylongname 2.0 enabled\n" + if got != want { + t.Errorf("unexpected table:\n got:\n%q\nwant:\n%q", got, want) + } +} + +func TestPrintTableColorDoesNotBreakAlignment(t *testing.T) { + // Colorizing a header/value must not shift columns: widths are computed + // on visible (ANSI-stripped) width. + var buf bytes.Buffer + err := PrintTable( + &buf, + []string{"NAME", "VERSION", "STATE"}, + [][]string{ + {"foo", "1.0", Tint("active", ReportSuccess)}, + }, + ) + if err != nil { + t.Fatalf("PrintTable returned error: %v", err) + } + got := stripANSI(buf.String()) + want := "" + + "NAME VERSION STATE\n" + + "foo 1.0 active\n" + if got != want { + t.Errorf("unexpected table:\n got:\n%q\nwant:\n%q", got, want) + } +} + +func TestPrintTableShortRows(t *testing.T) { + var buf bytes.Buffer + err := PrintTable( + &buf, + []string{"A", "B", "C"}, + [][]string{{"1"}}, + ) + if err != nil { + t.Fatalf("PrintTable returned error: %v", err) + } + got := stripANSI(buf.String()) + if !strings.Contains(got, "1") { + t.Errorf("short row not emitted, got: %q", got) + } +} + +func TestPrintTableEmptyHeaderNoop(t *testing.T) { + var buf bytes.Buffer + if err := PrintTable(&buf, nil, [][]string{{"x"}}); err != nil { + t.Fatalf("PrintTable returned error: %v", err) + } + if buf.Len() != 0 { + t.Errorf("expected no output for empty header, got: %q", buf.String()) + } +}