Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion internal/iostreams/forms.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ func newForm(io *IOStreams, field huh.Field) *huh.Form {
form := huh.NewForm(huh.NewGroup(field))
if io != nil && io.config.WithExperimentOn(experiment.Lipgloss) {
form = form.WithTheme(style.ThemeSlack())
} else {
form = form.WithTheme(style.ThemeSurvey())
}
return form
}
Expand Down Expand Up @@ -90,7 +92,7 @@ func buildSelectForm(io *IOStreams, msg string, options []string, cfg SelectProm
key := opt
if cfg.Description != nil {
if desc := style.RemoveEmoji(cfg.Description(opt, len(opts))); desc != "" {
key = opt + " - " + desc
key = style.Bright(opt) + " " + style.Secondary(desc)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔭 thought: If we're changing the "—" separator to a character outside of the ASCII character sets we can perhaps use a new function to fallback to the common dash without styles?

style.Separator

👾 quibble: This might be a change to discuss in a separate PR. IMHO #400 isn't an experimental change but it was related to prompts at the time of these updates and gets confusing...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@srtaalej Sweet! Could you link the PR here for more discussion?

}
}
opts = append(opts, huh.NewOption(key, opt))
Expand Down
100 changes: 94 additions & 6 deletions internal/iostreams/forms_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,57 @@ func TestSelectForm(t *testing.T) {
assert.Contains(t, view, "First letter")
})

t.Run("descriptions use em-dash separator with lipgloss enabled", func(t *testing.T) {
style.ToggleLipgloss(true)
style.ToggleStyles(true)
t.Cleanup(func() {
style.ToggleLipgloss(false)
style.ToggleStyles(false)
})

fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
cfg := config.NewConfig(fsMock, osMock)
cfg.ExperimentsFlag = []string{"lipgloss"}
cfg.LoadExperiments(context.Background(), func(_ context.Context, _ string, _ ...any) {})
io := NewIOStreams(cfg, fsMock, osMock)

var selected string
options := []string{"Alpha", "Beta"}
selectCfg := SelectPromptConfig{
Description: func(opt string, _ int) string {
if opt == "Alpha" {
return "First letter"
}
return ""
},
}
f := buildSelectForm(io, "Choose", options, selectCfg, &selected)
f.Update(f.Init())

view := ansi.Strip(f.View())
assert.Contains(t, view, " — First letter")
})

t.Run("descriptions use em-dash separator without lipgloss", func(t *testing.T) {
var selected string
options := []string{"Alpha", "Beta"}
selectCfg := SelectPromptConfig{
Description: func(opt string, _ int) string {
if opt == "Alpha" {
return "First letter"
}
return ""
},
}
f := buildSelectForm(nil, "Choose", options, selectCfg, &selected)
f.Update(f.Init())

view := ansi.Strip(f.View())
assert.Contains(t, view, "Alpha — 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"}
Expand Down Expand Up @@ -283,8 +334,8 @@ func TestMultiSelectForm(t *testing.T) {
m, _ := f.Update(key('x'))
view := ansi.Strip(m.View())

// After toggle, the first item should show as selected (checkmark)
assert.Contains(t, view, "")
// After toggle, the first item should show as selected
assert.Contains(t, view, "[x]")
})

t.Run("submit returns toggled items", func(t *testing.T) {
Expand Down Expand Up @@ -364,14 +415,51 @@ func TestFormsUseSlackTheme(t *testing.T) {
})
}

func TestFormsWithoutLipgloss(t *testing.T) {
t.Run("multi-select uses default prefix without lipgloss", func(t *testing.T) {
func TestFormsUseSurveyTheme(t *testing.T) {
t.Run("multi-select uses survey prefix without lipgloss", func(t *testing.T) {
var selected []string
f := buildMultiSelectForm(nil, "Pick", []string{"A", "B"}, &selected)
f.Update(f.Init())

view := ansi.Strip(f.View())
// Without lipgloss the Slack theme is not applied, so "[ ]" should not appear
assert.NotContains(t, view, "[ ]")
// ThemeSurvey uses "[ ] " as unselected prefix
assert.Contains(t, view, "[ ]")
})

t.Run("multi-select uses [x] for selected prefix", func(t *testing.T) {
var selected []string
f := buildMultiSelectForm(nil, "Pick", []string{"A", "B"}, &selected)
f.Update(f.Init())

// Toggle first item
m, _ := f.Update(key('x'))
view := ansi.Strip(m.View())
assert.Contains(t, view, "[x]")
})

t.Run("select form renders chevron cursor", func(t *testing.T) {
var selected string
f := buildSelectForm(nil, "Pick", []string{"A", "B"}, SelectPromptConfig{}, &selected)
f.Update(f.Init())

view := ansi.Strip(f.View())
assert.Contains(t, view, style.Chevron()+" A")
})

t.Run("all form builders apply ThemeSurvey without lipgloss", func(t *testing.T) {
var s string
var b bool
var ss []string
forms := []*huh.Form{
buildInputForm(nil, "msg", InputPromptConfig{}, &s),
buildConfirmForm(nil, "msg", &b),
buildSelectForm(nil, "msg", []string{"a"}, SelectPromptConfig{}, &s),
buildPasswordForm(nil, "msg", PasswordPromptConfig{}, &s),
buildMultiSelectForm(nil, "msg", []string{"a"}, &ss),
}
for _, f := range forms {
f.Update(f.Init())
assert.NotEmpty(t, f.View())
}
})
}
48 changes: 48 additions & 0 deletions internal/style/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,54 @@ func Chevron() string {
return "❱"
}

// ThemeSurvey returns a huh Theme that matches the legacy survey prompt styling.
// Applied when experiment.Huh is on but experiment.Lipgloss is off.
func ThemeSurvey() huh.Theme {
return huh.ThemeFunc(themeSurvey)
}

// themeSurvey builds huh styles matching the survey library's appearance.
func themeSurvey(isDark bool) *huh.Styles {
t := huh.ThemeBase(isDark)

ansiBlue := lipgloss.ANSIColor(blue)
ansiGray := lipgloss.ANSIColor(gray)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👽 praise: This does solid in foreground text where used!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪐 Before

Image

💫 After

Image

ansiGreen := lipgloss.ANSIColor(green)
ansiRed := lipgloss.ANSIColor(red)

t.Focused.Title = lipgloss.NewStyle().
Foreground(ansiGray).
Bold(true)
Comment on lines +144 to +146
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌲 praise: Nice use of bold formatting once more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌈 Before

Image

🍯 After

Image

t.Focused.ErrorIndicator = lipgloss.NewStyle().
Foreground(ansiRed).
SetString(" *")
t.Focused.ErrorMessage = lipgloss.NewStyle().
Foreground(ansiRed)

// Select styles
t.Focused.SelectSelector = lipgloss.NewStyle().
Foreground(ansiBlue).
Bold(true).
SetString(Chevron() + " ")
t.Focused.SelectedOption = lipgloss.NewStyle().
Foreground(ansiBlue).
Bold(true)
Comment on lines +154 to +160
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌟 praise: These selections have a impressive similarities! The change to help text placement builds confidence in this next update I think!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📸 Before

Image

📸 After

Image

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👾 issue(non-blocking): I notice the guided tutorials reference isn't included in the updated help description.

🪬 ramble: This might be a quick change to the create selection but we might want to check for similar custom templates and other deprecated fields being used in other prompts. I'd lean toward a separate PR since this doesn't seem related to the theme itself, if this is something you'd want to check out related?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah thanks for catching that! ill include it in a follow up PR

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we prefer it at the top
Screenshot 2026-03-23 at 12 26 44 PM

or bottom of the prompt
Screenshot 2026-03-23 at 12 33 37 PM

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@srtaalej Hmmm... I might prefer the top since it doesn't remove instructions below 🍀 ✨


// Multi-select styles
t.Focused.MultiSelectSelector = lipgloss.NewStyle().
Foreground(ansiBlue).
Bold(true).
SetString(Chevron() + " ")
t.Focused.SelectedPrefix = lipgloss.NewStyle().
Foreground(ansiGreen).
SetString("[x] ")
t.Focused.UnselectedPrefix = lipgloss.NewStyle().
Bold(true).
SetString("[ ] ")

return t
}

// SurveyIcons returns customizations to the appearance of survey prompts.
func SurveyIcons() survey.AskOpt {
if !isStyleEnabled {
Expand Down
40 changes: 40 additions & 0 deletions internal/style/theme_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,46 @@ func TestThemeSlack(t *testing.T) {
}
}

func TestThemeSurvey(t *testing.T) {
theme := ThemeSurvey().Theme(false)
tests := map[string]struct {
rendered string
expected []string
unexpected []string
}{
"focused title renders text": {
rendered: theme.Focused.Title.Render("x"),
expected: []string{"x"},
},
"focused error message renders text": {
rendered: theme.Focused.ErrorMessage.Render("err"),
expected: []string{"err"},
},
"focused select selector renders chevron": {
rendered: theme.Focused.SelectSelector.Render(),
expected: []string{Chevron()},
},
"focused multi-select selected prefix has [x]": {
rendered: theme.Focused.SelectedPrefix.Render(),
expected: []string{"[x]"},
},
"focused multi-select unselected prefix has brackets": {
rendered: theme.Focused.UnselectedPrefix.Render(),
expected: []string{"[ ]"},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
for _, exp := range tc.expected {
assert.Contains(t, tc.rendered, exp)
}
for _, unexp := range tc.unexpected {
assert.NotContains(t, tc.rendered, unexp)
}
})
}
}

func TestChevron(t *testing.T) {
tests := map[string]struct {
styleEnabled bool
Expand Down
Loading