From 962ccad15d2399d8c89e63e6f975c292f2afc20d Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 23 Feb 2026 23:33:46 -0800 Subject: [PATCH 1/6] revert: login specific prompt logic --- cmd/auth/login.go | 16 ++++------------ internal/pkg/auth/login.go | 28 +++++----------------------- 2 files changed, 9 insertions(+), 35 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 65dd779b..9159e256 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -18,8 +18,6 @@ import ( "context" "fmt" - "github.com/slackapi/slack-cli/internal/config" - "github.com/slackapi/slack-cli/internal/experiment" "github.com/slackapi/slack-cli/internal/iostreams" authpkg "github.com/slackapi/slack-cli/internal/pkg/auth" "github.com/slackapi/slack-cli/internal/shared" @@ -111,7 +109,7 @@ func RunLoginCommand(clients *shared.ClientFactory, cmd *cobra.Command) (types.S return types.SlackAuth{}, err } if selectedAuth.Token != "" { - printAuthSuccess(cmd, clients.Config, clients.IO, credentialsPath, selectedAuth.Token) + printAuthSuccess(cmd, clients.IO, credentialsPath, selectedAuth.Token) printAuthNextSteps(ctx, clients) } return selectedAuth, err @@ -121,14 +119,14 @@ func RunLoginCommand(clients *shared.ClientFactory, cmd *cobra.Command) (types.S if err != nil { return types.SlackAuth{}, err } else { - printAuthSuccess(cmd, clients.Config, clients.IO, credentialsPath, selectedAuth.Token) + printAuthSuccess(cmd, clients.IO, credentialsPath, selectedAuth.Token) printAuthNextSteps(ctx, clients) } return selectedAuth, nil } -func printAuthSuccess(cmd *cobra.Command, config *config.Config, IO iostreams.IOStreamer, credentialsPath string, token string) { +func printAuthSuccess(cmd *cobra.Command, IO iostreams.IOStreamer, credentialsPath string, token string) { ctx := cmd.Context() var secondaryLog string @@ -138,13 +136,7 @@ func printAuthSuccess(cmd *cobra.Command, config *config.Config, IO iostreams.IO secondaryLog = fmt.Sprintf("Service token:\n\n %s\n\nMake sure to copy the token now and save it safely.", token) } - // The legacy prompt leaves no blank line before the success message, so - // print one here. The Charm-based prompt already handles spacing. - if !config.WithExperimentOn(experiment.Charm) { - IO.PrintInfo(ctx, false, "") - } - - IO.PrintInfo(ctx, false, "%s", style.Sectionf(style.TextSection{ + IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ Emoji: "key", Text: "You've successfully authenticated!", Secondary: []string{secondaryLog}, diff --git a/internal/pkg/auth/login.go b/internal/pkg/auth/login.go index ef78ff3a..f5cfa5d6 100644 --- a/internal/pkg/auth/login.go +++ b/internal/pkg/auth/login.go @@ -20,12 +20,10 @@ import ( "strings" "time" - "github.com/charmbracelet/huh" "github.com/opentracing/opentracing-go" "github.com/slackapi/slack-cli/internal/api" "github.com/slackapi/slack-cli/internal/auth" "github.com/slackapi/slack-cli/internal/config" - "github.com/slackapi/slack-cli/internal/experiment" "github.com/slackapi/slack-cli/internal/iostreams" "github.com/slackapi/slack-cli/internal/pkg/version" "github.com/slackapi/slack-cli/internal/shared" @@ -142,27 +140,11 @@ func createNewAuth(ctx context.Context, apiClient api.APIInterface, authClient a return types.SlackAuth{}, "", err } - challengeCode := "" - if !config.WithExperimentOn(experiment.Charm) { - challengeCode, err = io.InputPrompt(ctx, "Enter challenge code", iostreams.InputPromptConfig{ - Required: true, - }) - if err != nil { - return types.SlackAuth{}, "", err - } - } else { - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Enter challenge code"). - Validate(huh.ValidateMinLength(1)). - Value(&challengeCode), - ), - ) - err := form.Run() - if err != nil { - return types.SlackAuth{}, "", err - } + challengeCode, err := io.InputPrompt(ctx, "Enter challenge code", iostreams.InputPromptConfig{ + Required: true, + }) + if err != nil { + return types.SlackAuth{}, "", err } authExchangeRes, err := apiClient.ExchangeAuthTicket(ctx, authTicket, challengeCode, version.Get()) From 952c73117d99c5a9e05552e8651e3e509c8a4439 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 23 Feb 2026 23:51:18 -0800 Subject: [PATCH 2/6] feat(experiment): add charm prompts to iostreams --- internal/iostreams/charm.go | 123 +++++++++++++++++++++++++++++++++++ internal/iostreams/survey.go | 20 ++++++ 2 files changed, 143 insertions(+) create mode 100644 internal/iostreams/charm.go diff --git a/internal/iostreams/charm.go b/internal/iostreams/charm.go new file mode 100644 index 00000000..d01b255b --- /dev/null +++ b/internal/iostreams/charm.go @@ -0,0 +1,123 @@ +// 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 + +// Charm-based prompt implementations using the huh library +// These are used when the "charm" experiment is enabled + +import ( + "context" + "slices" + + "github.com/charmbracelet/huh" +) + +// charmInputPrompt prompts for text input using a charm huh form +func charmInputPrompt(_ *IOStreams, _ context.Context, message string, cfg InputPromptConfig) (string, error) { + var input string + field := huh.NewInput(). + Title(message). + Value(&input) + if cfg.Required { + field.Validate(huh.ValidateMinLength(1)) + } + err := huh.NewForm(huh.NewGroup(field)).Run() + if err != nil { + return "", err + } + return input, nil +} + +// 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)).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 + var opts []huh.Option[string] + for _, opt := range options { + key := opt + if cfg.Description != nil { + if desc := cfg.Description(opt, len(opts)); desc != "" { + key = opt + "\n " + desc + } + } + opts = append(opts, huh.NewOption(key, opt)) + } + + field := huh.NewSelect[string](). + Title(msg). + Options(opts...). + Value(&selected) + + if cfg.PageSize > 0 { + field.Height(cfg.PageSize + 2) + } + + err := huh.NewForm(huh.NewGroup(field)).Run() + if err != nil { + return SelectPromptResponse{}, err + } + + index := slices.Index(options, selected) + 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 + field := huh.NewInput(). + Title(message). + EchoMode(huh.EchoModePassword). + Value(&input) + if cfg.Required { + field.Validate(huh.ValidateMinLength(1)) + } + err := huh.NewForm(huh.NewGroup(field)).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 + var opts []huh.Option[string] + for _, opt := range options { + opts = append(opts, huh.NewOption(opt, opt)) + } + + field := huh.NewMultiSelect[string](). + Title(message). + Options(opts...). + Value(&selected) + + err := huh.NewForm(huh.NewGroup(field)).Run() + if err != nil { + return []string{}, err + } + return selected, nil +} diff --git a/internal/iostreams/survey.go b/internal/iostreams/survey.go index 1f26f79e..8e2d95a1 100644 --- a/internal/iostreams/survey.go +++ b/internal/iostreams/survey.go @@ -27,6 +27,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" + "github.com/slackapi/slack-cli/internal/experiment" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/style" "github.com/spf13/pflag" @@ -120,6 +121,9 @@ func (cfg ConfirmPromptConfig) IsRequired() bool { // ConfirmPrompt prompts the user for a "yes" or "no" (true or false) value for // the message func (io *IOStreams) ConfirmPrompt(ctx context.Context, message string, defaultValue bool) (bool, error) { + if io.config.WithExperimentOn(experiment.Charm) { + return charmConfirmPrompt(io, ctx, message, defaultValue) + } // Temporarily swap default template for custom one defaultConfirmTemplate := survey.ConfirmQuestionTemplate @@ -191,6 +195,10 @@ func (cfg InputPromptConfig) IsRequired() bool { // InputPrompt prompts the user for a string value for the message, which can // optionally be made required func (io *IOStreams) InputPrompt(ctx context.Context, message string, cfg InputPromptConfig) (string, error) { + if io.config.WithExperimentOn(experiment.Charm) { + return charmInputPrompt(io, ctx, message, cfg) + } + defaultInputTemplate := survey.InputQuestionTemplate survey.InputQuestionTemplate = InputQuestionTemplate defer func() { @@ -263,6 +271,10 @@ func (cfg MultiSelectPromptConfig) IsRequired() bool { // MultiSelectPrompt prompts the user to select multiple values in a list and // returns the selected values func (io *IOStreams) MultiSelectPrompt(ctx context.Context, message string, options []string) ([]string, error) { + if io.config.WithExperimentOn(experiment.Charm) { + return charmMultiSelectPrompt(io, ctx, message, options) + } + defaultMultiSelectTemplate := survey.MultiSelectQuestionTemplate survey.MultiSelectQuestionTemplate = MultiSelectQuestionTemplate defer func() { @@ -340,6 +352,10 @@ func (io *IOStreams) PasswordPrompt(ctx context.Context, message string, cfg Pas return PasswordPromptResponse{}, errInteractivityFlags(cfg) } + if io.config.WithExperimentOn(experiment.Charm) { + return charmPasswordPrompt(io, ctx, message, cfg) + } + defaultPasswordTemplate := survey.PasswordQuestionTemplate if cfg.Template != "" { survey.PasswordQuestionTemplate = cfg.Template @@ -454,6 +470,10 @@ func (io *IOStreams) SelectPrompt(ctx context.Context, msg string, options []str } } + if io.config.WithExperimentOn(experiment.Charm) { + return charmSelectPrompt(io, ctx, msg, options, cfg) + } + defaultSelectTemplate := survey.SelectQuestionTemplate if cfg.Template != "" { survey.SelectQuestionTemplate = cfg.Template From 822b714d70815e1d74f4d8587fa90ae80cb6152c Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Tue, 24 Feb 2026 13:22:42 -0500 Subject: [PATCH 3/6] feat(charm): add Slack brand theme for huh prompts --- internal/iostreams/charm_theme.go | 120 ++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 internal/iostreams/charm_theme.go diff --git a/internal/iostreams/charm_theme.go b/internal/iostreams/charm_theme.go new file mode 100644 index 00000000..0f736c25 --- /dev/null +++ b/internal/iostreams/charm_theme.go @@ -0,0 +1,120 @@ +// 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 + +// Slack brand theme for charmbracelet/huh prompts. +// Uses official Slack brand colors to give the CLI a fun, playful feel. + +import ( + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +// Slack brand colors +var ( + slackAubergine = lipgloss.Color("#4a154b") + slackBrightAuberg = lipgloss.Color("#611f69") + slackBlue = lipgloss.Color("#36c5f0") + slackGreen = lipgloss.Color("#2eb67d") + slackYellow = lipgloss.Color("#ecb22e") + slackRed = lipgloss.Color("#e01e5a") + slackPool = lipgloss.Color("#78d7dd") + slackLegalGray = lipgloss.Color("#5e5d60") + slackOptionText = lipgloss.AdaptiveColor{Light: "#1d1c1d", Dark: "#f4ede4"} + slackDescriptionText = lipgloss.AdaptiveColor{Light: "#454447", Dark: "#b9b5b0"} + slackPlaceholderText = lipgloss.AdaptiveColor{Light: "#5e5d60", Dark: "#868380"} +) + +// ThemeSlack returns a huh theme styled with Slack brand colors. +func ThemeSlack() *huh.Theme { + t := huh.ThemeBase() + + // Focused field styles + t.Focused.Base = t.Focused.Base. + BorderForeground(slackBrightAuberg) + t.Focused.Title = lipgloss.NewStyle(). + Foreground(slackAubergine). + Bold(true) + t.Focused.Description = lipgloss.NewStyle(). + Foreground(slackDescriptionText) + t.Focused.ErrorIndicator = lipgloss.NewStyle(). + Foreground(slackRed). + SetString(" *") + t.Focused.ErrorMessage = lipgloss.NewStyle(). + Foreground(slackRed) + + // Select styles + t.Focused.SelectSelector = lipgloss.NewStyle(). + Foreground(slackBlue). + SetString("> ") + t.Focused.Option = lipgloss.NewStyle(). + Foreground(slackOptionText) + t.Focused.NextIndicator = lipgloss.NewStyle(). + Foreground(slackPool). + MarginLeft(1). + SetString("↓") + t.Focused.PrevIndicator = lipgloss.NewStyle(). + Foreground(slackPool). + MarginRight(1). + SetString("↑") + + // Multi-select styles + t.Focused.MultiSelectSelector = lipgloss.NewStyle(). + Foreground(slackYellow). + SetString("> ") + t.Focused.SelectedOption = lipgloss.NewStyle(). + Foreground(slackGreen) + t.Focused.SelectedPrefix = lipgloss.NewStyle(). + Foreground(slackGreen). + SetString("[✓] ") + t.Focused.UnselectedOption = lipgloss.NewStyle(). + Foreground(slackOptionText) + t.Focused.UnselectedPrefix = lipgloss.NewStyle(). + Foreground(slackLegalGray). + SetString("[ ] ") + + // Text input styles + t.Focused.TextInput.Cursor = lipgloss.NewStyle(). + Foreground(slackYellow) + t.Focused.TextInput.Prompt = lipgloss.NewStyle(). + Foreground(slackBlue) + t.Focused.TextInput.Placeholder = lipgloss.NewStyle(). + Foreground(slackPlaceholderText) + t.Focused.TextInput.Text = lipgloss.NewStyle(). + Foreground(slackOptionText) + + // Button styles + button := lipgloss.NewStyle(). + Padding(0, 2). + MarginRight(1) + t.Focused.FocusedButton = button. + Foreground(lipgloss.Color("#fff")). + Background(slackAubergine). + Bold(true) + t.Focused.BlurredButton = button. + Foreground(slackLegalGray). + Background(lipgloss.Color("#000")) + + // Blurred field styles — subdued version of focused + t.Blurred = t.Focused + t.Blurred.Base = t.Focused.Base. + BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ") + t.Blurred.MultiSelectSelector = lipgloss.NewStyle().SetString(" ") + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + + return t +} From 335baf2e9af13a362a2ab55bbce2bf4e09eb12e9 Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Thu, 26 Feb 2026 10:52:07 -0500 Subject: [PATCH 4/6] adds slack theme to cli prompts --- internal/iostreams/charm.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/iostreams/charm.go b/internal/iostreams/charm.go index d01b255b..4e9e1dfb 100644 --- a/internal/iostreams/charm.go +++ b/internal/iostreams/charm.go @@ -33,7 +33,7 @@ func charmInputPrompt(_ *IOStreams, _ context.Context, message string, cfg Input if cfg.Required { field.Validate(huh.ValidateMinLength(1)) } - err := huh.NewForm(huh.NewGroup(field)).Run() + err := huh.NewForm(huh.NewGroup(field)).WithTheme(ThemeSlack()).Run() if err != nil { return "", err } @@ -46,7 +46,7 @@ func charmConfirmPrompt(_ *IOStreams, _ context.Context, message string, default field := huh.NewConfirm(). Title(message). Value(&choice) - err := huh.NewForm(huh.NewGroup(field)).Run() + err := huh.NewForm(huh.NewGroup(field)).WithTheme(ThemeSlack()).Run() if err != nil { return false, err } @@ -76,7 +76,7 @@ func charmSelectPrompt(_ *IOStreams, _ context.Context, msg string, options []st field.Height(cfg.PageSize + 2) } - err := huh.NewForm(huh.NewGroup(field)).Run() + err := huh.NewForm(huh.NewGroup(field)).WithTheme(ThemeSlack()).Run() if err != nil { return SelectPromptResponse{}, err } @@ -95,7 +95,7 @@ func charmPasswordPrompt(_ *IOStreams, _ context.Context, message string, cfg Pa if cfg.Required { field.Validate(huh.ValidateMinLength(1)) } - err := huh.NewForm(huh.NewGroup(field)).Run() + err := huh.NewForm(huh.NewGroup(field)).WithTheme(ThemeSlack()).Run() if err != nil { return PasswordPromptResponse{}, err } @@ -115,7 +115,7 @@ func charmMultiSelectPrompt(_ *IOStreams, _ context.Context, message string, opt Options(opts...). Value(&selected) - err := huh.NewForm(huh.NewGroup(field)).Run() + err := huh.NewForm(huh.NewGroup(field)).WithTheme(ThemeSlack()).Run() if err != nil { return []string{}, err } From 94b0d7d148375e63646a11cf82473dba23ada277 Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Thu, 26 Feb 2026 11:17:58 -0500 Subject: [PATCH 5/6] tests(charm): adds tests to charm experiment --- internal/iostreams/charm.go | 73 ++++-- internal/iostreams/charm_test.go | 313 +++++++++++++++++++++++++ internal/iostreams/charm_theme_test.go | 112 +++++++++ 3 files changed, 474 insertions(+), 24 deletions(-) create mode 100644 internal/iostreams/charm_test.go create mode 100644 internal/iostreams/charm_theme_test.go diff --git a/internal/iostreams/charm.go b/internal/iostreams/charm.go index 4e9e1dfb..473bb0d6 100644 --- a/internal/iostreams/charm.go +++ b/internal/iostreams/charm.go @@ -24,38 +24,47 @@ import ( "github.com/charmbracelet/huh" ) -// 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(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(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 +79,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(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 +100,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(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 +132,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(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..e46d0849 --- /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_test.go b/internal/iostreams/charm_theme_test.go new file mode 100644 index 00000000..fc9dfe43 --- /dev/null +++ b/internal/iostreams/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 iostreams + +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("#4a154b"), 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("#4a154b"), 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 bright aubergine", func(t *testing.T) { + theme := ThemeSlack() + assert.Equal(t, lipgloss.Color("#611f69"), 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()) + }) +} From 97cd530b7e16052b5a8eb04add5316c556849d2c Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Fri, 27 Feb 2026 14:20:56 -0500 Subject: [PATCH 6/6] refactor: move charm_theme into internal/style --- internal/iostreams/charm.go | 11 ++++++----- internal/iostreams/charm_test.go | 8 ++++---- internal/{iostreams => style}/charm_theme.go | 2 +- .../{iostreams => style}/charm_theme_test.go | 16 ++++++++-------- 4 files changed, 19 insertions(+), 18 deletions(-) rename internal/{iostreams => style}/charm_theme.go (99%) rename internal/{iostreams => style}/charm_theme_test.go (89%) diff --git a/internal/iostreams/charm.go b/internal/iostreams/charm.go index 473bb0d6..d55b336e 100644 --- a/internal/iostreams/charm.go +++ b/internal/iostreams/charm.go @@ -22,6 +22,7 @@ import ( "slices" "github.com/charmbracelet/huh" + "github.com/slackapi/slack-cli/internal/style" ) // buildInputForm constructs a huh form for text input prompts. @@ -32,7 +33,7 @@ func buildInputForm(message string, cfg InputPromptConfig, input *string) *huh.F if cfg.Required { field.Validate(huh.ValidateMinLength(1)) } - return huh.NewForm(huh.NewGroup(field)).WithTheme(ThemeSlack()) + return huh.NewForm(huh.NewGroup(field)).WithTheme(style.ThemeSlack()) } // charmInputPrompt prompts for text input using a charm huh form @@ -50,7 +51,7 @@ func buildConfirmForm(message string, choice *bool) *huh.Form { field := huh.NewConfirm(). Title(message). Value(choice) - return huh.NewForm(huh.NewGroup(field)).WithTheme(ThemeSlack()) + return huh.NewForm(huh.NewGroup(field)).WithTheme(style.ThemeSlack()) } // charmConfirmPrompt prompts for a yes/no confirmation using a charm huh form @@ -85,7 +86,7 @@ func buildSelectForm(msg string, options []string, cfg SelectPromptConfig, selec field.Height(cfg.PageSize + 2) } - return huh.NewForm(huh.NewGroup(field)).WithTheme(ThemeSlack()) + return huh.NewForm(huh.NewGroup(field)).WithTheme(style.ThemeSlack()) } // charmSelectPrompt prompts the user to select one option using a charm huh form @@ -109,7 +110,7 @@ func buildPasswordForm(message string, cfg PasswordPromptConfig, input *string) if cfg.Required { field.Validate(huh.ValidateMinLength(1)) } - return huh.NewForm(huh.NewGroup(field)).WithTheme(ThemeSlack()) + return huh.NewForm(huh.NewGroup(field)).WithTheme(style.ThemeSlack()) } // charmPasswordPrompt prompts for a password (hidden input) using a charm huh form @@ -134,7 +135,7 @@ func buildMultiSelectForm(message string, options []string, selected *[]string) Options(opts...). Value(selected) - return huh.NewForm(huh.NewGroup(field)).WithTheme(ThemeSlack()) + return huh.NewForm(huh.NewGroup(field)).WithTheme(style.ThemeSlack()) } // charmMultiSelectPrompt prompts the user to select multiple options using a charm huh form diff --git a/internal/iostreams/charm_test.go b/internal/iostreams/charm_test.go index e46d0849..1075c8b1 100644 --- a/internal/iostreams/charm_test.go +++ b/internal/iostreams/charm_test.go @@ -121,7 +121,7 @@ func TestCharmSelect(t *testing.T) { f.Update(f.Init()) view := ansi.Strip(f.View()) - assert.Contains(t, view, "> Foo") + assert.Contains(t, view, "❱ Foo") }) t.Run("cursor navigation moves selection", func(t *testing.T) { @@ -132,8 +132,8 @@ func TestCharmSelect(t *testing.T) { 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")) + assert.Contains(t, view, "❱ Bar") + assert.False(t, strings.Contains(view, "❱ Foo")) }) t.Run("submit selects the hovered option", func(t *testing.T) { @@ -280,7 +280,7 @@ func TestCharmFormsUseSlackTheme(t *testing.T) { f.Update(f.Init()) view := ansi.Strip(f.View()) - assert.Contains(t, view, "> A") + assert.Contains(t, view, "❱ A") }) t.Run("multi-select form renders themed prefixes", func(t *testing.T) { 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/iostreams/charm_theme_test.go b/internal/style/charm_theme_test.go similarity index 89% rename from internal/iostreams/charm_theme_test.go rename to internal/style/charm_theme_test.go index fc9dfe43..9e676d45 100644 --- a/internal/iostreams/charm_theme_test.go +++ b/internal/style/charm_theme_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package iostreams +package style import ( "testing" @@ -34,13 +34,13 @@ func TestThemeSlack(t *testing.T) { t.Run("focused title uses aubergine foreground", func(t *testing.T) { theme := ThemeSlack() - assert.Equal(t, lipgloss.Color("#4a154b"), theme.Focused.Title.GetForeground()) + 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, ">") + assert.Contains(t, rendered, "❱") }) t.Run("focused multi-select selected prefix renders checkmark", func(t *testing.T) { @@ -62,7 +62,7 @@ func TestThemeSlack(t *testing.T) { t.Run("focused button uses aubergine background", func(t *testing.T) { theme := ThemeSlack() - assert.Equal(t, lipgloss.Color("#4a154b"), theme.Focused.FocusedButton.GetBackground()) + assert.Equal(t, lipgloss.Color("#7C2852"), theme.Focused.FocusedButton.GetBackground()) }) t.Run("focused button is bold", func(t *testing.T) { @@ -74,14 +74,14 @@ func TestThemeSlack(t *testing.T) { theme := ThemeSlack() rendered := theme.Blurred.SelectSelector.Render() assert.Contains(t, rendered, " ") - assert.NotContains(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, ">") + assert.NotContains(t, rendered, "❱") }) t.Run("blurred border is hidden", func(t *testing.T) { @@ -90,9 +90,9 @@ func TestThemeSlack(t *testing.T) { assert.Equal(t, lipgloss.HiddenBorder(), borderStyle) }) - t.Run("focused border uses bright aubergine", func(t *testing.T) { + t.Run("focused border uses aubergine", func(t *testing.T) { theme := ThemeSlack() - assert.Equal(t, lipgloss.Color("#611f69"), theme.Focused.Base.GetBorderLeftForeground()) + assert.Equal(t, lipgloss.Color("#7C2852"), theme.Focused.Base.GetBorderLeftForeground()) }) t.Run("focused text input prompt uses blue", func(t *testing.T) {