Skip to content

Commit d841c72

Browse files
committed
Switch interactive prompts to charmbracelet/huh
Replace the hand-rolled bubbletea models in ui.Select and ui.Prompt with thin wrappers around huh.NewSelect / huh.NewInput, and add ui.Confirm and ui.PromptWithPlaceholder. EmailableTheme styles huh with our brand purple and the existing "❯ " selector so every prompt across the CLI shares one look; BrandPurpleSoft is a lighter tint for the form gutter. External signatures are preserved — existing callers (welcome login menu, API key prompt) get the polished bordered look for free, with no code changes at their call sites. Drops the model-level select_test.go and prompt_test.go since field rendering is huh's responsibility now.
1 parent 993359c commit d841c72

9 files changed

Lines changed: 185 additions & 317 deletions

File tree

‎go.mod‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.26.3
55
require (
66
github.com/charmbracelet/bubbles v1.0.0
77
github.com/charmbracelet/bubbletea v1.3.10
8+
github.com/charmbracelet/huh v1.0.0
89
github.com/charmbracelet/lipgloss v1.1.0
910
github.com/spf13/cobra v1.10.2
1011
golang.org/x/term v0.43.0
@@ -13,21 +14,25 @@ require (
1314
require (
1415
github.com/atotto/clipboard v0.1.4 // indirect
1516
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
17+
github.com/catppuccin/go v0.3.0 // indirect
1618
github.com/charmbracelet/colorprofile v0.4.1 // indirect
1719
github.com/charmbracelet/harmonica v0.2.0 // indirect
1820
github.com/charmbracelet/x/ansi v0.11.6 // indirect
1921
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
22+
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
2023
github.com/charmbracelet/x/term v0.2.2 // indirect
2124
github.com/clipperhouse/displaywidth v0.9.0 // indirect
2225
github.com/clipperhouse/stringish v0.1.1 // indirect
2326
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
2427
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
28+
github.com/dustin/go-humanize v1.0.1 // indirect
2529
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
2630
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2731
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
2832
github.com/mattn/go-isatty v0.0.20 // indirect
2933
github.com/mattn/go-localereader v0.0.1 // indirect
3034
github.com/mattn/go-runewidth v0.0.19 // indirect
35+
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
3136
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
3237
github.com/muesli/cancelreader v0.2.2 // indirect
3338
github.com/muesli/termenv v0.16.0 // indirect
@@ -37,5 +42,5 @@ require (
3742
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
3843
go.yaml.in/yaml/v3 v3.0.4 // indirect
3944
golang.org/x/sys v0.44.0 // indirect
40-
golang.org/x/text v0.3.8 // indirect
45+
golang.org/x/text v0.23.0 // indirect
4146
)

‎go.sum‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
2+
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
13
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
24
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
35
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
46
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
7+
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
8+
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
9+
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
10+
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
511
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
612
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
713
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -10,14 +16,28 @@ github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco
1016
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
1117
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
1218
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
19+
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
20+
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
1321
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
1422
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
1523
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
1624
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
1725
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
1826
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
27+
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
28+
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
29+
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
30+
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
31+
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
32+
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
33+
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
34+
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
1935
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
2036
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
37+
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
38+
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
39+
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
40+
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
2141
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
2242
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
2343
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
@@ -26,6 +46,10 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w
2646
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
2747
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
2848
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
49+
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
50+
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
51+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
52+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
2953
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
3054
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
3155
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -38,6 +62,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
3862
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
3963
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
4064
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
65+
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
66+
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
4167
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
4268
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
4369
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -65,7 +91,7 @@ golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
6591
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
6692
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
6793
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
68-
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
69-
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
94+
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
95+
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
7096
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
7197
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

‎internal/ui/brand.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ const blankBraille = '⠀'
6565
// BrandPurple is Emailable's primary brand purple, used for the wordmark text.
6666
var BrandPurple = lipgloss.Color("#7e61ff")
6767

68+
// BrandPurpleSoft is a lighter tint for supporting chrome (form gutters).
69+
var BrandPurpleSoft = lipgloss.Color("#c7c2ff")
70+
6871
// brandPalette maps the color letters in brandColors to their brand hex.
6972
// '.' has no entry — it's never looked up because blank cells aren't styled.
7073
var brandPalette = map[byte]lipgloss.Color{

‎internal/ui/confirm.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package ui
2+
3+
import (
4+
"errors"
5+
"io"
6+
7+
"github.com/charmbracelet/huh"
8+
)
9+
10+
// Confirm returns true only on Yes; No / esc / ctrl-c collapse to false.
11+
func Confirm(in io.Reader, out io.Writer, message string) (yes bool, err error) {
12+
var v bool
13+
field := huh.NewConfirm().
14+
Title(message).
15+
Affirmative("Yes").
16+
Negative("No").
17+
Value(&v)
18+
19+
form := huh.NewForm(huh.NewGroup(field)).
20+
WithTheme(EmailableTheme()).
21+
WithKeyMap(EscKeyMap()).
22+
WithInput(in).
23+
WithOutput(out)
24+
25+
if err := form.Run(); err != nil {
26+
if errors.Is(err, huh.ErrUserAborted) {
27+
return false, nil
28+
}
29+
return false, err
30+
}
31+
return v, nil
32+
}

‎internal/ui/prompt.go‎

Lines changed: 24 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -5,75 +5,37 @@ import (
55
"io"
66
"strings"
77

8-
"github.com/charmbracelet/bubbles/textinput"
9-
tea "github.com/charmbracelet/bubbletea"
8+
"github.com/charmbracelet/huh"
109
)
1110

12-
// Prompt renders a single-line text input on out, reading keystrokes from in,
13-
// and returns what the user typed. When mask is true the entry is echoed as
14-
// bullets rather than shown verbatim — use it for secrets like API keys, where
15-
// the user still wants visible feedback that their paste landed. ok is false
16-
// when the user canceled (esc / ctrl-c) without submitting.
17-
//
18-
// Like Select, the prompt clears itself on exit so the caller can print its own
19-
// confirmation, and must only be invoked when stdin and out are both terminals
20-
// (do not gate on ui.IsTTY, which also reports false under NO_COLOR — a NO_COLOR
21-
// terminal is still interactive).
11+
// Prompt returns value, ok, err. ok is false on esc / ctrl-c. mask
12+
// echoes bullets for secrets.
2213
func Prompt(in io.Reader, out io.Writer, label string, mask bool) (value string, ok bool, err error) {
23-
ti := textinput.New()
24-
ti.Focus()
25-
ti.Prompt = ""
26-
if mask {
27-
ti.EchoMode = textinput.EchoPassword
28-
ti.EchoCharacter = '•'
29-
}
30-
31-
m := promptModel{label: label, input: ti}
32-
p := tea.NewProgram(m, tea.WithInput(in), tea.WithOutput(out))
33-
res, err := p.Run()
34-
if err != nil {
35-
return "", false, err
36-
}
37-
final, ok := res.(promptModel)
38-
if !ok {
39-
return "", false, errors.New("ui.Prompt: unexpected model type")
40-
}
41-
if final.canceled {
42-
return "", false, nil
43-
}
44-
return strings.TrimSpace(final.input.Value()), true, nil
14+
return PromptWithPlaceholder(in, out, label, "", mask)
4515
}
4616

47-
// promptModel is the bubbletea model backing Prompt.
48-
type promptModel struct {
49-
label string
50-
input textinput.Model
51-
canceled bool
52-
quitting bool
53-
}
17+
// PromptWithPlaceholder is Prompt with a placeholder hint.
18+
func PromptWithPlaceholder(in io.Reader, out io.Writer, label, placeholder string, mask bool) (value string, ok bool, err error) {
19+
var v string
20+
field := huh.NewInput().
21+
Title(label).
22+
Placeholder(placeholder).
23+
Value(&v)
24+
if mask {
25+
field = field.EchoMode(huh.EchoModePassword)
26+
}
5427

55-
func (m promptModel) Init() tea.Cmd { return textinput.Blink }
28+
form := huh.NewForm(huh.NewGroup(field)).
29+
WithTheme(EmailableTheme()).
30+
WithKeyMap(EscKeyMap()).
31+
WithInput(in).
32+
WithOutput(out)
5633

57-
func (m promptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
58-
if key, ok := msg.(tea.KeyMsg); ok {
59-
switch key.Type {
60-
case tea.KeyEnter:
61-
m.quitting = true
62-
return m, tea.Quit
63-
case tea.KeyCtrlC, tea.KeyEsc:
64-
m.canceled = true
65-
m.quitting = true
66-
return m, tea.Quit
34+
if err := form.Run(); err != nil {
35+
if errors.Is(err, huh.ErrUserAborted) {
36+
return "", false, nil
6737
}
38+
return "", false, err
6839
}
69-
var cmd tea.Cmd
70-
m.input, cmd = m.input.Update(msg)
71-
return m, cmd
72-
}
73-
74-
func (m promptModel) View() string {
75-
if m.quitting {
76-
return "" // wipe the prompt region; the caller prints its own confirmation
77-
}
78-
return selectPromptStyle.Render(m.label) + " " + m.input.View()
40+
return strings.TrimSpace(v), true, nil
7941
}

‎internal/ui/prompt_test.go‎

Lines changed: 0 additions & 81 deletions
This file was deleted.

0 commit comments

Comments
 (0)