diff --git a/cmd/bd/list.go b/cmd/bd/list.go index 821e8201..4b14e2a8 100644 --- a/cmd/bd/list.go +++ b/cmd/bd/list.go @@ -306,6 +306,61 @@ func sortIssues(issues []*types.Issue, sortBy string, reverse bool) { }) } +// formatIssueLong formats a single issue in long format to a buffer +func formatIssueLong(buf *strings.Builder, issue *types.Issue, labels []string) { + status := string(issue.Status) + if status == "closed" { + line := fmt.Sprintf("%s%s [P%d] [%s] %s\n %s", + pinIndicator(issue), issue.ID, issue.Priority, + issue.IssueType, status, issue.Title) + buf.WriteString(ui.RenderClosedLine(line)) + buf.WriteString("\n") + } else { + buf.WriteString(fmt.Sprintf("%s%s [%s] [%s] %s\n", + pinIndicator(issue), + ui.RenderID(issue.ID), + ui.RenderPriority(issue.Priority), + ui.RenderType(string(issue.IssueType)), + ui.RenderStatus(status))) + buf.WriteString(fmt.Sprintf(" %s\n", issue.Title)) + } + if issue.Assignee != "" { + buf.WriteString(fmt.Sprintf(" Assignee: %s\n", issue.Assignee)) + } + if len(labels) > 0 { + buf.WriteString(fmt.Sprintf(" Labels: %v\n", labels)) + } + buf.WriteString("\n") +} + +// formatIssueCompact formats a single issue in compact format to a buffer +func formatIssueCompact(buf *strings.Builder, issue *types.Issue, labels []string) { + labelsStr := "" + if len(labels) > 0 { + labelsStr = fmt.Sprintf(" %v", labels) + } + assigneeStr := "" + if issue.Assignee != "" { + assigneeStr = fmt.Sprintf(" @%s", issue.Assignee) + } + status := string(issue.Status) + if status == "closed" { + line := fmt.Sprintf("%s%s [P%d] [%s] %s%s%s - %s", + pinIndicator(issue), issue.ID, issue.Priority, + issue.IssueType, status, assigneeStr, labelsStr, issue.Title) + buf.WriteString(ui.RenderClosedLine(line)) + buf.WriteString("\n") + } else { + buf.WriteString(fmt.Sprintf("%s%s [%s] [%s] %s%s%s - %s\n", + pinIndicator(issue), + ui.RenderID(issue.ID), + ui.RenderPriority(issue.Priority), + ui.RenderType(string(issue.IssueType)), + ui.RenderStatus(status), + assigneeStr, labelsStr, issue.Title)) + } +} + var listCmd = &cobra.Command{ Use: "list", GroupID: "issues", @@ -373,6 +428,9 @@ var listCmd = &cobra.Command{ prettyFormat, _ := cmd.Flags().GetBool("pretty") watchMode, _ := cmd.Flags().GetBool("watch") + // Pager control (bd-jdz3) + noPager, _ := cmd.Flags().GetBool("no-pager") + // Watch mode implies pretty format if watchMode { prettyFormat = true @@ -683,63 +741,26 @@ var listCmd = &cobra.Command{ // Apply sorting sortIssues(issues, sortBy, reverse) + // Build output in buffer for pager support (bd-jdz3) + var buf strings.Builder if longFormat { // Long format: multi-line with details - fmt.Printf("\nFound %d issues:\n\n", len(issues)) + buf.WriteString(fmt.Sprintf("\nFound %d issues:\n\n", len(issues))) for _, issue := range issues { - status := string(issue.Status) - if status == "closed" { - // Entire closed issue is dimmed - line := fmt.Sprintf("%s%s [P%d] [%s] %s\n %s", - pinIndicator(issue), issue.ID, issue.Priority, - issue.IssueType, status, issue.Title) - fmt.Println(ui.RenderClosedLine(line)) - } else { - fmt.Printf("%s%s [%s] [%s] %s\n", - pinIndicator(issue), - ui.RenderID(issue.ID), - ui.RenderPriority(issue.Priority), - ui.RenderType(string(issue.IssueType)), - ui.RenderStatus(status)) - fmt.Printf(" %s\n", issue.Title) - } - if issue.Assignee != "" { - fmt.Printf(" Assignee: %s\n", issue.Assignee) - } - if len(issue.Labels) > 0 { - fmt.Printf(" Labels: %v\n", issue.Labels) - } - fmt.Println() + formatIssueLong(&buf, issue, issue.Labels) } } else { // Compact format: one line per issue for _, issue := range issues { - labelsStr := "" - if len(issue.Labels) > 0 { - labelsStr = fmt.Sprintf(" %v", issue.Labels) - } - assigneeStr := "" - if issue.Assignee != "" { - assigneeStr = fmt.Sprintf(" @%s", issue.Assignee) - } - status := string(issue.Status) - if status == "closed" { - // Entire closed line is dimmed - line := fmt.Sprintf("%s%s [P%d] [%s] %s%s%s - %s", - pinIndicator(issue), issue.ID, issue.Priority, - issue.IssueType, status, assigneeStr, labelsStr, issue.Title) - fmt.Println(ui.RenderClosedLine(line)) - } else { - fmt.Printf("%s%s [%s] [%s] %s%s%s - %s\n", - pinIndicator(issue), - ui.RenderID(issue.ID), - ui.RenderPriority(issue.Priority), - ui.RenderType(string(issue.IssueType)), - ui.RenderStatus(status), - assigneeStr, labelsStr, issue.Title) - } + formatIssueCompact(&buf, issue, issue.Labels) } } + + // Output with pager support + if err := ui.ToPager(buf.String(), ui.PagerOptions{NoPager: noPager}); err != nil { + fmt.Fprint(os.Stdout, buf.String()) + } + // Show truncation hint if we hit the limit (GH#788) if effectiveLimit > 0 && len(issues) == effectiveLimit { fmt.Fprintf(os.Stderr, "\nShowing %d issues (use --limit 0 for all)\n", effectiveLimit) @@ -836,68 +857,28 @@ var listCmd = &cobra.Command{ } labelsMap, _ := store.GetLabelsForIssues(ctx, issueIDs) + // Build output in buffer for pager support (bd-jdz3) + var buf strings.Builder if longFormat { // Long format: multi-line with details - fmt.Printf("\nFound %d issues:\n\n", len(issues)) + buf.WriteString(fmt.Sprintf("\nFound %d issues:\n\n", len(issues))) for _, issue := range issues { labels := labelsMap[issue.ID] - status := string(issue.Status) - - if status == "closed" { - // Entire closed issue is dimmed - line := fmt.Sprintf("%s%s [P%d] [%s] %s\n %s", - pinIndicator(issue), issue.ID, issue.Priority, - issue.IssueType, status, issue.Title) - fmt.Println(ui.RenderClosedLine(line)) - } else { - fmt.Printf("%s%s [%s] [%s] %s\n", - pinIndicator(issue), - ui.RenderID(issue.ID), - ui.RenderPriority(issue.Priority), - ui.RenderType(string(issue.IssueType)), - ui.RenderStatus(status)) - fmt.Printf(" %s\n", issue.Title) - } - if issue.Assignee != "" { - fmt.Printf(" Assignee: %s\n", issue.Assignee) - } - if len(labels) > 0 { - fmt.Printf(" Labels: %v\n", labels) - } - fmt.Println() + formatIssueLong(&buf, issue, labels) } } else { // Compact format: one line per issue for _, issue := range issues { labels := labelsMap[issue.ID] - - labelsStr := "" - if len(labels) > 0 { - labelsStr = fmt.Sprintf(" %v", labels) - } - assigneeStr := "" - if issue.Assignee != "" { - assigneeStr = fmt.Sprintf(" @%s", issue.Assignee) - } - status := string(issue.Status) - if status == "closed" { - // Entire closed line is dimmed - line := fmt.Sprintf("%s%s [P%d] [%s] %s%s%s - %s", - pinIndicator(issue), issue.ID, issue.Priority, - issue.IssueType, status, assigneeStr, labelsStr, issue.Title) - fmt.Println(ui.RenderClosedLine(line)) - } else { - fmt.Printf("%s%s [%s] [%s] %s%s%s - %s\n", - pinIndicator(issue), - ui.RenderID(issue.ID), - ui.RenderPriority(issue.Priority), - ui.RenderType(string(issue.IssueType)), - ui.RenderStatus(status), - assigneeStr, labelsStr, issue.Title) - } + formatIssueCompact(&buf, issue, labels) } } + // Output with pager support + if err := ui.ToPager(buf.String(), ui.PagerOptions{NoPager: noPager}); err != nil { + fmt.Fprint(os.Stdout, buf.String()) + } + // Show truncation hint if we hit the limit (GH#788) if effectiveLimit > 0 && len(issues) == effectiveLimit { fmt.Fprintf(os.Stderr, "\nShowing %d issues (use --limit 0 for all)\n", effectiveLimit) @@ -963,6 +944,9 @@ func init() { listCmd.Flags().Bool("pretty", false, "Display issues in a tree format with status/priority symbols") listCmd.Flags().BoolP("watch", "w", false, "Watch for changes and auto-update display (implies --pretty)") + // Pager control (bd-jdz3) + listCmd.Flags().Bool("no-pager", false, "Disable pager output") + // Note: --json flag is defined as a persistent flag in main.go, not here rootCmd.AddCommand(listCmd) } diff --git a/internal/ui/pager.go b/internal/ui/pager.go new file mode 100644 index 00000000..b6be46aa --- /dev/null +++ b/internal/ui/pager.go @@ -0,0 +1,119 @@ +// Package ui provides terminal styling and pager support for beads CLI output. +package ui + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "golang.org/x/term" +) + +// PagerOptions controls pager behavior +type PagerOptions struct { + // NoPager disables pager for this command (--no-pager flag) + NoPager bool +} + +// shouldUsePager determines if output should be piped to a pager. +// Returns false if: +// - NoPager option is set +// - BD_NO_PAGER environment variable is set +// - stdout is not a TTY (e.g., piped to another command) +func shouldUsePager(opts PagerOptions) bool { + if opts.NoPager { + return false + } + + if os.Getenv("BD_NO_PAGER") != "" { + return false + } + + // Check if stdout is a terminal + if !term.IsTerminal(int(os.Stdout.Fd())) { + return false + } + + return true +} + +// getPagerCommand returns the pager command to use. +// Checks BD_PAGER, then PAGER, defaults to "less". +func getPagerCommand() string { + if pager := os.Getenv("BD_PAGER"); pager != "" { + return pager + } + if pager := os.Getenv("PAGER"); pager != "" { + return pager + } + return "less" +} + +// getTerminalHeight returns the height of the terminal in lines. +// Returns 0 if unable to determine (not a TTY). +func getTerminalHeight() int { + fd := int(os.Stdout.Fd()) + if !term.IsTerminal(fd) { + return 0 + } + + _, height, err := term.GetSize(fd) + if err != nil { + return 0 + } + return height +} + +// contentHeight counts the number of lines in the content. +func contentHeight(content string) int { + if content == "" { + return 0 + } + return strings.Count(content, "\n") + 1 +} + +// ToPager pipes content to a pager if appropriate. +// If pager should not be used (not a TTY, --no-pager, etc.), prints directly. +// If content fits in terminal, prints directly without pager. +func ToPager(content string, opts PagerOptions) error { + if !shouldUsePager(opts) { + fmt.Print(content) + return nil + } + + // Check if content exceeds terminal height + termHeight := getTerminalHeight() + if termHeight > 0 && contentHeight(content) <= termHeight-1 { + // Content fits in terminal, no pager needed + fmt.Print(content) + return nil + } + + // Use pager + pagerCmd := getPagerCommand() + + // Parse pager command (may include arguments like "less -R") + parts := strings.Fields(pagerCmd) + if len(parts) == 0 { + fmt.Print(content) + return nil + } + + cmd := exec.Command(parts[0], parts[1:]...) + cmd.Stdin = strings.NewReader(content) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Set LESS environment variable for sensible defaults if not already set + // -R: Allow ANSI color codes + // -F: Quit if content fits on one screen + // -X: Don't clear screen on exit + if os.Getenv("LESS") == "" { + cmd.Env = append(os.Environ(), "LESS=-RFX") + } else { + cmd.Env = os.Environ() + } + + return cmd.Run() +} diff --git a/internal/ui/pager_test.go b/internal/ui/pager_test.go new file mode 100644 index 00000000..69f0b68c --- /dev/null +++ b/internal/ui/pager_test.go @@ -0,0 +1,174 @@ +package ui + +import ( + "os" + "strings" + "testing" +) + +func TestContentHeight(t *testing.T) { + tests := []struct { + name string + content string + want int + }{ + { + name: "empty string", + content: "", + want: 0, + }, + { + name: "single line", + content: "hello", + want: 1, + }, + { + name: "single line with newline", + content: "hello\n", + want: 2, + }, + { + name: "multiple lines", + content: "line1\nline2\nline3", + want: 3, + }, + { + name: "multiple lines with trailing newline", + content: "line1\nline2\nline3\n", + want: 4, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := contentHeight(tt.content) + if got != tt.want { + t.Errorf("contentHeight() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestShouldUsePager(t *testing.T) { + tests := []struct { + name string + opts PagerOptions + envVars map[string]string + wantPager bool + }{ + { + name: "NoPager option set", + opts: PagerOptions{NoPager: true}, + wantPager: false, + }, + { + name: "BD_NO_PAGER env set", + opts: PagerOptions{NoPager: false}, + envVars: map[string]string{"BD_NO_PAGER": "1"}, + wantPager: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up env vars + for k, v := range tt.envVars { + oldVal := os.Getenv(k) + os.Setenv(k, v) + defer os.Setenv(k, oldVal) + } + + got := shouldUsePager(tt.opts) + if got != tt.wantPager { + t.Errorf("shouldUsePager() = %v, want %v", got, tt.wantPager) + } + }) + } +} + +func TestGetPagerCommand(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + wantPager string + }{ + { + name: "default pager", + envVars: map[string]string{}, + wantPager: "less", + }, + { + name: "BD_PAGER set", + envVars: map[string]string{"BD_PAGER": "more"}, + wantPager: "more", + }, + { + name: "PAGER set", + envVars: map[string]string{"PAGER": "cat"}, + wantPager: "cat", + }, + { + name: "BD_PAGER takes precedence over PAGER", + envVars: map[string]string{"BD_PAGER": "more", "PAGER": "cat"}, + wantPager: "more", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save and clear relevant env vars + oldBdPager := os.Getenv("BD_PAGER") + oldPager := os.Getenv("PAGER") + os.Unsetenv("BD_PAGER") + os.Unsetenv("PAGER") + defer func() { + if oldBdPager != "" { + os.Setenv("BD_PAGER", oldBdPager) + } + if oldPager != "" { + os.Setenv("PAGER", oldPager) + } + }() + + // Set up env vars for test + for k, v := range tt.envVars { + os.Setenv(k, v) + } + + got := getPagerCommand() + if got != tt.wantPager { + t.Errorf("getPagerCommand() = %q, want %q", got, tt.wantPager) + } + }) + } +} + +func TestToPagerNoPagerOption(t *testing.T) { + // Create a test output that we want to capture + content := "test content\n" + + // With NoPager=true, ToPager should just print directly + // (we can't easily capture stdout in a test, but we can verify no error) + err := ToPager(content, PagerOptions{NoPager: true}) + if err != nil { + t.Errorf("ToPager() returned error: %v", err) + } +} + +func TestToPagerWithBdNoPagerEnv(t *testing.T) { + oldVal := os.Getenv("BD_NO_PAGER") + os.Setenv("BD_NO_PAGER", "1") + defer func() { + if oldVal != "" { + os.Setenv("BD_NO_PAGER", oldVal) + } else { + os.Unsetenv("BD_NO_PAGER") + } + }() + + content := strings.Repeat("line\n", 100) + err := ToPager(content, PagerOptions{}) + if err != nil { + t.Errorf("ToPager() returned error: %v", err) + } +}