refactor: migrate interactive prompts from survey to huh - #956
Conversation
Adds testable.Ask for multi-question forms and replaces ten direct survey.AskOne / survey.Ask callers that bypassed the shim.
Adds the Prompter interface and a testable.Confirm function as the entry point for migrated callers. The huh implementation writes to os.Stderr (granted's stdout is shell-evaluated) and binds ESC alongside Ctrl+C to the form's Quit action, since huh v2 binds Ctrl+C only.
Uses generic huh.Select[string] and inherits the ESC-cancel keybinding from the form keymap.
Replaces twelve survey.Select sites that used only Message and Options. Each migration drops the survey.WithStdio boilerplate since the Prompter handles stderr internally. One adjacent Confirm in browser/detect.go is migrated as well because it shared the now-removed withStdio variable.
Replaces twelve survey.Input sites. The settings/set.go Int case keeps
the existing string-through-interface{} path (no behaviour change).
Migrates the secret-access-key Password prompt and six Confirm prompts (alias install, default-browser-firefox, uninstall config, credentials removal, settings/set Bool case, registry setup). Drops the var prompt survey.Prompt declaration in settings/set.go since none of the cases reference it anymore.
Adds InputWithValidator and SelectWithValidator to the Prompter interface, plus a Required helper for the common non-empty case.
- credentials.go AddCredentialsCommand: survey.MinLength(1) becomes testable.Required (equivalent semantics). - credentials.go ImportCredentialsCommand: custom validator now receives the selected string directly instead of a core.OptionAnswer. - config_yaml.go required-keys prompt: the survey.Ask multi-question form only ever held a single Question (var questions was declared inside the loop). Collapses to InputWithValidator + SaveKey, dropping the ansmap intermediary. The now-unused SaveKeys function is removed and SaveKey's stale doc comment is updated.
huh.Select's built-in filter is literal substring only with no public matcher hook; bubbles/list exposes a Filter field that takes a func, so the implementation drops one layer down to build the picker directly. The non-modal key handler mirrors survey's UX: typing appends to the filter, arrows navigate, Enter selects, Esc clears the filter or quits. The default bubbles/list keymap is replaced with bindings that match what's actually wired up.
QueryProfiles drops the survey.Select call and the associated SelectQuestionTemplate global-mutation hack used to render the Profile/Description column header. The header is now pre-printed via fmt.Fprintln before launching the picker, which works because bubbles/list runs inline. filterMultiToken is restored with a simpler (term, opt) signature.
Help-text variants backed by huh's Description() on the underlying field. Each shares a single private helper with its plain counterpart.
- browser/detect.go SSOBrowser: Confirm with Help -> ConfirmWithHelp - settings/requesturl/set.go: Input with Help -> InputWithHelp
Removes testable.AskOne and testable.Ask along with the survey and survey/core imports from pkg/testable. go mod tidy drops AlecAivazis/survey/v2 from go.mod entirely. The library was archived by its author in 2024.
Adds grantedTheme (based on huh.ThemeCharm) and styling helpers for the bubbles/list-based filter picker. Replaces the fuchsia/indigo accents with ANSI cyan and green, removes the outer left-border around focused fields, and switches every colour to ANSI 16 indices so the prompts pick up the user's terminal palette instead of Charm's hex codes. The filter picker's focused row indicator becomes a > cursor in cyan with green option text, matching huh.Select's SelectSelector + SelectedOption convention. Status bar stays visible to show the active filter text.
Pull the pure filter-ranking logic and the bubbles/list model construction out of SelectWithFilter so both can be exercised without opening a terminal. No behavior change: SelectWithFilter builds the same model and ranks options identically.
Add unit tests for the logic the huh migration introduced: - Required, testInputAsBool (bool/parseable-string/parse-error/wrong-type), and testInputAsString (string/nil/non-string formatting). - rankFilter's empty-term, matches, and no-match cases. - selectFilterModel.Update: enter selects, ctrl+c cancels, printable chars build the filter, backspace shortens then clears it, and esc clears an active filter before cancelling -- the ESC behavior this migration set out to add over survey.
meyerjrr
left a comment
There was a problem hiding this comment.
Thanks for the contribution! Code looks good, only some small nit picks on some unneeded code and a rename.
I'm a fan of charmbracelet and their work as well, really awesome to see it used in here.
There were a couple of usability quirks that I'm curious if can be confgured in huh.
- When using a menu like
granted settings setthe filter option after initiating with/doesn't display the text being filtered on. Not a huge issue but would be slightly nicer to see what I'm filtering against. - Another one (honestly not blocking) but when running some commands we display warnings or info messages before the primary CTA, one example is the registry warning commands. Because huh pushes the CTA to the top of the terminal - these info messages can get cut off, see example image (that is the very top of my terminal):
There was a problem hiding this comment.
For the most part the prompter interface is just a indirection to the huh library - we could probably just use each of these methods directly and drop this interface
There was a problem hiding this comment.
Agreed, dropped it. It went in as scaffolding on the assumption that the survey and huh paths would need to coexist while callers moved over; that never happened, huhPrompter was the only implementation, and the migration ran through the package-level functions instead. It wasn't the test seam either — that's the isTesting branch inherited from the old testable.AskOne.
| } | ||
|
|
||
| // Required is a validator that rejects empty input. | ||
| var Required = func(s string) error { |
There was a problem hiding this comment.
| var Required = func(s string) error { | |
| var NonEmpty = func(s string) error { |
There was a problem hiding this comment.
Noted and incorporated.
huhPrompter was the only implementation the migration ever had, and the package-level prompt functions were what callers actually moved onto, so the interface was pure indirection. Test mode runs off the isTesting branch inside each method rather than an injected implementation, so it was not serving as a seam either. Keeps the stderr rationale by moving it onto newHuhPrompter, which is where the interface doc comment had been carrying it.
Taking the review suggestion. Required is already spoken for in this codebase — it's the urfave/cli flag field, and it shows up that way in add.go, completion.go and credentials.go — so a validator by the same name meant the word read two different ways depending on the line you were on. NonEmpty just says what it checks.
huh.Select on its own is wrong for these menus in two ways, both found in review. ESC cannot be made to behave. huh.Select binds it to its filter and we bind it to the form's Quit, and Form.Update matches Quit before the field sees the key, so ESC always cancelled -- even mid filter, where the help line advertised it as a filter control. No keymap fixes that; the precedence is structural. Filtering also hides matches. huh carries the pre-filter cursor into the filtered list and leaves the viewport scrolled past the earlier matches, so a search matching four options can show one. That is charmbracelet/huh#669, open since July, fixed upstream in charmbracelet/huh#804 but not released. The broken state is unexported, so it cannot be repaired from here either. So drive the field directly instead of through a Form, and rebuild it from scratch whenever the filter changes. A fresh field starts at the first match with an unscrolled viewport, which sidesteps the bug rather than repairing it, and owning the key loop puts ESC back within reach: it drops an active filter first and cancels only when there is none. Everything else follows from typing being the filter, as it was under survey and still is in the profile picker: - No letter is bound to navigation. huh binds j/k to move and g/G to jump by default; those are unbound so they reach the filter, which is the same trade survey made explicitly. - The multi-token filter that was private to assume becomes the default for every select, so "prod eu" narrows anywhere. SelectWithFilter goes away; nothing needs a custom filter now. - Selections can be validated. A rejected choice reports why and leaves the prompt open rather than losing the command to one bad pick. - Height only ever shrinks: long lists cap at ten options so warnings printed before the prompt stay on screen, and short lists render as they did.
|
Thanks for reviewing! Your catches deserved thorough answers, and they got me digging into a few things — including the muscle memory a vim-style Summary below, since the select code changed shape as a result. The two quirks you spotted turned out to be one bug, and it's upstream in huh. ESC had a related problem I hit while testing that. Two behavioral changes from what you already reviewed:
Separately, I noticed Happy to split any of this out if you'd rather review the migration on its own. |
Conflicts were the doc URL move to docs.granted.dev landing on the same lines as the survey prompts this branch replaces, plus the dependency bumps overlapping the x/term promotion. Kept upstream's URLs and versions with this branch's prompts.
Every exported prompt claimed it consumed a value from the stream set by WithNextSurveyInputFunc. That is true, but nothing calls it, so the docs led with machinery no caller can see. SelectWithValidator also still described re-prompting; the picker stays open and reports why instead.

Implements the migration proposed in #945.
AlecAivazis/surveyis archived (last release June 2023, 61 open issues) and its API blocks features like ESC-to-cancel. This branch replaces it withhuh(actively maintained, generically-typedSelect, first-class theming, built-in ESC-cancel) behind the existingpkg/testableshim, and drops thesurveydependency.Why one branch instead of the per-package PRs from the RFC: I didn't hear back on #945, so I've consolidated the work here. The commits still follow the RFC's incremental plan — each is behavior-equivalent, self-contained, and independently revertible, so this can be reviewed commit-by-commit.
Commit progression:
huhv2; introduce aPrompterinterface with a huh-backedConfirm; migratecfaws/env.go.Select/Input/Passwordmethods and migrate their callers, package by package.Input/Selectand migrate validator-using callers.SelectWithFilter(bubbles/list) and migrate theassumeprofile picker — removing theSelectQuestionTemplatemonkey-patch andprofileNameMapworkaround called out in the RFC.ConfirmWithHelp/InputWithHelpand migrate Help-using callers.surveydependency.Tests: the last two commits add coverage for the pure logic this migration introduced in
pkg/testable, without requiring a terminal:Required,testInputAsBool, andtestInputAsString(the test-mode input helpers).rankFilter(the generic filter ranking).selectFilterModel.Update— the filter-picker key handling, including the ESC-clears-filter-then-cancels behavior that motivated moving offsurvey. A small no-op refactor (refactor(testable): extract rankFilter and newSelectFilterModel) exposes these seams for testing without changing behavior.Verification:
go build ./...clean;go vet ./pkg/testable/clean;go test ./...passes with no failures.