diff --git a/internal/iostreams/charm.go b/internal/iostreams/charm.go index 4e9e1dfb..d55b336e 100644 --- a/internal/iostreams/charm.go +++ b/internal/iostreams/charm.go @@ -22,40 +22,50 @@ import ( "slices" "github.com/charmbracelet/huh" + "github.com/slackapi/slack-cli/internal/style" ) -// charmInputPrompt prompts for text input using a charm huh form -func charmInputPrompt(_ *IOStreams, _ context.Context, message string, cfg InputPromptConfig) (string, error) { - var input string +// buildInputForm constructs a huh form for text input prompts. +func buildInputForm(message string, cfg InputPromptConfig, input *string) *huh.Form { field := huh.NewInput(). Title(message). - Value(&input) + Value(input) if cfg.Required { field.Validate(huh.ValidateMinLength(1)) } - err := huh.NewForm(huh.NewGroup(field)).WithTheme(ThemeSlack()).Run() + return huh.NewForm(huh.NewGroup(field)).WithTheme(style.ThemeSlack()) +} + +// charmInputPrompt prompts for text input using a charm huh form +func charmInputPrompt(_ *IOStreams, _ context.Context, message string, cfg InputPromptConfig) (string, error) { + var input string + err := buildInputForm(message, cfg, &input).Run() if err != nil { return "", err } return input, nil } +// buildConfirmForm constructs a huh form for yes/no confirmation prompts. +func buildConfirmForm(message string, choice *bool) *huh.Form { + field := huh.NewConfirm(). + Title(message). + Value(choice) + return huh.NewForm(huh.NewGroup(field)).WithTheme(style.ThemeSlack()) +} + // charmConfirmPrompt prompts for a yes/no confirmation using a charm huh form func charmConfirmPrompt(_ *IOStreams, _ context.Context, message string, defaultValue bool) (bool, error) { var choice = defaultValue - field := huh.NewConfirm(). - Title(message). - Value(&choice) - err := huh.NewForm(huh.NewGroup(field)).WithTheme(ThemeSlack()).Run() + err := buildConfirmForm(message, &choice).Run() if err != nil { return false, err } return choice, nil } -// charmSelectPrompt prompts the user to select one option using a charm huh form -func charmSelectPrompt(_ *IOStreams, _ context.Context, msg string, options []string, cfg SelectPromptConfig) (SelectPromptResponse, error) { - var selected string +// buildSelectForm constructs a huh form for single-selection prompts. +func buildSelectForm(msg string, options []string, cfg SelectPromptConfig, selected *string) *huh.Form { var opts []huh.Option[string] for _, opt := range options { key := opt @@ -70,13 +80,19 @@ func charmSelectPrompt(_ *IOStreams, _ context.Context, msg string, options []st field := huh.NewSelect[string](). Title(msg). Options(opts...). - Value(&selected) + Value(selected) if cfg.PageSize > 0 { field.Height(cfg.PageSize + 2) } - err := huh.NewForm(huh.NewGroup(field)).WithTheme(ThemeSlack()).Run() + return huh.NewForm(huh.NewGroup(field)).WithTheme(style.ThemeSlack()) +} + +// charmSelectPrompt prompts the user to select one option using a charm huh form +func charmSelectPrompt(_ *IOStreams, _ context.Context, msg string, options []string, cfg SelectPromptConfig) (SelectPromptResponse, error) { + var selected string + err := buildSelectForm(msg, options, cfg, &selected).Run() if err != nil { return SelectPromptResponse{}, err } @@ -85,26 +101,30 @@ func charmSelectPrompt(_ *IOStreams, _ context.Context, msg string, options []st return SelectPromptResponse{Prompt: true, Index: index, Option: selected}, nil } -// charmPasswordPrompt prompts for a password (hidden input) using a charm huh form -func charmPasswordPrompt(_ *IOStreams, _ context.Context, message string, cfg PasswordPromptConfig) (PasswordPromptResponse, error) { - var input string +// buildPasswordForm constructs a huh form for password (hidden input) prompts. +func buildPasswordForm(message string, cfg PasswordPromptConfig, input *string) *huh.Form { field := huh.NewInput(). Title(message). EchoMode(huh.EchoModePassword). - Value(&input) + Value(input) if cfg.Required { field.Validate(huh.ValidateMinLength(1)) } - err := huh.NewForm(huh.NewGroup(field)).WithTheme(ThemeSlack()).Run() + return huh.NewForm(huh.NewGroup(field)).WithTheme(style.ThemeSlack()) +} + +// charmPasswordPrompt prompts for a password (hidden input) using a charm huh form +func charmPasswordPrompt(_ *IOStreams, _ context.Context, message string, cfg PasswordPromptConfig) (PasswordPromptResponse, error) { + var input string + err := buildPasswordForm(message, cfg, &input).Run() if err != nil { return PasswordPromptResponse{}, err } return PasswordPromptResponse{Prompt: true, Value: input}, nil } -// charmMultiSelectPrompt prompts the user to select multiple options using a charm huh form -func charmMultiSelectPrompt(_ *IOStreams, _ context.Context, message string, options []string) ([]string, error) { - var selected []string +// buildMultiSelectForm constructs a huh form for multiple-selection prompts. +func buildMultiSelectForm(message string, options []string, selected *[]string) *huh.Form { var opts []huh.Option[string] for _, opt := range options { opts = append(opts, huh.NewOption(opt, opt)) @@ -113,9 +133,15 @@ func charmMultiSelectPrompt(_ *IOStreams, _ context.Context, message string, opt field := huh.NewMultiSelect[string](). Title(message). Options(opts...). - Value(&selected) + Value(selected) - err := huh.NewForm(huh.NewGroup(field)).WithTheme(ThemeSlack()).Run() + return huh.NewForm(huh.NewGroup(field)).WithTheme(style.ThemeSlack()) +} + +// charmMultiSelectPrompt prompts the user to select multiple options using a charm huh form +func charmMultiSelectPrompt(_ *IOStreams, _ context.Context, message string, options []string) ([]string, error) { + var selected []string + err := buildMultiSelectForm(message, options, &selected).Run() if err != nil { return []string{}, err } diff --git a/internal/iostreams/charm_test.go b/internal/iostreams/charm_test.go new file mode 100644 index 00000000..1075c8b1 --- /dev/null +++ b/internal/iostreams/charm_test.go @@ -0,0 +1,313 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package iostreams + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" +) + +// keys creates a tea.KeyMsg for the given runes (same helper used in huh_test.go). +func keys(runes ...rune) tea.KeyMsg { + return tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: runes, + } +} + +func TestCharmInput(t *testing.T) { + t.Run("renders the title", func(t *testing.T) { + var input string + f := buildInputForm("Enter your name", InputPromptConfig{}, &input) + f.Update(f.Init()) + + view := ansi.Strip(f.View()) + assert.Contains(t, view, "Enter your name") + }) + + t.Run("accepts typed input", func(t *testing.T) { + var input string + f := buildInputForm("Name?", InputPromptConfig{}, &input) + f.Update(f.Init()) + + f.Update(keys('H', 'u', 'h')) + + view := ansi.Strip(f.View()) + assert.Contains(t, view, "Huh") + }) + + t.Run("stores typed value", func(t *testing.T) { + var input string + f := buildInputForm("Name?", InputPromptConfig{}, &input) + f.Update(f.Init()) + + f.Update(keys('t', 'e', 's', 't')) + f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + assert.Equal(t, "test", input) + }) +} + +func TestCharmConfirm(t *testing.T) { + t.Run("renders the title and buttons", func(t *testing.T) { + choice := false + f := buildConfirmForm("Are you sure?", &choice) + f.Update(f.Init()) + + view := ansi.Strip(f.View()) + assert.Contains(t, view, "Are you sure?") + assert.Contains(t, view, "Yes") + assert.Contains(t, view, "No") + }) + + t.Run("default value is respected", func(t *testing.T) { + choice := true + f := buildConfirmForm("Continue?", &choice) + f.Update(f.Init()) + + assert.True(t, choice) + }) + + t.Run("toggle changes value", func(t *testing.T) { + choice := false + f := buildConfirmForm("Continue?", &choice) + f.Update(f.Init()) + + // Toggle to Yes + f.Update(tea.KeyMsg{Type: tea.KeyLeft}) + assert.True(t, choice) + + // Toggle back to No + f.Update(tea.KeyMsg{Type: tea.KeyRight}) + assert.False(t, choice) + }) +} + +func TestCharmSelect(t *testing.T) { + t.Run("renders the title and options", func(t *testing.T) { + var selected string + options := []string{"Foo", "Bar", "Baz"} + f := buildSelectForm("Pick one", options, SelectPromptConfig{}, &selected) + f.Update(f.Init()) + + view := ansi.Strip(f.View()) + assert.Contains(t, view, "Pick one") + assert.Contains(t, view, "Foo") + assert.Contains(t, view, "Bar") + assert.Contains(t, view, "Baz") + }) + + t.Run("cursor starts on first option", func(t *testing.T) { + var selected string + options := []string{"Foo", "Bar"} + f := buildSelectForm("Pick one", options, SelectPromptConfig{}, &selected) + f.Update(f.Init()) + + view := ansi.Strip(f.View()) + assert.Contains(t, view, "❱ Foo") + }) + + t.Run("cursor navigation moves selection", func(t *testing.T) { + var selected string + options := []string{"Foo", "Bar", "Baz"} + f := buildSelectForm("Pick one", options, SelectPromptConfig{}, &selected) + f.Update(f.Init()) + + m, _ := f.Update(tea.KeyMsg{Type: tea.KeyDown}) + view := ansi.Strip(m.View()) + assert.Contains(t, view, "❱ Bar") + assert.False(t, strings.Contains(view, "❱ Foo")) + }) + + t.Run("submit selects the hovered option", func(t *testing.T) { + var selected string + options := []string{"Foo", "Bar", "Baz"} + f := buildSelectForm("Pick one", options, SelectPromptConfig{}, &selected) + f.Update(f.Init()) + + // Move down to Bar, then submit + f.Update(tea.KeyMsg{Type: tea.KeyDown}) + f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + assert.Equal(t, "Bar", selected) + }) + + t.Run("descriptions are appended to option display", func(t *testing.T) { + var selected string + options := []string{"Alpha", "Beta"} + cfg := SelectPromptConfig{ + Description: func(opt string, _ int) string { + if opt == "Alpha" { + return "First letter" + } + return "" + }, + } + f := buildSelectForm("Choose", options, cfg, &selected) + f.Update(f.Init()) + + view := ansi.Strip(f.View()) + assert.Contains(t, view, "First letter") + }) + + t.Run("page size sets field height", func(t *testing.T) { + var selected string + options := []string{"A", "B", "C", "D", "E", "F", "G", "H"} + cfg := SelectPromptConfig{PageSize: 3} + f := buildSelectForm("Pick", options, cfg, &selected) + f.Update(f.Init()) + + view := ansi.Strip(f.View()) + // With PageSize 3 (height 5), not all 8 options should be visible + assert.Contains(t, view, "A") + // At minimum the form should render without error + assert.NotEmpty(t, view) + }) +} + +func TestCharmPassword(t *testing.T) { + t.Run("renders the title", func(t *testing.T) { + var input string + f := buildPasswordForm("Enter password", PasswordPromptConfig{}, &input) + f.Update(f.Init()) + + view := ansi.Strip(f.View()) + assert.Contains(t, view, "Enter password") + }) + + t.Run("typed characters are masked in view", func(t *testing.T) { + var input string + f := buildPasswordForm("Password", PasswordPromptConfig{}, &input) + f.Update(f.Init()) + + f.Update(keys('s', 'e', 'c', 'r', 'e', 't')) + + view := ansi.Strip(f.View()) + assert.NotContains(t, view, "secret") + }) + + t.Run("stores typed value despite masking", func(t *testing.T) { + var input string + f := buildPasswordForm("Password", PasswordPromptConfig{}, &input) + f.Update(f.Init()) + + f.Update(keys('a', 'b', 'c')) + f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + assert.Equal(t, "abc", input) + }) +} + +func TestCharmMultiSelect(t *testing.T) { + t.Run("renders the title and options", func(t *testing.T) { + var selected []string + options := []string{"Foo", "Bar", "Baz"} + f := buildMultiSelectForm("Pick many", options, &selected) + f.Update(f.Init()) + + view := ansi.Strip(f.View()) + assert.Contains(t, view, "Pick many") + assert.Contains(t, view, "Foo") + assert.Contains(t, view, "Bar") + assert.Contains(t, view, "Baz") + }) + + t.Run("toggle selection with x key", func(t *testing.T) { + var selected []string + options := []string{"Foo", "Bar"} + f := buildMultiSelectForm("Pick", options, &selected) + f.Update(f.Init()) + + // Toggle first item + m, _ := f.Update(keys('x')) + view := ansi.Strip(m.View()) + + // After toggle, the first item should show as selected (checkmark) + assert.Contains(t, view, "✓") + }) + + t.Run("submit returns toggled items", func(t *testing.T) { + var selected []string + options := []string{"Foo", "Bar", "Baz"} + f := buildMultiSelectForm("Pick", options, &selected) + f.Update(f.Init()) + + // Toggle Foo (first item) + f.Update(keys('x')) + // Move down and toggle Bar + f.Update(tea.KeyMsg{Type: tea.KeyDown}) + f.Update(keys('x')) + // Submit + f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + assert.ElementsMatch(t, []string{"Foo", "Bar"}, selected) + }) +} + +func TestCharmFormsUseSlackTheme(t *testing.T) { + t.Run("input form uses Slack theme", func(t *testing.T) { + var input string + f := buildInputForm("Test", InputPromptConfig{}, &input) + f.Update(f.Init()) + + // The Slack theme applies a thick left border with bright aubergine color. + // Verify the form renders with a border (the base theme includes a thick + // left border which renders as a vertical bar character). + view := f.View() + assert.Contains(t, view, "┃") + }) + + t.Run("select form renders themed cursor", func(t *testing.T) { + var selected string + f := buildSelectForm("Pick", []string{"A", "B"}, SelectPromptConfig{}, &selected) + f.Update(f.Init()) + + view := ansi.Strip(f.View()) + assert.Contains(t, view, "❱ A") + }) + + t.Run("multi-select form renders themed prefixes", func(t *testing.T) { + var selected []string + f := buildMultiSelectForm("Pick", []string{"A", "B"}, &selected) + f.Update(f.Init()) + + view := ansi.Strip(f.View()) + // Our Slack theme uses "[ ] " as unselected prefix + assert.Contains(t, view, "[ ]") + }) + + t.Run("all form builders apply ThemeSlack", func(t *testing.T) { + // Verify each builder returns a form that can Init and render without panic + var s string + var b bool + var ss []string + forms := []*huh.Form{ + buildInputForm("msg", InputPromptConfig{}, &s), + buildConfirmForm("msg", &b), + buildSelectForm("msg", []string{"a"}, SelectPromptConfig{}, &s), + buildPasswordForm("msg", PasswordPromptConfig{}, &s), + buildMultiSelectForm("msg", []string{"a"}, &ss), + } + for _, f := range forms { + f.Update(f.Init()) + assert.NotEmpty(t, f.View()) + } + }) +} diff --git a/internal/iostreams/charm_theme.go b/internal/style/charm_theme.go similarity index 99% rename from internal/iostreams/charm_theme.go rename to internal/style/charm_theme.go index da89c6c4..cfdc72c1 100644 --- a/internal/iostreams/charm_theme.go +++ b/internal/style/charm_theme.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package iostreams +package style // Slack brand theme for charmbracelet/huh prompts. // Uses official Slack brand colors to give the CLI a fun, playful feel. diff --git a/internal/style/charm_theme_test.go b/internal/style/charm_theme_test.go new file mode 100644 index 00000000..9e676d45 --- /dev/null +++ b/internal/style/charm_theme_test.go @@ -0,0 +1,112 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package style + +import ( + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/stretchr/testify/assert" +) + +func TestThemeSlack(t *testing.T) { + t.Run("returns a non-nil theme", func(t *testing.T) { + theme := ThemeSlack() + assert.NotNil(t, theme) + }) + + t.Run("focused title is bold", func(t *testing.T) { + theme := ThemeSlack() + assert.True(t, theme.Focused.Title.GetBold()) + }) + + t.Run("focused title uses aubergine foreground", func(t *testing.T) { + theme := ThemeSlack() + assert.Equal(t, lipgloss.Color("#7C2852"), theme.Focused.Title.GetForeground()) + }) + + t.Run("focused select selector renders cursor", func(t *testing.T) { + theme := ThemeSlack() + rendered := theme.Focused.SelectSelector.Render() + assert.Contains(t, rendered, "❱") + }) + + t.Run("focused multi-select selected prefix renders checkmark", func(t *testing.T) { + theme := ThemeSlack() + rendered := theme.Focused.SelectedPrefix.Render() + assert.Contains(t, rendered, "✓") + }) + + t.Run("focused multi-select unselected prefix renders brackets", func(t *testing.T) { + theme := ThemeSlack() + rendered := theme.Focused.UnselectedPrefix.Render() + assert.Contains(t, rendered, "[ ]") + }) + + t.Run("focused error message uses red foreground", func(t *testing.T) { + theme := ThemeSlack() + assert.Equal(t, lipgloss.Color("#e01e5a"), theme.Focused.ErrorMessage.GetForeground()) + }) + + t.Run("focused button uses aubergine background", func(t *testing.T) { + theme := ThemeSlack() + assert.Equal(t, lipgloss.Color("#7C2852"), theme.Focused.FocusedButton.GetBackground()) + }) + + t.Run("focused button is bold", func(t *testing.T) { + theme := ThemeSlack() + assert.True(t, theme.Focused.FocusedButton.GetBold()) + }) + + t.Run("blurred select selector is blank", func(t *testing.T) { + theme := ThemeSlack() + rendered := theme.Blurred.SelectSelector.Render() + assert.Contains(t, rendered, " ") + assert.NotContains(t, rendered, "❱") + }) + + t.Run("blurred multi-select selector is blank", func(t *testing.T) { + theme := ThemeSlack() + rendered := theme.Blurred.MultiSelectSelector.Render() + assert.Contains(t, rendered, " ") + assert.NotContains(t, rendered, "❱") + }) + + t.Run("blurred border is hidden", func(t *testing.T) { + theme := ThemeSlack() + borderStyle := theme.Blurred.Base.GetBorderStyle() + assert.Equal(t, lipgloss.HiddenBorder(), borderStyle) + }) + + t.Run("focused border uses aubergine", func(t *testing.T) { + theme := ThemeSlack() + assert.Equal(t, lipgloss.Color("#7C2852"), theme.Focused.Base.GetBorderLeftForeground()) + }) + + t.Run("focused text input prompt uses blue", func(t *testing.T) { + theme := ThemeSlack() + assert.Equal(t, lipgloss.Color("#36c5f0"), theme.Focused.TextInput.Prompt.GetForeground()) + }) + + t.Run("focused text input cursor uses yellow", func(t *testing.T) { + theme := ThemeSlack() + assert.Equal(t, lipgloss.Color("#ecb22e"), theme.Focused.TextInput.Cursor.GetForeground()) + }) + + t.Run("focused selected option uses green", func(t *testing.T) { + theme := ThemeSlack() + assert.Equal(t, lipgloss.Color("#2eb67d"), theme.Focused.SelectedOption.GetForeground()) + }) +}