From 75d202f9a5b0001747f81b1e3225467ed43a5f47 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 20 Jun 2026 13:47:16 -0400 Subject: [PATCH 1/3] fix(init): align main menu order and styling Closes #354 --- go.mod | 2 +- internal/cmd/credentialcmd/credentialcmd.go | 38 +-- .../cmd/credentialcmd/credentialcmd_test.go | 223 ++++++++++++----- internal/cmd/credentialcmd/init_menu.go | 230 ++++++++++++++++++ 4 files changed, 401 insertions(+), 92 deletions(-) create mode 100644 internal/cmd/credentialcmd/init_menu.go diff --git a/go.mod b/go.mod index e7a9c0b7..2ac2ebe9 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/open-cli-collective/cli-common v0.4.0 github.com/spf13/cobra v1.10.2 golang.org/x/sys v0.46.0 + golang.org/x/term v0.44.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.51.0 ) @@ -64,7 +65,6 @@ require ( go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.72.3 // indirect diff --git a/internal/cmd/credentialcmd/credentialcmd.go b/internal/cmd/credentialcmd/credentialcmd.go index a3088e95..a6aaba05 100644 --- a/internal/cmd/credentialcmd/credentialcmd.go +++ b/internal/cmd/credentialcmd/credentialcmd.go @@ -1672,15 +1672,14 @@ func editInteractiveInitSecretsManagement(_ *cobra.Command, opts *root.Options, } func (p huhInitMenuPrompter) ChooseAction(prompt initMenuPrompt) (initMenuAction, error) { + if !initMenuUseAccessibleFallback(p.stdin, p.stderr) { + return runInitMenu(prompt, p.stdin, p.stderr) + } action := initMenuInitialAction(prompt) - options := []huh.Option[initMenuAction]{ - huh.NewOption(fmt.Sprintf("Configure LLM runtimes (%d)", prompt.LLMRuntimeCount), initMenuActionLLMRuntimes), - huh.NewOption(fmt.Sprintf("Configure reviewer entities (%d)", prompt.ReviewerEntityCount), initMenuActionReviewerEntities), - huh.NewOption(fmt.Sprintf("Configure review profiles (%d)", prompt.ReviewProfileCount), initMenuActionReviewProfiles), - huh.NewOption("Configure global settings", initMenuActionGlobalSettings), - huh.NewOption("Configure secrets management", initMenuActionSecretsManagement), - huh.NewOption("Commit staged changes and exit", initMenuActionSave), - huh.NewOption("Discard staged changes and exit", initMenuActionExit), + items := initMenuItems(prompt) + options := make([]huh.Option[initMenuAction], 0, len(items)) + for _, item := range items { + options = append(options, huh.NewOption(item.Title, item.Action)) } form := huh.NewForm( huh.NewGroup( @@ -1690,20 +1689,8 @@ func (p huhInitMenuPrompter) ChooseAction(prompt initMenuPrompt) (initMenuAction Options(options...). Value(&action). Validate(func(value initMenuAction) error { - switch value { - case initMenuActionLLMRuntimes: - if !prompt.CanConfigureLLM { - return errors.New("configure a review profile before editing LLM runtimes") - } - case initMenuActionReviewerEntities: - if !prompt.CanConfigureReviewer { - return errors.New("configure a review profile before editing reviewer entities") - } - case initMenuActionSave: - if !prompt.CanSave { - return errors.New("configure a review profile before committing changes") - } - case initMenuActionReviewProfiles, initMenuActionGlobalSettings, initMenuActionSecretsManagement, initMenuActionExit: + if reason := initMenuDisabledReason(prompt, value); reason != "" { + return errors.New(reason) } return nil }), @@ -1716,11 +1703,8 @@ func (p huhInitMenuPrompter) ChooseAction(prompt initMenuPrompt) (initMenuAction } func initMenuInitialAction(prompt initMenuPrompt) initMenuAction { - if prompt.CanConfigureLLM { - return initMenuActionLLMRuntimes - } - if prompt.CanConfigureReviewer { - return initMenuActionReviewerEntities + if prompt.HasWorkspace { + return initMenuActionSecretsManagement } return initMenuActionReviewProfiles } diff --git a/internal/cmd/credentialcmd/credentialcmd_test.go b/internal/cmd/credentialcmd/credentialcmd_test.go index 3b1c15cc..ca0ebb14 100644 --- a/internal/cmd/credentialcmd/credentialcmd_test.go +++ b/internal/cmd/credentialcmd/credentialcmd_test.go @@ -12065,6 +12065,114 @@ func TestBuildInteractiveInitMenuPromptNoWorkspaceStillShowsExistingInventoryCou } } +func TestInitMenuItemsOrdersRootMenuAndMovesCountsToDescriptions(t *testing.T) { + items := initMenuItems(initMenuPrompt{ + HasWorkspace: true, + LLMRuntimeCount: 2, + ReviewerEntityCount: 3, + ReviewProfileCount: 1, + CanConfigureLLM: true, + CanConfigureReviewer: true, + CanSave: true, + }) + var actions []initMenuAction + var titles []string + var descriptions []string + for _, item := range items { + actions = append(actions, item.Action) + titles = append(titles, item.Title) + descriptions = append(descriptions, item.Description) + if strings.Contains(item.Title, "(") || strings.Contains(item.Title, ")") { + t.Fatalf("menu title %q contains old inline count suffix", item.Title) + } + } + wantActions := []initMenuAction{ + initMenuActionSecretsManagement, + initMenuActionLLMRuntimes, + initMenuActionReviewerEntities, + initMenuActionReviewProfiles, + initMenuActionGlobalSettings, + initMenuActionSave, + initMenuActionExit, + } + if !reflect.DeepEqual(actions, wantActions) { + t.Fatalf("actions = %#v, want %#v", actions, wantActions) + } + assertContentOrder(t, strings.Join(titles, "\n"), + "Configure secrets management", + "Configure LLM runtimes", + "Configure reviewer entities", + "Configure review profiles", + "Configure global settings", + "Commit staged changes and exit", + "Discard staged changes and exit", + ) + joinedDescriptions := strings.Join(descriptions, "\n") + for _, want := range []string{ + "2 runtimes configured", + "3 reviewer entities configured", + "1 profile configured", + } { + if !strings.Contains(joinedDescriptions, want) { + t.Fatalf("descriptions = %#v, want %q", descriptions, want) + } + } +} + +func TestInitMenuStyledViewShowsRootMenuOrder(t *testing.T) { + model := newInitMenuModel(initMenuPrompt{ + HasWorkspace: true, + ActiveProfileName: "default", + LLMRuntimeCount: 2, + ReviewerEntityCount: 3, + ReviewProfileCount: 1, + CanConfigureLLM: true, + CanConfigureReviewer: true, + CanSave: true, + }) + out := model.View() + assertContentOrder(t, out, + "cr init", + "Active profile: default", + "Configure secrets management", + "Configure LLM runtimes", + "Configure reviewer entities", + "Configure review profiles", + "Configure global settings", + "Commit staged changes and exit", + "Discard staged changes and exit", + ) + if !strings.Contains(out, ">") { + t.Fatalf("view missing selected-row caret:\n%s", out) + } + if strings.Contains(out, "Configure LLM runtimes (2)") || strings.Contains(out, "Configure review profiles (1)") { + t.Fatalf("view contains old inline count suffix:\n%s", out) + } +} + +func TestInitMenuQDiscardsAndExits(t *testing.T) { + model := newInitMenuModel(initMenuPrompt{HasWorkspace: true}) + next, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + result := next.(initMenuModel) + if cmd == nil { + t.Fatal("cmd = nil, want quit command") + } + if result.result != initMenuActionExit || !result.quitting { + t.Fatalf("result = %#v, want discard exit", result) + } +} + +func TestInitMenuUseAccessibleFallback(t *testing.T) { + t.Setenv("TERM", "dumb") + if !initMenuUseAccessibleFallback(os.Stdin, os.Stderr) { + t.Fatal("TERM=dumb should use accessible fallback") + } + t.Setenv("TERM", "xterm") + if !initMenuUseAccessibleFallback(strings.NewReader(""), &bytes.Buffer{}) { + t.Fatal("non-file test streams should use accessible fallback") + } +} + func TestHuhInitMenuPrompterAccessibleShowsMenuEntries(t *testing.T) { t.Setenv("TERM", "dumb") var stderr bytes.Buffer @@ -12086,11 +12194,11 @@ func TestHuhInitMenuPrompterAccessibleShowsMenuEntries(t *testing.T) { } out := stderr.String() for _, want := range []string{ - "Configure LLM runtimes (2)", - "Configure reviewer entities (3)", - "Configure review profiles (1)", - "Configure global settings", "Configure secrets management", + "Configure LLM runtimes", + "Configure reviewer entities", + "Configure review profiles", + "Configure global settings", "Commit staged changes and exit", "Discard staged changes and exit", } { @@ -12104,38 +12212,55 @@ func TestHuhInitMenuPrompterAccessibleShowsMenuEntries(t *testing.T) { if strings.Contains(out, "Configure review profiles v2") { t.Fatalf("stderr = %q, want temporary v2 menu item removed", out) } + if strings.Contains(out, "Configure LLM runtimes (2)") || strings.Contains(out, "Configure reviewer entities (3)") || strings.Contains(out, "Configure review profiles (1)") { + t.Fatalf("stderr = %q, want fallback labels without old inline count suffixes", out) + } assertContentOrder(t, out, - "Configure LLM runtimes (2)", - "Configure reviewer entities (3)", - "Configure review profiles (1)", - "Configure global settings", "Configure secrets management", + "Configure LLM runtimes", + "Configure reviewer entities", + "Configure review profiles", + "Configure global settings", "Commit staged changes and exit", "Discard staged changes and exit", ) } -func TestHuhInitMenuPrompterAccessibleSelectsSecretsManagement(t *testing.T) { +func TestHuhInitMenuPrompterAccessibleNumericOrder(t *testing.T) { t.Setenv("TERM", "dumb") - var stderr bytes.Buffer - prompter := huhInitMenuPrompter{ - stdin: strings.NewReader("5\n"), - stderr: &stderr, - } - action, err := prompter.ChooseAction(initMenuPrompt{ - HasWorkspace: true, - LLMRuntimeCount: 2, - ReviewerEntityCount: 3, - ReviewProfileCount: 1, - CanConfigureLLM: true, - CanConfigureReviewer: true, - CanSave: true, - }) - if err != nil { - t.Fatalf("ChooseAction: %v", err) + tests := []struct { + input string + want initMenuAction + }{ + {input: "1\n", want: initMenuActionSecretsManagement}, + {input: "2\n", want: initMenuActionLLMRuntimes}, + {input: "3\n", want: initMenuActionReviewerEntities}, + {input: "4\n", want: initMenuActionReviewProfiles}, + {input: "5\n", want: initMenuActionGlobalSettings}, + {input: "6\n", want: initMenuActionSave}, + {input: "7\n", want: initMenuActionExit}, } - if action != initMenuActionSecretsManagement { - t.Fatalf("action = %q, want secrets management", action) + for _, tt := range tests { + var stderr bytes.Buffer + prompter := huhInitMenuPrompter{ + stdin: strings.NewReader(tt.input), + stderr: &stderr, + } + action, err := prompter.ChooseAction(initMenuPrompt{ + HasWorkspace: true, + LLMRuntimeCount: 2, + ReviewerEntityCount: 3, + ReviewProfileCount: 1, + CanConfigureLLM: true, + CanConfigureReviewer: true, + CanSave: true, + }) + if err != nil { + t.Fatalf("ChooseAction(%q): %v", tt.input, err) + } + if action != tt.want { + t.Fatalf("ChooseAction(%q) = %q, want %q", tt.input, action, tt.want) + } } } @@ -12159,18 +12284,18 @@ func TestHuhInitMenuPrompterDefaultStartsAtTopWhenProfileIsActive(t *testing.T) if err != nil { t.Fatalf("ChooseAction: %v", err) } - if action != initMenuActionLLMRuntimes { - t.Fatalf("action = %q, want LLM runtimes as first active-workspace configuration item", action) + if action != initMenuActionSecretsManagement { + t.Fatalf("action = %q, want secrets management as first active-workspace configuration item", action) } } -func TestInitMenuInitialActionFallsBackToReviewerWhenLLMDisabled(t *testing.T) { +func TestInitMenuInitialActionStartsAtSecretsManagementWhenWorkspaceIsActive(t *testing.T) { action := initMenuInitialAction(initMenuPrompt{ HasWorkspace: true, CanConfigureReviewer: true, }) - if action != initMenuActionReviewerEntities { - t.Fatalf("action = %q, want reviewer entities when LLM runtimes are disabled", action) + if action != initMenuActionSecretsManagement { + t.Fatalf("action = %q, want secrets management when workspace is active", action) } } @@ -12227,7 +12352,7 @@ func TestHuhInitMenuPrompterAccessibleRejectsDisabledLLMUntilProfileExists(t *te var stderr bytes.Buffer prompter := huhInitMenuPrompter{ stdin: strings.NewReader(strings.Join([]string{ - "1", // Configure LLM runtimes (disabled) + "2", // Configure LLM runtimes (disabled) "7", // Discard staged changes and exit "", }, "\n")), @@ -12250,7 +12375,7 @@ func TestHuhInitMenuPrompterAccessibleRejectsDisabledReviewerUntilProfileExists( var stderr bytes.Buffer prompter := huhInitMenuPrompter{ stdin: strings.NewReader(strings.Join([]string{ - "2", // Configure reviewer entities (disabled) + "3", // Configure reviewer entities (disabled) "7", // Discard staged changes and exit "", }, "\n")), @@ -12268,36 +12393,6 @@ func TestHuhInitMenuPrompterAccessibleRejectsDisabledReviewerUntilProfileExists( } } -func TestHuhInitMenuPrompterAccessibleSelectsReviewProfiles(t *testing.T) { - t.Setenv("TERM", "dumb") - var stderr bytes.Buffer - prompter := huhInitMenuPrompter{ - stdin: strings.NewReader("3\n"), - stderr: &stderr, - } - action, err := prompter.ChooseAction(initMenuPrompt{ - HasWorkspace: true, - LLMRuntimeCount: 2, - ReviewerEntityCount: 3, - ReviewProfileCount: 1, - CanConfigureLLM: true, - CanConfigureReviewer: true, - CanSave: true, - }) - if err != nil { - t.Fatalf("ChooseAction: %v", err) - } - if action != initMenuActionReviewProfiles { - t.Fatalf("action = %q, want review profiles", action) - } - if !strings.Contains(stderr.String(), "Configure review profiles (1)") { - t.Fatalf("stderr = %q, want review profile menu entry", stderr.String()) - } - if strings.Contains(stderr.String(), "Configure review profiles v2") { - t.Fatalf("stderr = %q, want temporary v2 menu entry removed", stderr.String()) - } -} - func TestInitProfileV2ReadOnlyContentRendersTargetOrderWithRealData(t *testing.T) { profile := basicProfile("open-cli-collective") reviewerRef, err := credentials.FormatRef("occ-reviewer") diff --git a/internal/cmd/credentialcmd/init_menu.go b/internal/cmd/credentialcmd/init_menu.go new file mode 100644 index 00000000..560e49ad --- /dev/null +++ b/internal/cmd/credentialcmd/init_menu.go @@ -0,0 +1,230 @@ +package credentialcmd + +import ( + "fmt" + "io" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "golang.org/x/term" +) + +type initMenuItem struct { + Action initMenuAction + Title string + Description string + Disabled string +} + +type initMenuModel struct { + items []initMenuItem + selected int + desc string + err string + result initMenuAction + quitting bool +} + +func runInitMenu(prompt initMenuPrompt, stdin io.Reader, stderr io.Writer) (initMenuAction, error) { + model := newInitMenuModel(prompt) + program := tea.NewProgram(model, tea.WithInput(stdin), tea.WithOutput(stderr)) + finalModel, err := program.Run() + if err != nil { + return "", err + } + resultModel, ok := finalModel.(initMenuModel) + if !ok || resultModel.result == "" { + return "", errInitNavigateBack + } + return resultModel.result, nil +} + +func initMenuUseAccessibleFallback(stdin io.Reader, stderr io.Writer) bool { + if strings.EqualFold(os.Getenv("TERM"), "dumb") { + return true + } + stdinFile, ok := stdin.(*os.File) + if !ok { + return true + } + stderrFile, ok := stderr.(*os.File) + if !ok { + return true + } + return !term.IsTerminal(int(stdinFile.Fd())) || !term.IsTerminal(int(stderrFile.Fd())) +} + +func newInitMenuModel(prompt initMenuPrompt) initMenuModel { + items := initMenuItems(prompt) + selected := initMenuSelectedIndex(items, initMenuInitialAction(prompt)) + return initMenuModel{ + items: items, + selected: selected, + desc: initMenuDescription(prompt), + } +} + +func initMenuSelectedIndex(items []initMenuItem, action initMenuAction) int { + for index, item := range items { + if item.Action == action { + return index + } + } + return 0 +} + +func initMenuItems(prompt initMenuPrompt) []initMenuItem { + items := []initMenuItem{ + { + Action: initMenuActionSecretsManagement, + Title: "Configure secrets management", + Description: "Credential-store profiles and default destination", + }, + { + Action: initMenuActionLLMRuntimes, + Title: "Configure LLM runtimes", + Description: initMenuCountDescription(prompt.LLMRuntimeCount, "runtime", "runtimes"), + }, + { + Action: initMenuActionReviewerEntities, + Title: "Configure reviewer entities", + Description: initMenuCountDescription(prompt.ReviewerEntityCount, "reviewer entity", "reviewer entities"), + }, + { + Action: initMenuActionReviewProfiles, + Title: "Configure review profiles", + Description: initMenuCountDescription(prompt.ReviewProfileCount, "profile", "profiles"), + }, + { + Action: initMenuActionGlobalSettings, + Title: "Configure global settings", + Description: "Data retention and global defaults", + }, + { + Action: initMenuActionSave, + Title: "Commit staged changes and exit", + Description: "Write staged config and credential changes", + }, + { + Action: initMenuActionExit, + Title: "Discard staged changes and exit", + Description: "Leave without writing staged changes", + }, + } + for index := range items { + items[index].Disabled = initMenuDisabledReason(prompt, items[index].Action) + if items[index].Disabled != "" { + items[index].Description = "Unavailable: " + items[index].Disabled + } + } + return items +} + +func initMenuCountDescription(count int, singular, plural string) string { + if count == 1 { + return fmt.Sprintf("1 %s configured", singular) + } + return fmt.Sprintf("%d %s configured", count, plural) +} + +func initMenuDisabledReason(prompt initMenuPrompt, action initMenuAction) string { + switch action { + case initMenuActionLLMRuntimes: + if !prompt.CanConfigureLLM { + return "configure a review profile before editing LLM runtimes" + } + case initMenuActionReviewerEntities: + if !prompt.CanConfigureReviewer { + return "configure a review profile before editing reviewer entities" + } + case initMenuActionSave: + if !prompt.CanSave { + return "configure a review profile before committing changes" + } + case initMenuActionSecretsManagement, initMenuActionReviewProfiles, initMenuActionGlobalSettings, initMenuActionExit: + } + return "" +} + +func (m initMenuModel) Init() tea.Cmd { + return nil +} + +func (m initMenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "ctrl+c", "q", "esc": + m.result = initMenuActionExit + m.quitting = true + return m, tea.Quit + case "up", "k", "shift+tab": + m.move(-1) + case "down", "j", "tab": + m.move(1) + case "enter": + if len(m.items) == 0 { + return m, nil + } + item := m.items[m.selected] + if item.Disabled != "" { + m.err = item.Disabled + return m, nil + } + m.result = item.Action + m.quitting = true + return m, tea.Quit + } + return m, nil +} + +func (m *initMenuModel) move(delta int) { + if len(m.items) == 0 { + return + } + m.err = "" + m.selected = (m.selected + delta + len(m.items)) % len(m.items) +} + +func (m initMenuModel) View() string { + if m.quitting { + return "" + } + var lines []string + lines = append(lines, initLinearTheme.title.Render("cr init")) + if strings.TrimSpace(m.desc) != "" { + lines = append(lines, initLinearTheme.help.Render(m.desc)) + } + lines = append(lines, "") + for index, item := range m.items { + lines = append(lines, m.renderItem(index, item)...) + } + if strings.TrimSpace(m.err) != "" { + lines = append(lines, "", initLinearTheme.error.Render("! "+m.err)) + } + lines = append(lines, "", initLinearTheme.help.Render("up/k previous - down/j next - enter select - q discard")) + return strings.Join(lines, "\n") +} + +func (m initMenuModel) renderItem(index int, item initMenuItem) []string { + selected := index == m.selected + titleStyle := lipgloss.NewStyle() + if item.Disabled != "" { + titleStyle = initLinearTheme.help + } + if selected { + titleStyle = initLinearTheme.selected + } + caret := " " + if selected { + caret = initLinearTheme.caret.Render(">") + } + return []string{ + fmt.Sprintf("%s %s", caret, titleStyle.Render(item.Title)), + " " + initLinearTheme.help.Render(item.Description), + } +} From 2580e1840e847c0c3ff70c6712d6a37ae9b68bd5 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 20 Jun 2026 13:51:11 -0400 Subject: [PATCH 2/3] test(init): cover main menu disabled rows --- .../cmd/credentialcmd/credentialcmd_test.go | 40 +++++++++++++++++++ internal/cmd/credentialcmd/init_menu.go | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/internal/cmd/credentialcmd/credentialcmd_test.go b/internal/cmd/credentialcmd/credentialcmd_test.go index ca0ebb14..1823eaae 100644 --- a/internal/cmd/credentialcmd/credentialcmd_test.go +++ b/internal/cmd/credentialcmd/credentialcmd_test.go @@ -12162,6 +12162,46 @@ func TestInitMenuQDiscardsAndExits(t *testing.T) { } } +func TestInitMenuDisabledRowsShowErrorWithoutQuitting(t *testing.T) { + tests := []struct { + action initMenuAction + reason string + }{ + { + action: initMenuActionLLMRuntimes, + reason: "configure a review profile before editing LLM runtimes", + }, + { + action: initMenuActionReviewerEntities, + reason: "configure a review profile before editing reviewer entities", + }, + { + action: initMenuActionSave, + reason: "configure a review profile before committing changes", + }, + } + for _, tt := range tests { + model := newInitMenuModel(initMenuPrompt{}) + model.selected = initMenuSelectedIndex(model.items, tt.action) + + next, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + result := next.(initMenuModel) + + if cmd != nil { + t.Fatalf("%s: cmd = %#v, want no quit command", tt.action, cmd) + } + if result.quitting || result.result != "" { + t.Fatalf("%s: result = %#v, want stay in menu without action", tt.action, result) + } + if !strings.Contains(result.err, tt.reason) { + t.Fatalf("%s: err = %q, want %q", tt.action, result.err, tt.reason) + } + if !strings.Contains(result.View(), "! "+tt.reason) { + t.Fatalf("%s: view missing rendered error:\n%s", tt.action, result.View()) + } + } +} + func TestInitMenuUseAccessibleFallback(t *testing.T) { t.Setenv("TERM", "dumb") if !initMenuUseAccessibleFallback(os.Stdin, os.Stderr) { diff --git a/internal/cmd/credentialcmd/init_menu.go b/internal/cmd/credentialcmd/init_menu.go index 560e49ad..ed2d71a0 100644 --- a/internal/cmd/credentialcmd/init_menu.go +++ b/internal/cmd/credentialcmd/init_menu.go @@ -158,7 +158,7 @@ func (m initMenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } switch keyMsg.String() { - case "ctrl+c", "q", "esc": + case "q": m.result = initMenuActionExit m.quitting = true return m, tea.Quit From 3fcf44dee5ca6ceb491910b6d99f7178bbc32eba Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 20 Jun 2026 13:55:39 -0400 Subject: [PATCH 3/3] test(init): exercise main menu tty path --- go.mod | 1 + .../cmd/credentialcmd/credentialcmd_test.go | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/go.mod b/go.mod index 2ac2ebe9..86dad2df 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 + github.com/creack/pty v1.1.24 github.com/google/uuid v1.6.0 github.com/open-cli-collective/cli-common v0.4.0 github.com/spf13/cobra v1.10.2 diff --git a/internal/cmd/credentialcmd/credentialcmd_test.go b/internal/cmd/credentialcmd/credentialcmd_test.go index 1823eaae..c1bb9ac8 100644 --- a/internal/cmd/credentialcmd/credentialcmd_test.go +++ b/internal/cmd/credentialcmd/credentialcmd_test.go @@ -17,6 +17,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" + "github.com/creack/pty" "github.com/open-cli-collective/cli-common/credstore" "github.com/spf13/cobra" "gopkg.in/yaml.v3" @@ -12135,12 +12136,19 @@ func TestInitMenuStyledViewShowsRootMenuOrder(t *testing.T) { "cr init", "Active profile: default", "Configure secrets management", + "Credential-store profiles and default destination", "Configure LLM runtimes", + "2 runtimes configured", "Configure reviewer entities", + "3 reviewer entities configured", "Configure review profiles", + "1 profile configured", "Configure global settings", + "Data retention and global defaults", "Commit staged changes and exit", + "Write staged config and credential changes", "Discard staged changes and exit", + "Leave without writing staged changes", ) if !strings.Contains(out, ">") { t.Fatalf("view missing selected-row caret:\n%s", out) @@ -12162,6 +12170,20 @@ func TestInitMenuQDiscardsAndExits(t *testing.T) { } } +func TestRunInitMenuExecutesBubbleTeaPath(t *testing.T) { + var stderr bytes.Buffer + action, err := runInitMenu(initMenuPrompt{HasWorkspace: true}, strings.NewReader("q"), &stderr) + if err != nil { + t.Fatalf("runInitMenu: %v", err) + } + if action != initMenuActionExit { + t.Fatalf("action = %q, want discard exit", action) + } + if strings.Contains(stderr.String(), "Configure LLM runtimes (") { + t.Fatalf("stderr contains old inline count suffix:\n%s", stderr.String()) + } +} + func TestInitMenuDisabledRowsShowErrorWithoutQuitting(t *testing.T) { tests := []struct { action initMenuAction @@ -12213,6 +12235,22 @@ func TestInitMenuUseAccessibleFallback(t *testing.T) { } } +func TestInitMenuUseAccessibleFallbackRecognizesTerminalFiles(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY test is Unix-only") + } + t.Setenv("TERM", "xterm") + master, slave, err := pty.Open() + if err != nil { + t.Fatalf("pty.Open: %v", err) + } + defer master.Close() + defer slave.Close() + if initMenuUseAccessibleFallback(slave, slave) { + t.Fatal("PTY-backed terminal files should use Bubble Tea menu") + } +} + func TestHuhInitMenuPrompterAccessibleShowsMenuEntries(t *testing.T) { t.Setenv("TERM", "dumb") var stderr bytes.Buffer