Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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 {
Expand Down
40 changes: 15 additions & 25 deletions cmd/plugin_dynamic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
},
}

Expand Down Expand Up @@ -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)
},
}

Expand Down Expand Up @@ -440,8 +431,7 @@ var pluginMarketplaceListCmd = &cobra.Command{
cmd.Println(auditTint("Add a source: graycode plugin marketplace add <name> <index-url>", 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 {
Expand All @@ -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)
},
}

Expand Down
9 changes: 4 additions & 5 deletions cmd/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
17 changes: 5 additions & 12 deletions cmd/trust.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
},
}

Expand Down
95 changes: 95 additions & 0 deletions internal/theme/table.go
Original file line number Diff line number Diff line change
@@ -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
}
83 changes: 83 additions & 0 deletions internal/theme/table_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading