diff --git a/cmd/agent/list.go b/cmd/agent/list.go index df85cd0a..42936bea 100644 --- a/cmd/agent/list.go +++ b/cmd/agent/list.go @@ -4,16 +4,16 @@ import ( "context" "fmt" "os" + "slices" "strings" "github.com/alecthomas/kong" - "github.com/buildkite/cli/v3/internal/agent" "github.com/buildkite/cli/v3/internal/cli" + bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" - tea "github.com/charmbracelet/bubbletea" ) const ( @@ -79,6 +79,7 @@ func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() + f.NoPager = globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err @@ -92,77 +93,91 @@ func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { format := output.Format(c.Output) - // Skip TUI when using non-text format (JSON/YAML) - if format != output.FormatText { - agents := []buildkite.Agent{} - page := 1 - - for len(agents) < c.Limit && page < 50 { - opts := buildkite.AgentListOptions{ - Name: c.Name, - Hostname: c.Hostname, - Version: c.Version, - ListOptions: buildkite.ListOptions{ - Page: page, - PerPage: c.PerPage, - }, - } - - pageAgents, _, err := f.RestAPIClient.Agents.List(ctx, f.Config.OrganizationSlug(), &opts) - if err != nil { - return err - } - - if len(pageAgents) == 0 { - break - } - - filtered := filterAgents(pageAgents, c.State, c.Tags) - agents = append(agents, filtered...) - page++ + agents := []buildkite.Agent{} + page := 1 + hasMore := false + + for len(agents) < c.Limit && page < 50 { + opts := buildkite.AgentListOptions{ + Name: c.Name, + Hostname: c.Hostname, + Version: c.Version, + ListOptions: buildkite.ListOptions{ + Page: page, + PerPage: c.PerPage, + }, + } + + pageAgents, _, err := f.RestAPIClient.Agents.List(ctx, f.Config.OrganizationSlug(), &opts) + if err != nil { + return err } - if len(agents) > c.Limit { - agents = agents[:c.Limit] + if len(pageAgents) == 0 { + break } + filtered := filterAgents(pageAgents, c.State, c.Tags) + agents = append(agents, filtered...) + page++ + + // If we have more than the limit, there are definitely more results, so we'll set hasMore to true, which will add a '+' to the total count display in the output + // this is just to make it clear to the user that there's more results available than what are shown, as otherwise if they set --limit to say 5 + // then it'd say showing 5 out of 30 agents (assuming there are more than 30 agents) + if len(agents) >= c.Limit { + hasMore = true + } + } + + totalFetched := len(agents) + if len(agents) > c.Limit { + agents = agents[:c.Limit] + } + + if format != output.FormatText { return output.Write(os.Stdout, agents, format) } - loader := func(page int) tea.Cmd { - return func() tea.Msg { - opts := buildkite.AgentListOptions{ - Name: c.Name, - Hostname: c.Hostname, - Version: c.Version, - ListOptions: buildkite.ListOptions{ - Page: page, - PerPage: c.PerPage, - }, - } - - agents, resp, err := f.RestAPIClient.Agents.List(ctx, f.Config.OrganizationSlug(), &opts) - if err != nil { - return err - } - - filtered := filterAgents(agents, c.State, c.Tags) - - items := make([]agent.AgentListItem, len(filtered)) - for i, a := range filtered { - a := a - items[i] = agent.AgentListItem{Agent: a} - } - - return agent.NewAgentItemsMsg(items, resp.LastPage) + if len(agents) == 0 { + fmt.Println("No agents found") + return nil + } + + headers := []string{"State", "Name", "Version", "Queue", "Hostname"} + rows := make([][]string, len(agents)) + for i, agent := range agents { + queue := extractQueue(agent.Metadata) + rows[i] = []string{ + agent.ConnectedState, + agent.Name, + agent.Version, + queue, + agent.Hostname, } } - model := agent.NewAgentList(loader, 1, c.PerPage, f.Quiet) + columnStyles := map[string]string{ + "state": "bold", + "name": "bold", + "hostname": "dim", + "version": "italic", + "queue": "italic", + } + table := output.Table(headers, rows, columnStyles) + + writer, cleanup := bkIO.Pager(f.NoPager) + defer func() { + _ = cleanup() + }() + + totalDisplay := fmt.Sprintf("%d", totalFetched) + if hasMore { + totalDisplay = fmt.Sprintf("%d+", totalFetched) + } + fmt.Fprintf(writer, "Showing %d of %s agents in %s\n\n", len(agents), totalDisplay, f.Config.OrganizationSlug()) + fmt.Fprint(writer, table) - p := tea.NewProgram(model, tea.WithAltScreen()) - _, err = p.Run() - return err + return nil } func validateState(state string) error { @@ -171,10 +186,8 @@ func validateState(state string) error { } normalized := strings.ToLower(state) - for _, valid := range validStates { - if normalized == valid { - return nil - } + if slices.Contains(validStates, normalized) { + return nil } return fmt.Errorf("invalid state %q: must be one of %s, %s, or %s", state, stateRunning, stateIdle, statePaused) @@ -222,10 +235,14 @@ func matchesTags(a buildkite.Agent, tags []string) bool { } func hasTag(metadata []string, tag string) bool { - for _, meta := range metadata { - if meta == tag { - return true + return slices.Contains(metadata, tag) +} + +func extractQueue(metadata []string) string { + for _, m := range metadata { + if after, ok := strings.CutPrefix(m, "queue="); ok { + return after } } - return false + return "default" } diff --git a/cmd/agent/stop.go b/cmd/agent/stop.go index bfdab12c..6048ee31 100644 --- a/cmd/agent/stop.go +++ b/cmd/agent/stop.go @@ -4,17 +4,16 @@ import ( "bufio" "context" "errors" + "fmt" "os" "strings" "sync" "github.com/alecthomas/kong" - "github.com/buildkite/cli/v3/internal/agent" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" - tea "github.com/charmbracelet/bubbletea" "github.com/mattn/go-isatty" "golang.org/x/sync/semaphore" ) @@ -64,12 +63,14 @@ func (c *StopCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { ctx := context.Background() - // use a wait group to ensure we exit the program after all agents have finished - var wg sync.WaitGroup // this semaphore is used to limit how many concurrent API requests can be sent - sem := semaphore.NewWeighted(c.Limit) + limit := c.Limit + if limit < 1 { + limit = 1 + } + sem := semaphore.NewWeighted(limit) - var agents []agent.StoppableAgent + var agentIDs []string // this command accepts either input from stdin or positional arguments (not both) in that order // so we need to check if stdin has data for us to read and read that, otherwise use positional args and if // there are none, then we need to error @@ -80,8 +81,7 @@ func (c *StopCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { for scanner.Scan() { id := scanner.Text() if strings.TrimSpace(id) != "" { - wg.Add(1) - agents = append(agents, agent.NewStoppableAgent(id, stopper(ctx, id, f, c.Force, sem, &wg), f.Quiet)) + agentIDs = append(agentIDs, id) } } @@ -91,75 +91,101 @@ func (c *StopCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { } else if len(c.Agents) > 0 { for _, id := range c.Agents { if strings.TrimSpace(id) != "" { - wg.Add(1) - agents = append(agents, agent.NewStoppableAgent(id, stopper(ctx, id, f, c.Force, sem, &wg), f.Quiet)) + agentIDs = append(agentIDs, id) } } } else { return errors.New("must supply agents to stop") } - bulkAgent := agent.BulkAgent{ - Agents: agents, + if len(agentIDs) == 0 { + return errors.New("must supply agents to stop") } - programOpts := []tea.ProgramOption{tea.WithOutput(os.Stdout)} - if !isatty.IsTerminal(os.Stdin.Fd()) { - programOpts = append(programOpts, tea.WithInput(nil)) + writer := os.Stdout + isTTY := isatty.IsTerminal(writer.Fd()) + + total := len(agentIDs) + updates := make(chan stopResult, total) + + var wg sync.WaitGroup + for _, id := range agentIDs { + wg.Add(1) + go func(agentID string) { + defer wg.Done() + updates <- stopAgent(ctx, agentID, f, c.Force, sem) + }(id) } - p := tea.NewProgram(bulkAgent, programOpts...) - // send a quit message after all agents have stopped go func() { wg.Wait() - p.Send(tea.Quit()) + close(updates) }() - _, err = p.Run() - if err != nil { - return err + succeeded := 0 + failed := 0 + completed := 0 + var errorDetails []string + + if !f.Quiet { + line := bkIO.ProgressLine("Stopping agents", completed, total, succeeded, failed, 24) + if isTTY { + fmt.Fprint(writer, line) + } else { + fmt.Fprintln(writer, line) + } } - for _, agent := range agents { - if agent.Errored() { - return errors.New("at least one agent failed to stop") + for update := range updates { + completed++ + if update.err != nil { + failed++ + errorDetails = append(errorDetails, fmt.Sprintf("FAILED %s: %v", update.id, update.err)) + } else { + succeeded++ + } + + if !f.Quiet { + line := bkIO.ProgressLine("Stopping agents", completed, total, succeeded, failed, 24) + if isTTY { + fmt.Fprintf(writer, "\r%s", line) + } else { + fmt.Fprintln(writer, line) + } } } + + if !f.Quiet && isTTY { + fmt.Fprintln(writer) + } + + // Will print error details after the progress bar is complete so we don't skew the bar display + if !f.Quiet && len(errorDetails) > 0 { + for _, detail := range errorDetails { + fmt.Fprintln(writer, detail) + } + } + + if failed > 0 { + return fmt.Errorf("failed to stop %d of %d agents (see above for details)", failed, total) + } + return nil } -// here we want to allow each agent to transition through from a waiting state to stopping and ending at -// success/failure. so we need to wrap up multiple tea.Cmds, the first one marking it as "stopping". after -// that, another Cmd is started to make the API request to stop it. After that request we return a status to -// indicate success/failure -// the sync.WaitGroup also needs to be marked as done so we can stop the entire application after all agents -// are stopped -func stopper(ctx context.Context, id string, f *factory.Factory, force bool, sem *semaphore.Weighted, wg *sync.WaitGroup) agent.StopFn { +type stopResult struct { + id string + err error +} + +func stopAgent(ctx context.Context, id string, f *factory.Factory, force bool, sem *semaphore.Weighted) stopResult { org, agentID := parseAgentArg(id, f.Config) - return func() agent.StatusUpdate { - // before attempting to stop the agent, acquire a semaphore lock to limit parallelisation - _ = sem.Acquire(context.Background(), 1) - - return agent.StatusUpdate{ - ID: id, - Status: agent.Stopping, - // return an new command to actually stop the agent in the api and return the status of that - Cmd: func() tea.Msg { - // defer the semaphore and waitgroup release until the whole operation is completed - defer sem.Release(1) - defer wg.Done() - _, err := f.RestAPIClient.Agents.Stop(ctx, org, agentID, force) - if err != nil { - return agent.StatusUpdate{ - ID: id, - Err: err, - } - } - return agent.StatusUpdate{ - ID: id, - Status: agent.Succeeded, - } - }, - } + + if err := sem.Acquire(ctx, 1); err != nil { + return stopResult{id: id, err: err} } + defer sem.Release(1) + + _, err := f.RestAPIClient.Agents.Stop(ctx, org, agentID, force) + return stopResult{id: id, err: err} } diff --git a/cmd/agent/view.go b/cmd/agent/view.go index 22c20156..c8f9d5de 100644 --- a/cmd/agent/view.go +++ b/cmd/agent/view.go @@ -4,9 +4,10 @@ import ( "context" "fmt" "os" + "strings" + "time" "github.com/alecthomas/kong" - "github.com/buildkite/cli/v3/internal/agent" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" @@ -49,6 +50,7 @@ func (c *ViewCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() + f.NoPager = globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err @@ -66,20 +68,6 @@ func (c *ViewCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { return browser.OpenURL(url) } - if format != output.FormatText { - var agentData buildkite.Agent - spinErr := bkIO.SpinWhile(f, "Loading agent", func() { - agentData, _, err = f.RestAPIClient.Agents.Get(ctx, org, id) - }) - if spinErr != nil { - return spinErr - } - if err != nil { - return err - } - return output.Write(os.Stdout, agentData, format) - } - var agentData buildkite.Agent spinErr := bkIO.SpinWhile(f, "Loading agent", func() { agentData, _, err = f.RestAPIClient.Agents.Get(ctx, org, id) @@ -91,7 +79,70 @@ func (c *ViewCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { return err } - fmt.Printf("%s\n", agent.AgentDataTable(agentData)) + if format != output.FormatText { + return output.Write(os.Stdout, agentData, format) + } + + metadata, queue := parseMetadata(agentData.Metadata) + metadata = strings.TrimSpace(strings.ReplaceAll(metadata, "\n", ", ")) + if metadata == "" { + metadata = "~" + } + connected := "-" + if agentData.CreatedAt != nil { + connected = agentData.CreatedAt.Format(time.RFC3339) + } - return err + headers := []string{"Property", "Value"} + rows := [][]string{ + {"ID", agentData.ID}, + {"Name", agentData.Name}, + {"State", agentData.ConnectedState}, + {"Queue", queue}, + {"Version", agentData.Version}, + {"Hostname", agentData.Hostname}, + {"User Agent", agentData.UserAgent}, + {"IP Address", agentData.IPAddress}, + {"Connected", connected}, + {"Metadata", metadata}, + } + + table := output.Table(headers, rows, map[string]string{ + "Property": "bold", + "Value": "dim", + }) + + writer, cleanup := bkIO.Pager(f.NoPager) + defer func() { _ = cleanup() }() + + fmt.Fprintf(writer, "Agent %s (%s)\n\n", agentData.Name, agentData.ID) + fmt.Fprint(writer, table) + + return nil +} + +func parseMetadata(metadataList []string) (string, string) { + var metadata, queue string + + if len(metadataList) == 1 { + return "~", parseQueue(metadataList[0]) + } + + for _, v := range metadataList { + if queueValue := parseQueue(v); queueValue != "" { + queue = queueValue + } else { + metadata += v + "\n" + } + } + + return metadata, queue +} + +func parseQueue(metadata string) string { + parts := strings.Split(metadata, "=") + if len(parts) > 1 && parts[0] == "queue" { + return parts[1] + } + return "" } diff --git a/go.mod b/go.mod index 4b9892f4..5c828cd0 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.24.3 require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/alecthomas/kong v1.13.0 + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be github.com/buildkite/go-buildkite/v4 v4.13.1 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbletea v1.3.10 @@ -36,6 +37,7 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect @@ -75,7 +77,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect diff --git a/go.sum b/go.sum index 3091a589..c9da9761 100644 --- a/go.sum +++ b/go.sum @@ -69,6 +69,8 @@ github.com/charmbracelet/x/exp/teatest v0.0.0-20231215171016-7ba2b450712d h1:J6m github.com/charmbracelet/x/exp/teatest v0.0.0-20231215171016-7ba2b450712d/go.mod h1:43J0pdacLjJQtomu7vU6RFZX3bn84toqNw7hjX8bhmM= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -144,6 +146,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= diff --git a/internal/agent/bulk.go b/internal/agent/bulk.go deleted file mode 100644 index 7448d02a..00000000 --- a/internal/agent/bulk.go +++ /dev/null @@ -1,57 +0,0 @@ -package agent - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" -) - -// BulkAgent aggregates multiple StoppableAgents to stop them in parallel and display the progress to the user. -type BulkAgent struct { - Agents []StoppableAgent -} - -// Init implements tea.Model -// It calls all StoppableAgent Init methods -func (bulkAgent BulkAgent) Init() tea.Cmd { - cmds := make([]tea.Cmd, len(bulkAgent.Agents)) - for i, agent := range bulkAgent.Agents { - cmds[i] = agent.Init() - } - - return tea.Batch(cmds...) -} - -// Update implements tea.Model. -// It handles cancelling the whole operation and passing through updates to each StoppableAgent to update the UI. -func (bulkAgent BulkAgent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // if a key is pressed, ignore everything except for common quitting - if msg, ok := msg.(tea.KeyMsg); ok { - switch msg.String() { - case "q", "esc", "ctrl+c": - return bulkAgent, tea.Quit - default: - return bulkAgent, nil - } - } - - // otherwise pass the message through to all agents to allow them to update - cmds := make([]tea.Cmd, len(bulkAgent.Agents)) - for i, agent := range bulkAgent.Agents { - agent, cmd := agent.Update(msg) - bulkAgent.Agents[i] = agent.(StoppableAgent) - cmds[i] = cmd - } - return bulkAgent, tea.Batch(cmds...) -} - -// View implements tea.Model to aggregate the output of all StoppableAgents -func (bulkAgent BulkAgent) View() string { - var sb strings.Builder - - for _, agent := range bulkAgent.Agents { - sb.WriteString(agent.View()) - } - - return sb.String() -} diff --git a/internal/agent/bulk_test.go b/internal/agent/bulk_test.go deleted file mode 100644 index 48831554..00000000 --- a/internal/agent/bulk_test.go +++ /dev/null @@ -1 +0,0 @@ -package agent diff --git a/internal/agent/item.go b/internal/agent/item.go deleted file mode 100644 index cb7b857e..00000000 --- a/internal/agent/item.go +++ /dev/null @@ -1,25 +0,0 @@ -package agent - -import ( - "strings" - - buildkite "github.com/buildkite/go-buildkite/v4" -) - -// AgentListItem implements list.Item for displaying in a list -type AgentListItem struct { - buildkite.Agent -} - -func (ali AgentListItem) FilterValue() string { - return strings.Join([]string{ali.Name, ali.QueueName(), ali.ConnectedState, ali.Version}, " ") -} - -func (ali AgentListItem) QueueName() string { - for _, m := range ali.Metadata { - if strings.Contains(m, "queue=") { - return strings.Split(m, "=")[1] - } - } - return "default" -} diff --git a/internal/agent/item_delegate.go b/internal/agent/item_delegate.go deleted file mode 100644 index d496ef52..00000000 --- a/internal/agent/item_delegate.go +++ /dev/null @@ -1,160 +0,0 @@ -package agent - -import ( - "fmt" - "io" - "strings" - - "github.com/buildkite/cli/v3/pkg/style" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/muesli/reflow/truncate" -) - -type itemStyles struct { - normalStatus lipgloss.Style - selectedStatus lipgloss.Style - dimmedStatus lipgloss.Style - - normalName lipgloss.Style - selectedName lipgloss.Style - dimmedName lipgloss.Style - - normalVersion lipgloss.Style - selectedVersion lipgloss.Style - dimmedVersion lipgloss.Style - - normalQueue lipgloss.Style - selectedQueue lipgloss.Style - dimmedQueue lipgloss.Style - - filterMatch lipgloss.Style -} - -func DefaultItemStyles() (s itemStyles) { - // apply a width of the longest expected string - s.normalStatus = lipgloss.NewStyle().Width(len("connected")) - s.selectedStatus = s.normalStatus - s.dimmedStatus = s.normalStatus - - s.normalName = lipgloss.NewStyle().PaddingLeft(2) - s.selectedName = s.normalName - s.dimmedName = s.normalName - - s.normalVersion = s.normalName.Foreground(style.Grey) //.Width(len("v0.00.00")) - s.selectedVersion = s.normalVersion - s.dimmedVersion = s.normalVersion - - s.normalQueue = s.normalName.Foreground(style.Teal) - s.selectedQueue = s.normalQueue - s.dimmedQueue = s.normalQueue - - s.filterMatch = lipgloss.NewStyle().Underline(true) - - return -} - -func NewDelegate() listAgentDelegate { - return listAgentDelegate{ - Styles: DefaultItemStyles(), - } -} - -// listAgentDelegate implements list.ItemDelegate to customise how each agent is rendered in a list -type listAgentDelegate struct { - Styles itemStyles -} - -// Height implements list.ItemDelegate. -func (listAgentDelegate) Height() int { - return 1 -} - -// Render implements list.ItemDelegate. -// This is mostly a reimplementation of list.DefaultDelegate#Render -func (d listAgentDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { - var ( - status, name, version, queue string - matchedRunes []int - s = &d.Styles - ) - if agent, ok := item.(AgentListItem); ok { - name = agent.Name - status = agent.ConnectedState - version = agent.Version - queue = agent.QueueName() - } else { - return - } - - // Prevent text from exceeding list width - // TODO: add more truncation to name so other colums are displayed fully - nameWidth := uint(m.Width() - s.normalName.GetPaddingLeft() - s.normalName.GetPaddingRight()) - name = truncate.StringWithTail(name, nameWidth, style.Ellipsis) - status = s.normalStatus.Foreground(MapStatusToColour(status)).Render(status) - version = s.normalVersion.Render(version) - queue = s.normalQueue.Render(queue) - - // Conditions - var ( - isSelected = index == m.Index() - emptyFilter = m.FilterState() == list.Filtering && m.FilterValue() == "" - isFiltered = m.FilterState() == list.Filtering || m.FilterState() == list.FilterApplied - ) - - if isFiltered && index < len(m.VisibleItems()) { - // Get indices of matched characters - matchedRunes = m.MatchesForItem(index) - } - - if emptyFilter { - name = s.dimmedName.Render(name) - status = s.dimmedStatus.Render(status) - version = s.dimmedVersion.Render(version) - queue = s.dimmedQueue.Render(queue) - } else if isSelected && m.FilterState() != list.Filtering { - if isFiltered { - // Highlight matches - unmatched := s.selectedName.Inline(true) - matched := unmatched.Inherit(s.filterMatch) - name = lipgloss.StyleRunes(name, matchedRunes, matched, unmatched) - } - name = s.selectedName.Render(name) - status = s.selectedStatus.Render(status) - version = s.selectedVersion.Render(version) - queue = s.selectedQueue.Render(queue) - } else { - if isFiltered { - // Highlight matches - unmatched := s.normalName.Inline(true) - matched := unmatched.Inherit(s.filterMatch) - name = lipgloss.StyleRunes(name, matchedRunes, matched, unmatched) - } - name = s.normalName.Render(name) - status = s.normalStatus.Render(status) - version = s.normalVersion.Render(version) - queue = s.normalQueue.Render(queue) - } - - fmt.Fprintf(w, "%s %s %s %s", status, name, version, queue) -} - -// Spacing implements list.ItemDelegate. -func (listAgentDelegate) Spacing() int { - return 0 -} - -// Update implements list.ItemDelegate. -func (listAgentDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { - return nil -} - -func MapStatusToColour(s string) lipgloss.Color { - switch strings.ToLower(s) { - case "connected": - return style.Green - default: - return style.Black - } -} diff --git a/internal/agent/list.go b/internal/agent/list.go deleted file mode 100644 index 208ddaab..00000000 --- a/internal/agent/list.go +++ /dev/null @@ -1,183 +0,0 @@ -package agent - -import ( - "fmt" - "time" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/pkg/browser" -) - -var ( - agentListStyle = lipgloss.NewStyle().Padding(1, 2) - viewPortStyle = agentListStyle -) - -type AgentListModel struct { - agentList list.Model - agentViewPort viewport.Model - agentDataDisplayed bool - agentCurrentPage int - agentPerPage int - agentLastPage int - agentsLoading bool - agentLoader func(int) tea.Cmd - quiet bool -} - -func NewAgentList(loader func(int) tea.Cmd, page, perpage int, quiet bool) AgentListModel { - l := list.New(nil, NewDelegate(), 0, 0) - l.Title = "Buildkite Agents" - l.SetStatusBarItemName("agent", "agents") - - v := viewport.New(0, 0) - v.SetContent("") - - l.AdditionalShortHelpKeys = func() []key.Binding { - return []key.Binding{ - key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "view")), - key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "web")), - } - } - - l.AdditionalFullHelpKeys = l.AdditionalShortHelpKeys - - return AgentListModel{ - agentList: l, - agentViewPort: v, - agentDataDisplayed: false, - agentCurrentPage: page, - agentPerPage: perpage, - agentLoader: loader, - quiet: quiet, - } -} - -func (m *AgentListModel) appendAgents() tea.Cmd { - // Set agentsLoading - m.agentsLoading = true - // Fetch and append more agents - appendAgents := m.agentLoader(m.agentCurrentPage) - - // Skip status message in quiet mode - if m.quiet { - return appendAgents - } - - // Set a status message - statusMessage := fmt.Sprintf("Loading more agents: page %d of %d", m.agentCurrentPage, m.agentLastPage) - setStatus := m.agentList.NewStatusMessage(statusMessage) - return tea.Sequence(setStatus, appendAgents) -} - -func (m *AgentListModel) setComponentSizing(width, height int) { - h, v := agentListStyle.GetFrameSize() - // Set component size - m.agentList.SetSize(width-h, height-v) - m.agentViewPort.Height = height - v - m.agentViewPort.Width = width - h - - // Set styles width/height for resizing upon a tea.WindowSizeMsg - viewPortStyle.Width((width - h) / 2) - viewPortStyle.Height(height - v) - agentListStyle.Width((width - h) / 2) - agentListStyle.Height(height - v) -} - -func (m *AgentListModel) clearAgentViewPort() { - m.agentViewPort.SetContent("") - m.agentDataDisplayed = false -} - -func (m AgentListModel) Init() tea.Cmd { - return m.appendAgents() -} - -func (m AgentListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.WindowSizeMsg: - // When viewport size is reported, show a message to the user indicating agents are loading - m.setComponentSizing(msg.Width, msg.Height) - if m.quiet { - return m, nil - } - return m, m.agentList.NewStatusMessage("Loading agents") - case tea.KeyMsg: - switch msg.String() { - case "v": - if !m.agentDataDisplayed { - if agent, ok := m.agentList.SelectedItem().(AgentListItem); ok { - tableContext := AgentDataTable(agent.Agent) - m.agentViewPort.SetContent(tableContext) - m.agentDataDisplayed = true - } - } else { - m.clearAgentViewPort() - } - case "up": - m.clearAgentViewPort() - case "down": - m.clearAgentViewPort() - // Calculate last element, unfiltered and if the last agent page via the API has been reached - lastListItem := m.agentList.Index() == len(m.agentList.Items())-1 - unfilteredState := m.agentList.FilterState() == list.Unfiltered - if !m.agentsLoading && lastListItem && unfilteredState { - lastPageReached := m.agentCurrentPage > m.agentLastPage - // If down is pressed on the last agent item, list state is unfiltered and more agents are available - // to load the API - if !lastPageReached { - return m, m.appendAgents() - } else if !m.quiet { - // Append a status message to alert that no more agents are available to load from the API - setStatus := m.agentList.NewStatusMessage("No more agents to load!") - cmds = append(cmds, setStatus) - } - } - case "w": - if agent, ok := m.agentList.SelectedItem().(AgentListItem); ok { - if err := browser.OpenURL(agent.WebURL); err != nil { - return m, m.agentList.NewStatusMessage(fmt.Sprintf("Failed opening agent web url: %s", err.Error())) - } - } - } - // Custom messages - case AgentItemsMsg: - // When a new page of agents is received, append them to existing agents in the list and stop the loading - // spinner - allItems := append(m.agentList.Items(), msg.ListItems()...) - cmds = append(cmds, m.agentList.SetItems(allItems)) - // If the message from the initial agent load, set the last page - if m.agentCurrentPage == 1 { - m.agentLastPage = msg.lastPage - } - // Increment the models' current agent page, set agentsLoading to false - m.agentCurrentPage++ - m.agentsLoading = false - case error: - // Show a status message for a long time - m.agentList.StatusMessageLifetime = time.Duration(time.Hour) - return m, m.agentList.NewStatusMessage(fmt.Sprintf("Failed loading agents: %s", msg.Error())) - } - - var cmd tea.Cmd - m.agentList, cmd = m.agentList.Update(msg) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -func (m AgentListModel) View() string { - return lipgloss.JoinHorizontal( - lipgloss.Left, - agentListStyle.Render(m.agentList.View()), - lipgloss.JoinVertical( - lipgloss.Top, - viewPortStyle.Render(m.agentViewPort.View()), - ), - ) -} diff --git a/internal/agent/message.go b/internal/agent/message.go deleted file mode 100644 index 6314b03c..00000000 --- a/internal/agent/message.go +++ /dev/null @@ -1,22 +0,0 @@ -package agent - -import ( - "github.com/charmbracelet/bubbles/list" -) - -type AgentItemsMsg struct { - items []AgentListItem - lastPage int -} - -func NewAgentItemsMsg(items []AgentListItem, page int) AgentItemsMsg { - return AgentItemsMsg{items, page} -} - -func (a AgentItemsMsg) ListItems() []list.Item { - agg := make([]list.Item, len(a.items)) - for i, v := range a.items { - agg[i] = v - } - return agg -} diff --git a/internal/agent/stoppable.go b/internal/agent/stoppable.go deleted file mode 100644 index 3b574451..00000000 --- a/internal/agent/stoppable.go +++ /dev/null @@ -1,101 +0,0 @@ -package agent - -import ( - "fmt" - - tea "github.com/charmbracelet/bubbletea" -) - -type Status int - -const ( - Waiting Status = iota - Stopping - Succeeded - Failed -) - -// StatusUpdate is used to update the internal state of a StoppableAgent -type StatusUpdate struct { - Cmd tea.Cmd - Err error - ID string - Status Status -} - -// StopFn represents a function that returns a StatusUpdate -// Use a function of this type to update the state of a StoppableAgent -type StopFn func() StatusUpdate - -type StoppableAgent struct { - err error - id string - status Status - stopFn StopFn - quiet bool -} - -func NewStoppableAgent(id string, stopFn StopFn, quiet bool) StoppableAgent { - return StoppableAgent{ - id: id, - status: Waiting, - stopFn: stopFn, - quiet: quiet, - } -} - -// Init implements tea.Model -func (agent StoppableAgent) Init() tea.Cmd { - return func() tea.Msg { - if agent.stopFn != nil { - return agent.stopFn() - } - return nil - } -} - -// Update implements tea.Model. -func (agent StoppableAgent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case StatusUpdate: - // if the msg ID doesn't match this agent, do nothing as it doesnt apply to this instance - if msg.ID != agent.id { - return agent, nil - } - // if the status update contains an error, deal with that first - if msg.Err != nil { - agent.err = msg.Err - agent.status = Failed - return agent, msg.Cmd - } - agent.status = msg.Status - return agent, msg.Cmd - default: - return agent, nil - } -} - -// View implements tea.Model. -func (agent StoppableAgent) View() string { - switch agent.status { - case Waiting: - if agent.quiet { - return "" - } - return fmt.Sprintf("... Waiting to stop agent %s\n", agent.id) - case Stopping: - if agent.quiet { - return "" - } - return fmt.Sprintf("... Stopping agent %s\n", agent.id) - case Succeeded: - return fmt.Sprintf("✓ Stopped agent %s\n", agent.id) - case Failed: - return fmt.Sprintf("✗ Failed to stop agent %s (error: %s)\n", agent.id, agent.err.Error()) - } - return "" -} - -func (agent StoppableAgent) Errored() bool { - return agent.err != nil -} diff --git a/internal/agent/stoppable_test.go b/internal/agent/stoppable_test.go deleted file mode 100644 index 7ebde3fb..00000000 --- a/internal/agent/stoppable_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package agent - -import ( - "bytes" - "errors" - "io" - "testing" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/x/exp/teatest" -) - -func TestStoppableAgentOutput(t *testing.T) { - t.Parallel() - - t.Run("starts in waiting state", func(t *testing.T) { - t.Parallel() - - // use a StopFn that quits straight away - model := NewStoppableAgent("123", func() StatusUpdate { return StatusUpdate{tea.Quit, nil, "123", Waiting} }, false) - testModel := teatest.NewTestModel(t, model) - out, err := io.ReadAll(testModel.FinalOutput(t)) - if err != nil { - t.Error(err) - } - if !bytes.Contains(out, []byte("Waiting to stop agent 123")) { - t.Error("Output did not match") - } - }) - - t.Run("stopping state", func(t *testing.T) { - t.Parallel() - - // use a StopFn that quits straight away - model := NewStoppableAgent("123", func() StatusUpdate { return StatusUpdate{tea.Quit, nil, "123", Stopping} }, false) - testModel := teatest.NewTestModel(t, model) - out, err := io.ReadAll(testModel.FinalOutput(t)) - if err != nil { - t.Error(err) - } - if !bytes.Contains(out, []byte("Stopping agent 123")) { - t.Error("Output did not match") - } - }) - - t.Run("success state", func(t *testing.T) { - t.Parallel() - - // use a StopFn that quits straight away - model := NewStoppableAgent("123", func() StatusUpdate { return StatusUpdate{tea.Quit, nil, "123", Succeeded} }, false) - testModel := teatest.NewTestModel(t, model) - out, err := io.ReadAll(testModel.FinalOutput(t)) - if err != nil { - t.Error(err) - } - if !bytes.Contains(out, []byte("Stopped agent 123")) { - t.Error("Output did not match") - } - }) - - t.Run("failed state", func(t *testing.T) { - t.Parallel() - - // use a StopFn that quits straight away - model := NewStoppableAgent("123", func() StatusUpdate { return StatusUpdate{tea.Quit, errors.New("error"), "123", Failed} }, false) - testModel := teatest.NewTestModel(t, model) - out, err := io.ReadAll(testModel.FinalOutput(t)) - if err != nil { - t.Error(err) - } - if !bytes.Contains(out, []byte("Failed to stop agent 123 (error: error)")) { - t.Error("Output did not match") - } - }) - - t.Run("transitions through waiting-stopping-succeeded", func(t *testing.T) { - t.Parallel() - - // use a StopFn that quits straight away - model := NewStoppableAgent("123", func() StatusUpdate { return StatusUpdate{nil, nil, "123", Waiting} }, false) - testModel := teatest.NewTestModel(t, model) - - teatest.WaitFor(t, testModel.Output(), func(bts []byte) bool { - return bytes.Contains(bts, []byte("Waiting to stop agent 123")) - }) - testModel.Send(StatusUpdate{ - ID: "123", - Status: Stopping, - }) - teatest.WaitFor(t, testModel.Output(), func(bts []byte) bool { - return bytes.Contains(bts, []byte("Stopping agent 123")) - }) - testModel.Send(StatusUpdate{ - ID: "123", - Status: Succeeded, - Cmd: tea.Quit, - }) - teatest.WaitFor(t, testModel.Output(), func(bts []byte) bool { - return bytes.Contains(bts, []byte("Stopped agent 123")) - }) - - testModel.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) - }) - - t.Run("shows error state", func(t *testing.T) { - t.Parallel() - - // use a StopFn that quits straight away - model := NewStoppableAgent("123", func() StatusUpdate { return StatusUpdate{nil, nil, "123", Waiting} }, false) - testModel := teatest.NewTestModel(t, model) - - teatest.WaitFor(t, testModel.Output(), func(bts []byte) bool { - return bytes.Contains(bts, []byte("Waiting to stop agent 123")) - }) - testModel.Send(StatusUpdate{ - ID: "123", - Err: errors.New("Could not stop"), - }) - teatest.WaitFor(t, testModel.Output(), func(bts []byte) bool { - return bytes.Contains(bts, []byte("Failed to stop agent 123 (error: Could not stop)")) - }) - testModel.Send(tea.Quit()) - - testModel.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) - }) -} diff --git a/internal/agent/view.go b/internal/agent/view.go deleted file mode 100644 index c8e9b8aa..00000000 --- a/internal/agent/view.go +++ /dev/null @@ -1,66 +0,0 @@ -package agent - -import ( - "strings" - - "github.com/buildkite/cli/v3/internal/ui" - buildkite "github.com/buildkite/go-buildkite/v4" -) - -// AgentDataTable renders detailed agent information in a table format -func AgentDataTable(agent buildkite.Agent) string { - // Parse metadata and queue name from returned REST API Metadata list - metadata, queue := ParseMetadata(agent.Metadata) - - // Create table with agent details - rows := [][]string{ - {"ID", agent.ID}, - {"State", agent.ConnectedState}, - {"Queue", queue}, - {"Version", agent.Version}, - {"Hostname", agent.Hostname}, - {"User Agent", agent.UserAgent}, - {"IP Address", agent.IPAddress}, - {"Connected", ui.FormatDate(agent.CreatedAt.Time)}, - {"Metadata", metadata}, - } - - // Render agent name as a title - title := ui.Bold.Render(agent.Name) - - // Render the table with agent details - table := ui.Table([]string{"Property", "Value"}, rows) - - return ui.SpacedVertical(title, table) -} - -// ParseMetadata parses agent metadata to extract queue and other metadata -func ParseMetadata(metadataList []string) (string, string) { - var metadata, queue string - - // If no tags/only queue name (or default) is set - return a tilde (~) representing - // no metadata key/value tags, along with the found queue name - if len(metadataList) == 1 { - return "~", parseQueue(metadataList[0]) - } else { - // We can't guarantee order of metadata key/value pairs, extract each pair - // and the queue name when found in the respective element string - for _, v := range metadataList { - if queueValue := parseQueue(v); queueValue != "" { - queue = queueValue - } else { - metadata += v + "\n" - } - } - return metadata, queue - } -} - -// parseQueue extracts queue value from "queue=value" format -func parseQueue(metadata string) string { - parts := strings.Split(metadata, "=") - if len(parts) > 1 && parts[0] == "queue" { - return parts[1] - } - return "" -} diff --git a/internal/cli/context.go b/internal/cli/context.go index f9d91a65..4404331a 100644 --- a/internal/cli/context.go +++ b/internal/cli/context.go @@ -4,12 +4,14 @@ type GlobalFlags interface { SkipConfirmation() bool DisableInput() bool IsQuiet() bool + DisablePager() bool } type Globals struct { Yes bool NoInput bool Quiet bool + NoPager bool } func (g Globals) SkipConfirmation() bool { @@ -23,3 +25,7 @@ func (g Globals) DisableInput() bool { func (g Globals) IsQuiet() bool { return g.Quiet } + +func (g Globals) DisablePager() bool { + return g.NoPager +} diff --git a/internal/io/pager.go b/internal/io/pager.go new file mode 100644 index 00000000..74612b33 --- /dev/null +++ b/internal/io/pager.go @@ -0,0 +1,106 @@ +package io + +import ( + "io" + "os" + "os/exec" + "strings" + "sync" + + "github.com/anmitsu/go-shlex" + "github.com/mattn/go-isatty" +) + +// Pager returns a writer hooked up to a pager (default: less -R) when stdout is a TTY. +// Falls back to stdout when paging is disabled or the pager cannot run. +func Pager(noPager bool) (w io.Writer, cleanup func() error) { + cleanup = func() error { return nil } + + if noPager || !isTTY() { + return os.Stdout, cleanup + } + + pagerEnv := os.Getenv("PAGER") + if pagerEnv == "" { + pagerEnv = "less -R" + } + + parts, err := shlex.Split(pagerEnv, true) + if err != nil || len(parts) == 0 { + return os.Stdout, cleanup + } + + pagerCmd := parts[0] + pagerArgs := parts[1:] + + pagerPath, err := exec.LookPath(pagerCmd) + if err != nil { + return os.Stdout, cleanup + } + + if isLessPager(pagerPath) && !hasFlag(pagerArgs, "-R", "--RAW-CONTROL-CHARS") { + pagerArgs = append(pagerArgs, "-R") + } + + cmd := exec.Command(pagerPath, pagerArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + stdin, err := cmd.StdinPipe() + if err != nil { + return os.Stdout, cleanup + } + + if err := cmd.Start(); err != nil { + stdin.Close() + return os.Stdout, cleanup + } + + var once sync.Once + var cleanupErr error + + cleanup = func() error { + once.Do(func() { + closeErr := stdin.Close() + waitErr := cmd.Wait() + + if waitErr != nil { + cleanupErr = waitErr + } else { + cleanupErr = closeErr + } + }) + return cleanupErr + } + + return stdin, cleanup +} + +func isTTY() bool { + if isatty.IsTerminal(os.Stdout.Fd()) { + return true + } + return isatty.IsCygwinTerminal(os.Stdout.Fd()) +} + +func isLessPager(path string) bool { + base := path + if idx := strings.LastIndex(path, "/"); idx != -1 { + base = path[idx+1:] + } + if idx := strings.LastIndex(path, "\\"); idx != -1 { + base = path[idx+1:] + } + return base == "less" || base == "less.exe" +} + +func hasFlag(args []string, flags ...string) bool { + for _, arg := range args { + for _, flag := range flags { + if arg == flag || strings.HasPrefix(arg, flag+"=") { + return true + } + } + } + return false +} diff --git a/internal/io/progress.go b/internal/io/progress.go new file mode 100644 index 00000000..1a6921c5 --- /dev/null +++ b/internal/io/progress.go @@ -0,0 +1,34 @@ +package io + +import ( + "fmt" + "strings" +) + +func ProgressBar(completed, total, width int) string { + if width <= 0 { + return "[]" + } + if total <= 0 { + return "[" + strings.Repeat("░", width) + "]" + } + + if completed < 0 { + completed = 0 + } + + filled := min(completed*width/total, width) + + return "[" + strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + "]" +} + +func ProgressLine(label string, completed, total, succeeded, failed, barWidth int) string { + if total == 0 { + return fmt.Sprintf("%s [no items]", label) + } + + bar := ProgressBar(completed, total, barWidth) + percent := min(completed*100/total, 100) + + return fmt.Sprintf("%s %s %3d%% %d/%d passed:%d failed:%d", label, bar, percent, completed, total, succeeded, failed) +} diff --git a/internal/io/progress_test.go b/internal/io/progress_test.go new file mode 100644 index 00000000..06b3e529 --- /dev/null +++ b/internal/io/progress_test.go @@ -0,0 +1,32 @@ +package io + +import "testing" + +func TestProgressBar(t *testing.T) { + t.Parallel() + + bar := ProgressBar(5, 10, 10) + if bar != "[█████░░░░░]" { + t.Fatalf("progress bar mismatch: %q", bar) + } + + empty := ProgressBar(0, 0, 4) + if empty != "[░░░░]" { + t.Fatalf("empty bar mismatch: %q", empty) + } +} + +func TestProgressLine(t *testing.T) { + t.Parallel() + + line := ProgressLine("Work", 3, 10, 2, 1, 6) + expected := "Work [█░░░░░] 30% 3/10 passed:2 failed:1" + if line != expected { + t.Fatalf("progress line mismatch: got %q want %q", line, expected) + } + + noItems := ProgressLine("Work", 0, 0, 0, 0, 6) + if noItems != "Work [no items]" { + t.Fatalf("no items line mismatch: %q", noItems) + } +} diff --git a/main.go b/main.go index dec788eb..9d5414c3 100644 --- a/main.go +++ b/main.go @@ -33,6 +33,7 @@ type CLI struct { Yes bool `help:"Skip all confirmation prompts" short:"y"` NoInput bool `help:"Disable all interactive prompts" name:"no-input"` Quiet bool `help:"Suppress progress output" short:"q"` + NoPager bool `help:"Disable pager for text output" name:"no-pager"` // Verbose bool `help:"Enable verbose error output" short:"V"` // TODO: Implement this, atm this is just a skeleton flag Agent AgentCmd `cmd:"" help:"Manage agents"` @@ -237,6 +238,7 @@ func run() int { Yes: cliInstance.Yes, NoInput: cliInstance.NoInput, Quiet: cliInstance.Quiet, + NoPager: cliInstance.NoPager, } ctx.BindTo(cli.GlobalFlags(globals), (*cli.GlobalFlags)(nil)) diff --git a/pkg/cmd/factory/factory.go b/pkg/cmd/factory/factory.go index 414d1311..ca271b2f 100644 --- a/pkg/cmd/factory/factory.go +++ b/pkg/cmd/factory/factory.go @@ -23,6 +23,7 @@ type Factory struct { SkipConfirm bool NoInput bool Quiet bool + NoPager bool } // SetGlobalFlags reads the global persistent flags and sets them on the factory. diff --git a/pkg/output/color.go b/pkg/output/color.go new file mode 100644 index 00000000..27f10f8d --- /dev/null +++ b/pkg/output/color.go @@ -0,0 +1,22 @@ +package output + +import ( + "os" + "sync" +) + +var ( + colorOnce sync.Once + colorEnabled = true +) + +// ColorEnabled returns false when the NO_COLOR environment variable is present +// See https://no-color.org for the convention +func ColorEnabled() bool { + colorOnce.Do(func() { + if _, disabled := os.LookupEnv("NO_COLOR"); disabled { + colorEnabled = false + } + }) + return colorEnabled +} diff --git a/pkg/output/table.go b/pkg/output/table.go new file mode 100644 index 00000000..03087aa4 --- /dev/null +++ b/pkg/output/table.go @@ -0,0 +1,133 @@ +package output + +import ( + "regexp" + "strings" + + "github.com/mattn/go-runewidth" +) + +const ( + ansiReset = "\033[0m" + ansiBold = "\033[1m" + ansiDim = "\033[2m" + ansiItalic = "\033[3m" + ansiUnderline = "\033[4m" + ansiDimUnder = "\033[2;4m" + ansiStrikeThrough = "\033[9m" + colSeparator = " " +) + +// ansiPattern strips ANSI/OSC escape sequences +var ansiPattern = regexp.MustCompile(`\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\a]*(?:\a|\x1b\\)|[P_\]^][^\x1b]*\x1b\\)`) + +func Table(headers []string, rows [][]string, columnStyles map[string]string) string { + if len(headers) == 0 { + return "" + } + + useColor := ColorEnabled() + + upperHeaders := make([]string, len(headers)) + colStyles := make([]string, len(headers)) + for i, header := range headers { + upperHeaders[i] = strings.ToUpper(header) + + style := columnStyles[strings.ToLower(header)] + if style != "" && useColor { + switch style { + case "bold": + colStyles[i] = ansiBold + case "dim": + colStyles[i] = ansiDim + case "italic": + colStyles[i] = ansiItalic + case "underline": + colStyles[i] = ansiUnderline + case "strikethrough": + colStyles[i] = ansiStrikeThrough + default: + colStyles[i] = "" + } + } + } + + // Start widths from rendered headers + colWidths := make([]int, len(headers)) + for i, header := range upperHeaders { + colWidths[i] = displayWidth(header) + } + + // Ensure widths grow based on content + for _, row := range rows { + for i := 0; i < len(row) && i < len(colWidths); i++ { + if w := displayWidth(row[i]); w > colWidths[i] { + colWidths[i] = w + } + } + } + + // Roughly size the buffer to avoid extra allocations + totalWidth := 0 + for _, w := range colWidths { + totalWidth += w + len(colSeparator) // width + separator + } + estimatedSize := totalWidth * (len(rows) + 1) // headers + all rows + var sb strings.Builder + sb.Grow(estimatedSize) + + for i, upperHeader := range upperHeaders { + if useColor { + sb.WriteString(ansiDimUnder) + } + writePadded(&sb, upperHeader, colWidths[i]) + if useColor { + sb.WriteString(ansiReset) + } + if i < len(headers)-1 { + sb.WriteString(colSeparator) + } + } + sb.WriteString("\n") + + for _, row := range rows { + for i := range headers { + value := "" + if i < len(row) { + value = row[i] + } + + if colStyles[i] != "" { + sb.WriteString(colStyles[i]) + } + + writePadded(&sb, value, colWidths[i]) + + if colStyles[i] != "" { + sb.WriteString(ansiReset) + } + + if i < len(headers)-1 { + sb.WriteString(colSeparator) + } + } + sb.WriteString("\n") + } + + return sb.String() +} + +// displayWidth returns visible width without escape codes. +func displayWidth(s string) int { + stripped := ansiPattern.ReplaceAllString(s, "") + return runewidth.StringWidth(stripped) +} + +// writePadded writes s and pads based on visible width. +func writePadded(sb *strings.Builder, s string, width int) { + visible := displayWidth(s) + sb.WriteString(s) + for i := visible; i < width; i++ { + sb.WriteByte(' ') + } +}