From ead34e1ed23f1046d4f6d456893bbeeef0c5c3d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:03:22 +0000 Subject: [PATCH 01/21] chore(deps): bump azure/login from 2.3.0 to 3.0.0 Bumps [azure/login](https://github.com/azure/login) from 2.3.0 to 3.0.0. - [Release notes](https://github.com/azure/login/releases) - [Commits](https://github.com/azure/login/compare/a457da9ea143d694b1b9c7c869ebb04ebe844ef5...532459ea530d8321f2fb9bb10d1e0bcf23869a43) --- updated-dependencies: - dependency-name: azure/login dependency-version: 3.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/deployment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 30a158dc9a6..676855fb577 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -208,7 +208,7 @@ jobs: run: git tag "$TAG_NAME" - name: Authenticate to Azure for code signing if: inputs.environment == 'production' - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 with: client-id: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }} tenant-id: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }} From c255e77ddea684c1b5737c62d8143fac636a30c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:14:32 +0000 Subject: [PATCH 02/21] chore(deps): bump mislav/bump-homebrew-formula-action from 3.6 to 4.1 Bumps [mislav/bump-homebrew-formula-action](https://github.com/mislav/bump-homebrew-formula-action) from 3.6 to 4.1. - [Release notes](https://github.com/mislav/bump-homebrew-formula-action/releases) - [Commits](https://github.com/mislav/bump-homebrew-formula-action/compare/56a283fa15557e9abaa4bdb63b8212abc68e655c...ccf2332299a883f6af50a1d2d41e5df7904dd769) --- updated-dependencies: - dependency-name: mislav/bump-homebrew-formula-action dependency-version: '4.1' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/deployment.yml | 2 +- .github/workflows/homebrew-bump.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 30a158dc9a6..17dd1c24646 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -414,7 +414,7 @@ jobs: git diff --name-status @{upstream}.. fi - name: Bump homebrew-core formula - uses: mislav/bump-homebrew-formula-action@56a283fa15557e9abaa4bdb63b8212abc68e655c + uses: mislav/bump-homebrew-formula-action@ccf2332299a883f6af50a1d2d41e5df7904dd769 if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') with: formula-name: gh diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml index ff7f0039336..eccf933dd77 100644 --- a/.github/workflows/homebrew-bump.yml +++ b/.github/workflows/homebrew-bump.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Bump homebrew-core formula - uses: mislav/bump-homebrew-formula-action@56a283fa15557e9abaa4bdb63b8212abc68e655c + uses: mislav/bump-homebrew-formula-action@ccf2332299a883f6af50a1d2d41e5df7904dd769 if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') with: formula-name: gh From dd1a3680d31791e4417e8a85c0781a4be970281f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:14:35 +0000 Subject: [PATCH 03/21] chore(deps): bump microsoft/setup-msbuild from 2.0.0 to 3.0.0 Bumps [microsoft/setup-msbuild](https://github.com/microsoft/setup-msbuild) from 2.0.0 to 3.0.0. - [Release notes](https://github.com/microsoft/setup-msbuild/releases) - [Commits](https://github.com/microsoft/setup-msbuild/compare/6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce...30375c66a4eea26614e0d39710365f22f8b0af57) --- updated-dependencies: - dependency-name: microsoft/setup-msbuild dependency-version: 3.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/deployment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 30a158dc9a6..dfd9ef05893 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -226,7 +226,7 @@ jobs: run: script/release --local "$TAG_NAME" --platform windows - name: Set up MSBuild id: setupmsbuild - uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce + uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 - name: Build MSI shell: bash env: From 6666850871091a8caa0d24b7c84247568a3a4277 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:05:20 -0600 Subject: [PATCH 04/21] fix(acceptance): set git identity in testscript sandbox The sandbox overrides HOME so git cannot find the user's global config, causing 'Author identity unknown' errors when acceptance test scripts make commits. Write a minimal .gitconfig with user.name and user.email into the sandbox working directory during sharedSetup. Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> --- acceptance/acceptance_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index b8eff83890c..98642afafeb 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path" + "path/filepath" "strconv" "strings" "testing" @@ -15,6 +16,7 @@ import ( "github.com/cli/cli/v2/internal/ghcmd" "github.com/cli/go-internal/testscript" + "github.com/MakeNowJust/heredoc" ) func ghMain() int { @@ -224,6 +226,19 @@ func sharedSetup(tsEnv testScriptEnv) func(ts *testscript.Env) error { ts.Setenv("RANDOM_STRING", randomString(10)) + // The sandbox overrides HOME, so git cannot find the user's global + // config. Write a minimal identity so commits inside the sandbox + // don't fail with "Author identity unknown". + gitCfg := filepath.Join(ts.Cd, ".gitconfig") + gitCfgContent := heredoc.Doc(` + [user] + name = GitHub CLI Acceptance Test Runner + email = cli-acceptance-test-runner@github.com + `) + if err := os.WriteFile(gitCfg, []byte(gitCfgContent), 0o644); err != nil { + return fmt.Errorf("writing sandbox .gitconfig: %w", err) + } + ts.Values[keyT] = ts.T() return nil } From be4960a255a84ab736a9af1cf7052e012f5a6a7a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:31:10 -0600 Subject: [PATCH 05/21] test(acceptance): remove run-download-traversal test GitHub's Artifact API now rejects artifact names like '..' server-side with a 400 Bad Request, making it impossible to create artifacts with path traversal names. This means the scenario this test was verifying (that gh run download catches traversal names) can no longer be reproduced through normal artifact creation. The client-side traversal check in gh run download remains in place as a defense-in-depth measure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflow/run-download-traversal.txtar | 71 ------------------- 1 file changed, 71 deletions(-) delete mode 100644 acceptance/testdata/workflow/run-download-traversal.txtar diff --git a/acceptance/testdata/workflow/run-download-traversal.txtar b/acceptance/testdata/workflow/run-download-traversal.txtar deleted file mode 100644 index a8a64475216..00000000000 --- a/acceptance/testdata/workflow/run-download-traversal.txtar +++ /dev/null @@ -1,71 +0,0 @@ -# Set up env -env REPO=${SCRIPT_NAME}-${RANDOM_STRING} - -# Use gh as a credential helper -exec gh auth setup-git - -# Create a repository with a file so it has a default branch -exec gh repo create ${ORG}/${REPO} --add-readme --private - -# Defer repo cleanup -defer gh repo delete --yes ${ORG}/${REPO} - -# Clone the repo -exec gh repo clone ${ORG}/${REPO} - -# commit the workflow file -cd ${REPO} -mkdir .github/workflows -mv ../workflow.yml .github/workflows/workflow.yml -exec git add .github/workflows/workflow.yml -exec git commit -m 'Create workflow file' -exec git push -u origin main - -# Sleep because it takes a second for the workflow to register -sleep 1 - -# Check the workflow is indeed created -exec gh workflow list -stdout 'Test Workflow Name' - -# Run the workflow -exec gh workflow run 'Test Workflow Name' - -# It takes some time for a workflow run to register -sleep 10 - -# Get the run ID we want to watch -exec gh run list --json databaseId --jq '.[0].databaseId' -stdout2env RUN_ID - -# Wait for workflow to complete -exec gh run watch ${RUN_ID} --exit-status - -# Download the artifact and see there is an error -! exec gh run download ${RUN_ID} -stderr 'would result in path traversal' - --- workflow.yml -- -# This is a basic workflow to help you get started with Actions - -name: Test Workflow Name - -# Controls when the workflow will run -on: - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# A workflow run is made up of one or more jobs that can run sequentially or in parallel -jobs: - # This workflow contains a single job called "build" - build: - # The type of runner that the job will run on - runs-on: ubuntu-latest - - # Steps represent a sequence of tasks that will be executed as part of the job - steps: - - run: echo hello > world.txt - - uses: actions/upload-artifact@v4 - with: - name: .. - path: world.txt From 5ed8cf0faa80caff3eeb2edc6d32ef539e477a48 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:43:08 -0600 Subject: [PATCH 06/21] fix(pr view): fetch nameWithOwner in headRepository GraphQL query Commit dd424d85f added NameWithOwner to PRRepository for agent-task listings but didn't update the headRepository GraphQL query to fetch it. This caused gh pr view --json headRepository to emit an empty "nameWithOwner":"" field, breaking the pr-create-respects-simple- pushdefault acceptance test. Fetch nameWithOwner in the query and update the test assertion to expect it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../testdata/pr/pr-create-respects-simple-pushdefault.txtar | 2 +- api/query_builder.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar index 63d3ae2b41e..3781ec925b8 100644 --- a/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar @@ -31,4 +31,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${REPO_ID}","name":"${REPO}"},"isCrossRepository":false} +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${REPO_ID}","name":"${REPO}","nameWithOwner":"${ORG}/${REPO}"},"isCrossRepository":false} diff --git a/api/query_builder.go b/api/query_builder.go index c3e1e9ba3a9..9c97e67e997 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -387,7 +387,7 @@ func IssueGraphQL(fields []string) string { case "headRepositoryOwner": q = append(q, `headRepositoryOwner{id,login,...on User{name}}`) case "headRepository": - q = append(q, `headRepository{id,name}`) + q = append(q, `headRepository{id,name,nameWithOwner}`) case "assignees": q = append(q, `assignees(first:100){nodes{id,login,name,databaseId},totalCount}`) case "assignedActors": From 971be976b3d50527bd7ad21df834f7feb4d5afee Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 26 Mar 2026 13:23:43 +0100 Subject: [PATCH 07/21] Add nameWithOwner to necessary tests --- ...-create-guesses-remote-from-sha-with-branch-name-slash.txtar | 2 +- acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar | 2 +- .../pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar | 2 +- .../pr/pr-create-remote-ref-with-branch-name-slash.txtar | 2 +- .../testdata/pr/pr-create-respects-branch-pushremote.txtar | 2 +- .../testdata/pr/pr-create-respects-push-destination.txtar | 2 +- .../testdata/pr/pr-create-respects-remote-pushdefault.txtar | 2 +- .../pr/pr-create-respects-user-colon-branch-syntax.txtar | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar index 542579b0aa6..c3717ab2314 100644 --- a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar +++ b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar @@ -47,4 +47,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Check the PR is indeed created exec gh pr view ${USER}:feature/branch --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature/branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} +stdout {"baseRefName":"main","headRefName":"feature/branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}","nameWithOwner":"${USER}/${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar index e263b0351c8..6359672e158 100644 --- a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar +++ b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar @@ -45,4 +45,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Check the PR is indeed created exec gh pr view ${USER}:feature-branch --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}","nameWithOwner":"${USER}/${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar b/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar index 0974f922590..b51e13d13e1 100644 --- a/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar +++ b/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar @@ -47,4 +47,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}","nameWithOwner":"${USER}/${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar b/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar index 395fce86a03..b8b1515f530 100644 --- a/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar +++ b/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar @@ -43,4 +43,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature/branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} +stdout {"baseRefName":"main","headRefName":"feature/branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}","nameWithOwner":"${USER}/${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar index e0d0c099cd7..cbfc7dcb120 100644 --- a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar +++ b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar @@ -46,4 +46,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}","nameWithOwner":"${USER}/${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar index 51708405d8f..24fb2781736 100644 --- a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar +++ b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar @@ -50,4 +50,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}","nameWithOwner":"${USER}/${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar index ff92f1e2d49..48e8fa6cccd 100644 --- a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar @@ -46,4 +46,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}","nameWithOwner":"${USER}/${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar b/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar index a59171d5899..7c45b1d3756 100644 --- a/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar +++ b/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar @@ -44,4 +44,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view ${USER}:feature-branch --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}","nameWithOwner":"${USER}/${FORK}"},"isCrossRepository":true} From 87426ee236c3918827fcc05e4aafe5dafcc09368 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:13:32 -0700 Subject: [PATCH 08/21] Add experimental huh-only prompter gated by GH_EXPERIMENTAL_PROMPTER Introduce a new Prompter implementation (huhPrompter) that uses the charmbracelet/huh library in its standard interactive mode, as an alternative to the survey-based default prompter. The new implementation is gated behind the GH_EXPERIMENTAL_PROMPTER environment variable, following the same truthy/falsey pattern as GH_ACCESSIBLE_PROMPTER. Key differences from the accessible prompter: - No WithAccessible(true) flag (full interactive TUI) - Uses EchoModePassword (masked with *) instead of EchoModeNone - No default value annotations appended to prompt text Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/prompter/huh_prompter.go | 226 ++++++++++++++++++++++++++++++ internal/prompter/prompter.go | 9 ++ pkg/cmd/factory/default.go | 7 + pkg/iostreams/iostreams.go | 13 +- 4 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 internal/prompter/huh_prompter.go diff --git a/internal/prompter/huh_prompter.go b/internal/prompter/huh_prompter.go new file mode 100644 index 00000000000..66738d04f70 --- /dev/null +++ b/internal/prompter/huh_prompter.go @@ -0,0 +1,226 @@ +package prompter + +import ( + "fmt" + "slices" + + "github.com/charmbracelet/huh" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/pkg/surveyext" + ghPrompter "github.com/cli/go-gh/v2/pkg/prompter" +) + +type huhPrompter struct { + stdin ghPrompter.FileReader + stdout ghPrompter.FileWriter + stderr ghPrompter.FileWriter + editorCmd string +} + +func (p *huhPrompter) newForm(groups ...*huh.Group) *huh.Form { + return huh.NewForm(groups...). + WithTheme(huh.ThemeBase16()). + WithInput(p.stdin). + WithOutput(p.stdout) +} + +func (p *huhPrompter) Select(prompt, defaultValue string, options []string) (int, error) { + var result int + + if !slices.Contains(options, defaultValue) { + defaultValue = "" + } + + formOptions := make([]huh.Option[int], len(options)) + for i, o := range options { + if defaultValue == o { + result = i + } + formOptions[i] = huh.NewOption(o, i) + } + + err := p.newForm( + huh.NewGroup( + huh.NewSelect[int](). + Title(prompt). + Value(&result). + Options(formOptions...), + ), + ).Run() + + return result, err +} + +func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { + var result []int + + defaults = slices.DeleteFunc(defaults, func(s string) bool { + return !slices.Contains(options, s) + }) + + formOptions := make([]huh.Option[int], len(options)) + for i, o := range options { + if slices.Contains(defaults, o) { + result = append(result, i) + } + formOptions[i] = huh.NewOption(o, i) + } + + err := p.newForm( + huh.NewGroup( + huh.NewMultiSelect[int](). + Title(prompt). + Value(&result). + Limit(len(options)). + Options(formOptions...), + ), + ).Run() + + if err != nil { + return nil, err + } + return result, nil +} + +func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) { + return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc) +} + +func (p *huhPrompter) Input(prompt, defaultValue string) (string, error) { + result := defaultValue + + err := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title(prompt). + Value(&result), + ), + ).Run() + + return result, err +} + +func (p *huhPrompter) Password(prompt string) (string, error) { + var result string + + err := p.newForm( + huh.NewGroup( + huh.NewInput(). + EchoMode(huh.EchoModePassword). + Title(prompt). + Value(&result), + ), + ).Run() + + if err != nil { + return "", err + } + return result, nil +} + +func (p *huhPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { + result := defaultValue + + err := p.newForm( + huh.NewGroup( + huh.NewConfirm(). + Title(prompt). + Value(&result), + ), + ).Run() + + if err != nil { + return false, err + } + return result, nil +} + +func (p *huhPrompter) AuthToken() (string, error) { + var result string + + err := p.newForm( + huh.NewGroup( + huh.NewInput(). + EchoMode(huh.EchoModePassword). + Title("Paste your authentication token:"). + Validate(func(input string) error { + if input == "" { + return fmt.Errorf("token is required") + } + return nil + }). + Value(&result), + ), + ).Run() + + return result, err +} + +func (p *huhPrompter) ConfirmDeletion(requiredValue string) error { + return p.newForm( + huh.NewGroup( + huh.NewInput(). + Title(fmt.Sprintf("Type %q to confirm deletion", requiredValue)). + Validate(func(input string) error { + if input != requiredValue { + return fmt.Errorf("You entered: %q", input) + } + return nil + }), + ), + ).Run() +} + +func (p *huhPrompter) InputHostname() (string, error) { + var result string + + err := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title("Hostname:"). + Validate(ghinstance.HostnameValidator). + Value(&result), + ), + ).Run() + + if err != nil { + return "", err + } + return result, nil +} + +func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { + var result string + skipOption := "skip" + launchOption := "launch" + options := []huh.Option[string]{ + huh.NewOption(fmt.Sprintf("Launch %s", surveyext.EditorName(p.editorCmd)), launchOption), + } + if blankAllowed { + options = append(options, huh.NewOption("Skip", skipOption)) + } + + err := p.newForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(prompt). + Options(options...). + Value(&result), + ), + ).Run() + + if err != nil { + return "", err + } + + if result == skipOption { + return "", nil + } + + text, err := surveyext.Edit(p.editorCmd, "*.md", defaultValue, p.stdin, p.stdout, p.stderr) + if err != nil { + return "", err + } + + return text, nil +} diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 2bf49eb5877..40b746839a8 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -53,6 +53,15 @@ type Prompter interface { } func New(editorCmd string, io *iostreams.IOStreams) Prompter { + if io.ExperimentalPrompterEnabled() { + return &huhPrompter{ + stdin: io.In, + stdout: io.Out, + stderr: io.ErrOut, + editorCmd: editorCmd, + } + } + if io.AccessiblePrompterEnabled() { return &accessiblePrompter{ stdin: io.In, diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 48ec0c8fe0c..cdbed20af40 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -316,6 +316,13 @@ func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams { io.SetAccessiblePrompterEnabled(true) } + experimentalPrompterValue, experimentalPrompterIsSet := os.LookupEnv("GH_EXPERIMENTAL_PROMPTER") + if experimentalPrompterIsSet { + if !slices.Contains(falseyValues, experimentalPrompterValue) { + io.SetExperimentalPrompterEnabled(true) + } + } + ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED") if ghSpinnerDisabledIsSet { if !slices.Contains(falseyValues, ghSpinnerDisabledValue) { diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 22f966ac810..89d4600c0dd 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -79,8 +79,9 @@ type IOStreams struct { pagerCommand string pagerProcess *os.Process - neverPrompt bool - accessiblePrompterEnabled bool + neverPrompt bool + accessiblePrompterEnabled bool + experimentalPrompterEnabled bool TempFileOverride *os.File } @@ -466,6 +467,14 @@ func (s *IOStreams) AccessiblePrompterEnabled() bool { return s.accessiblePrompterEnabled } +func (s *IOStreams) SetExperimentalPrompterEnabled(enabled bool) { + s.experimentalPrompterEnabled = enabled +} + +func (s *IOStreams) ExperimentalPrompterEnabled() bool { + return s.experimentalPrompterEnabled +} + func System() *IOStreams { terminal := ghTerm.FromEnv() From 726714d1a79de5bda4541cd3f0c1cb98cd82f5a9 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:50:14 -0700 Subject: [PATCH 09/21] Use LayoutStack for huhPrompter MultiSelectWithSearch Implement a huh-native MultiSelectWithSearch that renders the search input and multi-select list simultaneously using LayoutStack. The search input is in Group 0 and the multi-select in Group 1, with OptionsFunc bound to the search query so results update when the user presses Enter to advance focus. Users can Shift+Tab back to refine their search, and selections persist across queries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/prompter/huh_prompter.go | 89 ++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/internal/prompter/huh_prompter.go b/internal/prompter/huh_prompter.go index 66738d04f70..37370b633ec 100644 --- a/internal/prompter/huh_prompter.go +++ b/internal/prompter/huh_prompter.go @@ -83,7 +83,94 @@ func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []st } func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) { - return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc) + selectedValues := make([]string, len(defaultValues)) + copy(selectedValues, defaultValues) + + optionKeyLabels := make(map[string]string) + for _, k := range selectedValues { + optionKeyLabels[k] = k + } + + buildOptions := func(query string) []huh.Option[string] { + result := searchFunc(query) + if result.Err != nil { + return nil + } + for i, k := range result.Keys { + optionKeyLabels[k] = result.Labels[i] + } + + var formOptions []huh.Option[string] + seen := make(map[string]bool) + + // 1. Currently selected values (persisted across searches). + for _, k := range selectedValues { + if seen[k] { + continue + } + seen[k] = true + l := optionKeyLabels[k] + if l == "" { + l = k + } + formOptions = append(formOptions, huh.NewOption(l, k)) + } + + // 2. Search results. + for i, k := range result.Keys { + if seen[k] { + continue + } + seen[k] = true + l := result.Labels[i] + if l == "" { + l = k + } + formOptions = append(formOptions, huh.NewOption(l, k)) + } + + // 3. Persistent options. + for _, k := range persistentValues { + if seen[k] { + continue + } + seen[k] = true + l := optionKeyLabels[k] + if l == "" { + l = k + } + formOptions = append(formOptions, huh.NewOption(l, k)) + } + + if len(formOptions) == 0 { + formOptions = append(formOptions, huh.NewOption("No results", "")) + } + + return formOptions + } + + var searchQuery string + + err := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title(searchPrompt). + Value(&searchQuery), + huh.NewMultiSelect[string](). + Title(prompt). + Options(buildOptions("")...). + OptionsFunc(func() []huh.Option[string] { + return buildOptions(searchQuery) + }, &searchQuery). + Value(&selectedValues). + Limit(0), + ), + ).Run() + if err != nil { + return nil, err + } + + return selectedValues, nil } func (p *huhPrompter) Input(prompt, defaultValue string) (string, error) { From 4661c05ed02aa4366be7fc78ee5d2457955a5e27 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:31:23 -0700 Subject: [PATCH 10/21] Fix gofmt alignment for prompter-enabled fields in IOStreams Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/iostreams/iostreams.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 89d4600c0dd..b3bada201fc 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -79,8 +79,8 @@ type IOStreams struct { pagerCommand string pagerProcess *os.Process - neverPrompt bool - accessiblePrompterEnabled bool + neverPrompt bool + accessiblePrompterEnabled bool experimentalPrompterEnabled bool TempFileOverride *os.File From f294831e7ddaacc8ee2acef88ad2d404059b7b5d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:53:29 -0600 Subject: [PATCH 11/21] Upgrade to huh/v2 and fix selection persistence in MultiSelectWithSearch Migrate from github.com/charmbracelet/huh v1 to charm.land/huh/v2, updating ThemeBase16 to the new ThemeFunc API. Fix selected options being lost across searches in the huhPrompter's MultiSelectWithSearch. The root cause was huh's internal Eval cache: when the user returned to a previously-seen search query, cached options with stale .selected state overwrote the current selections via updateValue(). The fix includes selectedValues in the OptionsFunc binding hash (via searchOptionsBinding) so the cache key changes whenever selections change, preventing stale cache hits. A local searchFunc result cache avoids redundant API calls when only the selection state (not the query) has changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go.mod | 26 +++++++----- go.sum | 67 +++++++++++++++++-------------- internal/prompter/huh_prompter.go | 34 +++++++++++++--- internal/prompter/prompter.go | 4 +- 4 files changed, 82 insertions(+), 49 deletions(-) diff --git a/go.mod b/go.mod index 4a34d400f02..98195340d99 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/cli/cli/v2 go 1.26.1 require ( + charm.land/huh/v2 v2.0.3 github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc v1.0.0 github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 @@ -11,7 +12,6 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 github.com/cenkalti/backoff/v5 v5.0.3 github.com/charmbracelet/glamour v0.10.0 - github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/cli/go-gh/v2 v2.13.0 github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 @@ -61,6 +61,9 @@ require ( ) require ( + charm.land/bubbles/v2 v2.0.0 // indirect + charm.land/bubbletea/v2 v2.0.2 // indirect + charm.land/lipgloss/v2 v2.0.2 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect @@ -72,16 +75,20 @@ require ( github.com/blang/semver v3.5.1+incompatible // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect - github.com/charmbracelet/colorprofile v0.3.1 // indirect - github.com/charmbracelet/x/ansi v0.10.2 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250630141444-821143405392 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20250630141444-821143405392 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/cli/browser v1.3.0 // indirect github.com/cli/shurcooL-graphql v0.0.4 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/danieljoos/wincred v1.2.3 // indirect @@ -92,7 +99,6 @@ require ( github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.18.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -134,14 +140,12 @@ require ( github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.17 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect diff --git a/go.sum b/go.sum index 5fcce09e833..c9cfb3dfc02 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,11 @@ +charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= +charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= @@ -86,8 +94,8 @@ github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= @@ -102,38 +110,38 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= -github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= -github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= -github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= -github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= -github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/slice v0.0.0-20250630141444-821143405392 h1:VHLoEcL+kH60a4F8qMsPfOIfWjFE3ciaW4gge2YR3sA= github.com/charmbracelet/x/exp/slice v0.0.0-20250630141444-821143405392/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= github.com/charmbracelet/x/exp/strings v0.0.0-20250630141444-821143405392 h1:6ipGA1NEA0AZG2UEf81RQGJvEPvYLn/M18mZcdt4J8g= github.com/charmbracelet/x/exp/strings v0.0.0-20250630141444-821143405392/go.mod h1:Rgw3/F+xlcUc5XygUtimVSxAqCOsqyvJjqF5UHRvc5k= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= -github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q= github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= @@ -148,6 +156,10 @@ github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY= github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= @@ -185,8 +197,6 @@ github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqI github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -380,12 +390,10 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= -github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= @@ -403,8 +411,6 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= @@ -596,7 +602,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/prompter/huh_prompter.go b/internal/prompter/huh_prompter.go index 37370b633ec..a6118de9b3f 100644 --- a/internal/prompter/huh_prompter.go +++ b/internal/prompter/huh_prompter.go @@ -4,7 +4,7 @@ import ( "fmt" "slices" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/surveyext" ghPrompter "github.com/cli/go-gh/v2/pkg/prompter" @@ -19,7 +19,7 @@ type huhPrompter struct { func (p *huhPrompter) newForm(groups ...*huh.Group) *huh.Form { return huh.NewForm(groups...). - WithTheme(huh.ThemeBase16()). + WithTheme(huh.ThemeFunc(huh.ThemeBase16)). WithInput(p.stdin). WithOutput(p.stdout) } @@ -82,6 +82,15 @@ func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []st return result, nil } +// searchOptionsBinding is used as the OptionsFunc binding for MultiSelectWithSearch. +// By including both the search query and selected values, the binding hash changes +// whenever either changes. This prevents huh's internal Eval cache from serving +// stale option sets that would overwrite the user's current selections. +type searchOptionsBinding struct { + Query *string + Selected *[]string +} + func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) { selectedValues := make([]string, len(defaultValues)) copy(selectedValues, defaultValues) @@ -91,8 +100,19 @@ func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, default optionKeyLabels[k] = k } + // Cache searchFunc results locally keyed by query string. + // This avoids redundant calls when the OptionsFunc binding hash changes + // due to selection changes (not query changes). + var cachedSearchQuery string + var cachedSearchResult MultiSelectSearchResult + buildOptions := func(query string) []huh.Option[string] { - result := searchFunc(query) + if query != cachedSearchQuery || cachedSearchResult.Err != nil { + cachedSearchResult = searchFunc(query) + cachedSearchQuery = query + } + result := cachedSearchResult + if result.Err != nil { return nil } @@ -113,7 +133,7 @@ func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, default if l == "" { l = k } - formOptions = append(formOptions, huh.NewOption(l, k)) + formOptions = append(formOptions, huh.NewOption(l, k).Selected(true)) } // 2. Search results. @@ -150,6 +170,10 @@ func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, default } var searchQuery string + binding := &searchOptionsBinding{ + Query: &searchQuery, + Selected: &selectedValues, + } err := p.newForm( huh.NewGroup( @@ -161,7 +185,7 @@ func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, default Options(buildOptions("")...). OptionsFunc(func() []huh.Option[string] { return buildOptions(searchQuery) - }, &searchQuery). + }, binding). Value(&selectedValues). Limit(0), ), diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 40b746839a8..a9986804409 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -6,7 +6,7 @@ import ( "strings" "github.com/AlecAivazis/survey/v2" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/surveyext" @@ -89,7 +89,7 @@ type accessiblePrompter struct { func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form { return huh.NewForm(groups...). - WithTheme(huh.ThemeBase16()). + WithTheme(huh.ThemeFunc(huh.ThemeBase16)). WithAccessible(true). WithInput(p.stdin). WithOutput(p.stdout) From 86d876fd34da105bfa4740af24669b70671ea0f4 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:56:46 -0600 Subject: [PATCH 12/21] test(huh prompter): add table-driven tests for all prompt types Extract build*Form() methods from each huhPrompter method, separating form construction from form.Run(). This enables testing the real form construction code by driving it with direct model updates, adapted from huh's own test patterns. Tests cover Input, Select, MultiSelect, Confirm, Password, MarkdownEditor, and MultiSelectWithSearch including a persistence test that verifies selections survive across search query changes. Also fixes a search cache initialization bug where the first buildOptions("") call would skip the searchFunc due to cachedSearchQuery defaulting to "". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/prompter/huh_prompter.go | 136 ++++--- internal/prompter/huh_prompter_test.go | 508 +++++++++++++++++++++++++ 2 files changed, 599 insertions(+), 45 deletions(-) create mode 100644 internal/prompter/huh_prompter_test.go diff --git a/internal/prompter/huh_prompter.go b/internal/prompter/huh_prompter.go index a6118de9b3f..7fbd052adfd 100644 --- a/internal/prompter/huh_prompter.go +++ b/internal/prompter/huh_prompter.go @@ -24,7 +24,7 @@ func (p *huhPrompter) newForm(groups ...*huh.Group) *huh.Form { WithOutput(p.stdout) } -func (p *huhPrompter) Select(prompt, defaultValue string, options []string) (int, error) { +func (p *huhPrompter) buildSelectForm(prompt, defaultValue string, options []string) (*huh.Form, *int) { var result int if !slices.Contains(options, defaultValue) { @@ -39,19 +39,24 @@ func (p *huhPrompter) Select(prompt, defaultValue string, options []string) (int formOptions[i] = huh.NewOption(o, i) } - err := p.newForm( + form := p.newForm( huh.NewGroup( huh.NewSelect[int](). Title(prompt). Value(&result). Options(formOptions...), ), - ).Run() + ) + return form, &result +} - return result, err +func (p *huhPrompter) Select(prompt, defaultValue string, options []string) (int, error) { + form, result := p.buildSelectForm(prompt, defaultValue, options) + err := form.Run() + return *result, err } -func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { +func (p *huhPrompter) buildMultiSelectForm(prompt string, defaults []string, options []string) (*huh.Form, *[]int) { var result []int defaults = slices.DeleteFunc(defaults, func(s string) bool { @@ -66,7 +71,7 @@ func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []st formOptions[i] = huh.NewOption(o, i) } - err := p.newForm( + form := p.newForm( huh.NewGroup( huh.NewMultiSelect[int](). Title(prompt). @@ -74,12 +79,17 @@ func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []st Limit(len(options)). Options(formOptions...), ), - ).Run() + ) + return form, &result +} +func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { + form, result := p.buildMultiSelectForm(prompt, defaults, options) + err := form.Run() if err != nil { return nil, err } - return result, nil + return *result, nil } // searchOptionsBinding is used as the OptionsFunc binding for MultiSelectWithSearch. @@ -91,7 +101,7 @@ type searchOptionsBinding struct { Selected *[]string } -func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) { +func (p *huhPrompter) buildMultiSelectWithSearchForm(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) (*huh.Form, *[]string) { selectedValues := make([]string, len(defaultValues)) copy(selectedValues, defaultValues) @@ -103,13 +113,15 @@ func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, default // Cache searchFunc results locally keyed by query string. // This avoids redundant calls when the OptionsFunc binding hash changes // due to selection changes (not query changes). + searchCacheValid := false var cachedSearchQuery string var cachedSearchResult MultiSelectSearchResult buildOptions := func(query string) []huh.Option[string] { - if query != cachedSearchQuery || cachedSearchResult.Err != nil { + if !searchCacheValid || query != cachedSearchQuery { cachedSearchResult = searchFunc(query) cachedSearchQuery = query + searchCacheValid = true } result := cachedSearchResult @@ -175,7 +187,7 @@ func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, default Selected: &selectedValues, } - err := p.newForm( + form := p.newForm( huh.NewGroup( huh.NewInput(). Title(searchPrompt). @@ -189,67 +201,83 @@ func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, default Value(&selectedValues). Limit(0), ), - ).Run() + ) + return form, &selectedValues +} + +func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) { + form, result := p.buildMultiSelectWithSearchForm(prompt, searchPrompt, defaultValues, persistentValues, searchFunc) + err := form.Run() if err != nil { return nil, err } - - return selectedValues, nil + return *result, nil } -func (p *huhPrompter) Input(prompt, defaultValue string) (string, error) { +func (p *huhPrompter) buildInputForm(prompt, defaultValue string) (*huh.Form, *string) { result := defaultValue - - err := p.newForm( + form := p.newForm( huh.NewGroup( huh.NewInput(). Title(prompt). Value(&result), ), - ).Run() + ) + return form, &result +} - return result, err +func (p *huhPrompter) Input(prompt, defaultValue string) (string, error) { + form, result := p.buildInputForm(prompt, defaultValue) + err := form.Run() + return *result, err } -func (p *huhPrompter) Password(prompt string) (string, error) { +func (p *huhPrompter) buildPasswordForm(prompt string) (*huh.Form, *string) { var result string - - err := p.newForm( + form := p.newForm( huh.NewGroup( huh.NewInput(). EchoMode(huh.EchoModePassword). Title(prompt). Value(&result), ), - ).Run() + ) + return form, &result +} +func (p *huhPrompter) Password(prompt string) (string, error) { + form, result := p.buildPasswordForm(prompt) + err := form.Run() if err != nil { return "", err } - return result, nil + return *result, nil } -func (p *huhPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { +func (p *huhPrompter) buildConfirmForm(prompt string, defaultValue bool) (*huh.Form, *bool) { result := defaultValue - - err := p.newForm( + form := p.newForm( huh.NewGroup( huh.NewConfirm(). Title(prompt). Value(&result), ), - ).Run() + ) + return form, &result +} +func (p *huhPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { + form, result := p.buildConfirmForm(prompt, defaultValue) + err := form.Run() if err != nil { return false, err } - return result, nil + return *result, nil } -func (p *huhPrompter) AuthToken() (string, error) { +func (p *huhPrompter) buildAuthTokenForm() (*huh.Form, *string) { var result string - - err := p.newForm( + form := p.newForm( huh.NewGroup( huh.NewInput(). EchoMode(huh.EchoModePassword). @@ -262,12 +290,17 @@ func (p *huhPrompter) AuthToken() (string, error) { }). Value(&result), ), - ).Run() + ) + return form, &result +} - return result, err +func (p *huhPrompter) AuthToken() (string, error) { + form, result := p.buildAuthTokenForm() + err := form.Run() + return *result, err } -func (p *huhPrompter) ConfirmDeletion(requiredValue string) error { +func (p *huhPrompter) buildConfirmDeletionForm(requiredValue string) *huh.Form { return p.newForm( huh.NewGroup( huh.NewInput(). @@ -279,28 +312,36 @@ func (p *huhPrompter) ConfirmDeletion(requiredValue string) error { return nil }), ), - ).Run() + ) } -func (p *huhPrompter) InputHostname() (string, error) { - var result string +func (p *huhPrompter) ConfirmDeletion(requiredValue string) error { + return p.buildConfirmDeletionForm(requiredValue).Run() +} - err := p.newForm( +func (p *huhPrompter) buildInputHostnameForm() (*huh.Form, *string) { + var result string + form := p.newForm( huh.NewGroup( huh.NewInput(). Title("Hostname:"). Validate(ghinstance.HostnameValidator). Value(&result), ), - ).Run() + ) + return form, &result +} +func (p *huhPrompter) InputHostname() (string, error) { + form, result := p.buildInputHostnameForm() + err := form.Run() if err != nil { return "", err } - return result, nil + return *result, nil } -func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { +func (p *huhPrompter) buildMarkdownEditorForm(prompt string, blankAllowed bool) (*huh.Form, *string) { var result string skipOption := "skip" launchOption := "launch" @@ -311,20 +352,25 @@ func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed b options = append(options, huh.NewOption("Skip", skipOption)) } - err := p.newForm( + form := p.newForm( huh.NewGroup( huh.NewSelect[string](). Title(prompt). Options(options...). Value(&result), ), - ).Run() + ) + return form, &result +} +func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { + form, result := p.buildMarkdownEditorForm(prompt, blankAllowed) + err := form.Run() if err != nil { return "", err } - if result == skipOption { + if *result == "skip" { return "", nil } diff --git a/internal/prompter/huh_prompter_test.go b/internal/prompter/huh_prompter_test.go new file mode 100644 index 00000000000..374cabaa384 --- /dev/null +++ b/internal/prompter/huh_prompter_test.go @@ -0,0 +1,508 @@ +package prompter + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test helpers adapted from huh's own test suite (huh_test.go). + +func batchUpdate(m huh.Model, cmd tea.Cmd) huh.Model { + if cmd == nil { + return m + } + msg := cmd() + m, cmd = m.Update(msg) + if cmd == nil { + return m + } + msg = cmd() + m, _ = m.Update(msg) + return m +} + +func codeKeypress(r rune) tea.KeyPressMsg { + return tea.KeyPressMsg(tea.Key{Code: r}) +} + +func keypress(r rune) tea.KeyPressMsg { + return tea.KeyPressMsg(tea.Key{ + Text: string(r), + Code: r, + ShiftedCode: r, + }) +} + +func typeText(m huh.Model, s string) huh.Model { + for _, r := range s { + m, _ = m.Update(keypress(r)) + } + return m +} + +func viewStripped(m huh.Model) string { + return ansi.Strip(m.View()) +} + +func shiftTabKeypress() tea.KeyPressMsg { + return tea.KeyPressMsg(tea.Key{Code: tea.KeyTab, Mod: tea.ModShift}) +} + +func newTestHuhPrompter() *huhPrompter { + return &huhPrompter{} +} + +// doAllUpdates processes all batched commands from the form, including async +// OptionsFunc evaluations. Adapted from huh's own test suite. Uses iterative +// rounds with a depth limit to prevent infinite loops from cascading binding updates. +func doAllUpdates(f *huh.Form, cmd tea.Cmd) { + for range 3 { + if cmd == nil { + return + } + cmds := expandBatch(cmd) + var next []tea.Cmd + for _, c := range cmds { + if c == nil { + continue + } + _, result := f.Update(c()) + if result != nil { + next = append(next, result) + } + } + if len(next) == 0 { + return + } + cmd = tea.Batch(next...) + } +} + +// expandBatch flattens nested tea.BatchMsg into a flat slice of commands. +func expandBatch(cmd tea.Cmd) []tea.Cmd { + if cmd == nil { + return nil + } + msg := cmd() + if batch, ok := msg.(tea.BatchMsg); ok { + var all []tea.Cmd + for _, sub := range batch { + all = append(all, expandBatch(sub)...) + } + return all + } + return []tea.Cmd{func() tea.Msg { return msg }} +} + +func TestHuhPrompterInput(t *testing.T) { + tests := []struct { + name string + defaultValue string + input string + wantResult string + }{ + { + name: "basic input", + input: "hello", + wantResult: "hello", + }, + { + name: "default value returned when no input", + defaultValue: "default", + wantResult: "default", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildInputForm("Name:", tt.defaultValue) + f.Update(f.Init()) + + var m huh.Model = f + if tt.input != "" { + m = typeText(m, tt.input) + } + batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) + + require.Equal(t, tt.wantResult, *result) + }) + } +} + +func TestHuhPrompterSelect(t *testing.T) { + tests := []struct { + name string + options []string + defaultValue string + keys []tea.KeyPressMsg // keypresses before Enter + wantIndex int + }{ + { + name: "selects first option by default", + options: []string{"a", "b", "c"}, + wantIndex: 0, + }, + { + name: "respects default value", + options: []string{"a", "b", "c"}, + defaultValue: "b", + wantIndex: 1, + }, + { + name: "invalid default selects first", + options: []string{"a", "b", "c"}, + defaultValue: "z", + wantIndex: 0, + }, + { + name: "navigate down one", + options: []string{"a", "b", "c"}, + keys: []tea.KeyPressMsg{keypress('j')}, + wantIndex: 1, + }, + { + name: "navigate down two", + options: []string{"a", "b", "c"}, + keys: []tea.KeyPressMsg{keypress('j'), keypress('j')}, + wantIndex: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildSelectForm("Pick:", tt.defaultValue, tt.options) + f.Update(f.Init()) + + var m huh.Model = f + for _, k := range tt.keys { + m = batchUpdate(m.Update(k)) + } + batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) + + require.Equal(t, tt.wantIndex, *result) + }) + } +} + +func TestHuhPrompterMultiSelect(t *testing.T) { + tests := []struct { + name string + options []string + defaults []string + keys []tea.KeyPressMsg + wantResult []int + }{ + { + name: "no defaults and no toggles returns empty", + options: []string{"a", "b", "c"}, + wantResult: []int{}, + }, + { + name: "defaults are pre-selected", + options: []string{"a", "b", "c"}, + defaults: []string{"a", "c"}, + wantResult: []int{0, 2}, + }, + { + name: "toggle first option", + options: []string{"a", "b", "c"}, + keys: []tea.KeyPressMsg{keypress('x')}, + wantResult: []int{0}, + }, + { + name: "toggle multiple options", + options: []string{"a", "b", "c"}, + keys: []tea.KeyPressMsg{ + keypress('x'), // toggle a + keypress('j'), // move to b + keypress('j'), // move to c + keypress('x'), // toggle c + }, + wantResult: []int{0, 2}, + }, + { + name: "invalid defaults are excluded", + options: []string{"a", "b"}, + defaults: []string{"z"}, + wantResult: []int{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildMultiSelectForm("Pick:", tt.defaults, tt.options) + f.Update(f.Init()) + + var m huh.Model = f + for _, k := range tt.keys { + m = batchUpdate(m.Update(k)) + } + batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) + + require.Equal(t, tt.wantResult, *result) + }) + } +} + +func TestHuhPrompterConfirm(t *testing.T) { + tests := []struct { + name string + defaultValue bool + keys []tea.KeyPressMsg + wantResult bool + }{ + { + name: "default false submitted as-is", + defaultValue: false, + wantResult: false, + }, + { + name: "default true submitted as-is", + defaultValue: true, + wantResult: true, + }, + { + name: "toggle from false to true with left arrow", + defaultValue: false, + keys: []tea.KeyPressMsg{codeKeypress(tea.KeyLeft)}, + wantResult: true, + }, + { + name: "toggle from true to false with right arrow", + defaultValue: true, + keys: []tea.KeyPressMsg{codeKeypress(tea.KeyRight)}, + wantResult: false, + }, + { + name: "accept with y key", + defaultValue: false, + keys: []tea.KeyPressMsg{keypress('y')}, + wantResult: true, + }, + { + name: "reject with n key", + defaultValue: true, + keys: []tea.KeyPressMsg{keypress('n')}, + wantResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildConfirmForm("Sure?", tt.defaultValue) + f.Update(f.Init()) + + var m huh.Model = f + for _, k := range tt.keys { + m = batchUpdate(m.Update(k)) + } + batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) + + require.Equal(t, tt.wantResult, *result) + }) + } +} + +func TestHuhPrompterPassword(t *testing.T) { + tests := []struct { + name string + input string + wantResult string + }{ + { + name: "basic password", + input: "s3cret", + wantResult: "s3cret", + }, + { + name: "empty password", + wantResult: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildPasswordForm("Password:") + f.Update(f.Init()) + + var m huh.Model = f + if tt.input != "" { + m = typeText(m, tt.input) + } + batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) + + require.Equal(t, tt.wantResult, *result) + }) + } +} + +func TestHuhPrompterMarkdownEditor(t *testing.T) { + tests := []struct { + name string + blankAllowed bool + keys []tea.KeyPressMsg + wantResult string + }{ + { + name: "selects launch by default", + blankAllowed: true, + wantResult: "launch", + }, + { + name: "navigate to skip", + blankAllowed: true, + keys: []tea.KeyPressMsg{keypress('j')}, + wantResult: "skip", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildMarkdownEditorForm("Body:", tt.blankAllowed) + f.Update(f.Init()) + + var m huh.Model = f + for _, k := range tt.keys { + m = batchUpdate(m.Update(k)) + } + batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) + + require.Equal(t, tt.wantResult, *result) + }) + } +} + +func TestHuhPrompterMultiSelectWithSearch(t *testing.T) { + staticSearchFunc := func(query string) MultiSelectSearchResult { + if query == "" { + return MultiSelectSearchResult{ + Keys: []string{"result-a", "result-b"}, + Labels: []string{"Result A", "Result B"}, + } + } + return MultiSelectSearchResult{ + Keys: []string{"search-1", "search-2"}, + Labels: []string{"Search 1", "Search 2"}, + } + } + + tests := []struct { + name string + defaults []string + persistent []string + keys []tea.KeyPressMsg + wantResult []string + }{ + { + name: "defaults are pre-selected and returned on immediate submit", + defaults: []string{"result-a"}, + keys: []tea.KeyPressMsg{ + // Tab past the search input to the multi-select, then submit. + codeKeypress(tea.KeyTab), + codeKeypress(tea.KeyEnter), + }, + wantResult: []string{"result-a"}, + }, + { + name: "toggle an option from search results", + keys: []tea.KeyPressMsg{ + codeKeypress(tea.KeyTab), // advance to multi-select + keypress('x'), // toggle first option (result-a) + codeKeypress(tea.KeyEnter), // submit + }, + wantResult: []string{"result-a"}, + }, + { + name: "toggle multiple options", + keys: []tea.KeyPressMsg{ + codeKeypress(tea.KeyTab), // advance to multi-select + keypress('x'), // toggle result-a + keypress('j'), // move to result-b + keypress('x'), // toggle result-b + codeKeypress(tea.KeyEnter), // submit + }, + wantResult: []string{"result-a", "result-b"}, + }, + { + name: "no selection returns empty", + keys: []tea.KeyPressMsg{ + codeKeypress(tea.KeyTab), + codeKeypress(tea.KeyEnter), + }, + wantResult: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildMultiSelectWithSearchForm( + "Select", "Search", tt.defaults, tt.persistent, staticSearchFunc, + ) + doAllUpdates(f, f.Init()) + + for _, k := range tt.keys { + _, cmd := f.Update(k) + doAllUpdates(f, cmd) + } + + assert.Equal(t, tt.wantResult, *result) + }) + } +} + +func TestHuhPrompterMultiSelectWithSearchPersistence(t *testing.T) { + callCount := 0 + staticSearchFunc := func(query string) MultiSelectSearchResult { + callCount++ + if query == "" { + return MultiSelectSearchResult{ + Keys: []string{"result-a", "result-b"}, + Labels: []string{"Result A", "Result B"}, + } + } + return MultiSelectSearchResult{ + Keys: []string{"search-1", "search-2"}, + Labels: []string{"Search 1", "Search 2"}, + } + } + + t.Run("selections persist after changing search query", func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildMultiSelectWithSearchForm( + "Select", "Search", nil, nil, staticSearchFunc, + ) + doAllUpdates(f, f.Init()) + + steps := []tea.KeyPressMsg{ + // Tab to multi-select, toggle result-a. + codeKeypress(tea.KeyTab), + keypress('x'), + // Shift+Tab back to search input, type "foo". + shiftTabKeypress(), + keypress('f'), keypress('o'), keypress('o'), + // Tab back to multi-select — result-a should still be selected. + codeKeypress(tea.KeyTab), + // Submit. + codeKeypress(tea.KeyEnter), + } + + for _, k := range steps { + _, cmd := f.Update(k) + doAllUpdates(f, cmd) + } + + assert.Equal(t, []string{"result-a"}, *result) + }) +} From 4d74e057f265bf8e022b8bf217416d5ad0c87aa4 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:39:26 -0600 Subject: [PATCH 13/21] refactor(huh prompter): pipe-based test harness with full coverage Replace manual model updates with an io.Pipe-based test harness that drives forms through bubbletea's real event loop. Interaction helpers (tab(), toggle(), typeKeys(), enter(), etc.) send raw terminal bytes through io.Pipe to form.Run() in a goroutine. Add tests for AuthToken, ConfirmDeletion, and InputHostname including validation rejection paths. Add MultiSelectWithSearch coverage for persistent options and empty search results. 30 tests, ~1s, all build*Form methods at 94-100% coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/prompter/huh_prompter_test.go | 489 ++++++++++++++----------- internal/prompter/prompter.go | 2 +- 2 files changed, 283 insertions(+), 208 deletions(-) diff --git a/internal/prompter/huh_prompter_test.go b/internal/prompter/huh_prompter_test.go index 374cabaa384..8f1d55c152d 100644 --- a/internal/prompter/huh_prompter_test.go +++ b/internal/prompter/huh_prompter_test.go @@ -1,119 +1,151 @@ package prompter import ( + "io" "testing" + "time" - tea "charm.land/bubbletea/v2" "charm.land/huh/v2" - "github.com/charmbracelet/x/ansi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// Test helpers adapted from huh's own test suite (huh_test.go). +// --- Interaction helpers --- +// A set of helpers for simulating user input in huh form tests. +// Each helper (tab(), toggle(), typeKeys(), etc.) produces raw terminal +// bytes that are piped into form.Run() via io.Pipe, driving the real +// bubbletea event loop. -func batchUpdate(m huh.Model, cmd tea.Cmd) huh.Model { - if cmd == nil { - return m - } - msg := cmd() - m, cmd = m.Update(msg) - if cmd == nil { - return m - } - msg = cmd() - m, _ = m.Update(msg) - return m +type interactionStep struct { + bytes []byte + delay time.Duration // pause before sending (lets the event loop settle) } -func codeKeypress(r rune) tea.KeyPressMsg { - return tea.KeyPressMsg(tea.Key{Code: r}) +type interaction struct { + steps []interactionStep } -func keypress(r rune) tea.KeyPressMsg { - return tea.KeyPressMsg(tea.Key{ - Text: string(r), - Code: r, - ShiftedCode: r, - }) +func newInteraction(steps ...interactionStep) interaction { + return interaction{steps: steps} } -func typeText(m huh.Model, s string) huh.Model { - for _, r := range s { - m, _ = m.Update(keypress(r)) +func (ix interaction) run(t *testing.T, w *io.PipeWriter) { + t.Helper() + for _, s := range ix.steps { + time.Sleep(s.delay) + _, err := w.Write(s.bytes) + require.NoError(t, err) } - return m } -func viewStripped(m huh.Model) string { - return ansi.Strip(m.View()) +// Step helpers — each returns a single interactionStep. +// +// These send raw terminal escape sequences that bubbletea's input parser +// understands. Common ANSI escape codes: +// +// \t = Tab +// \x1b[Z = Shift+Tab (reverse tab) +// \r = Enter (carriage return) +// \x1b[A = Arrow Up +// \x1b[B = Arrow Down +// \x1b[C = Arrow Right +// \x1b[D = Arrow Left +// \x01 = Ctrl+A (line start) +// \x0b = Ctrl+K (kill to end of line) + +func tab() interactionStep { + return interactionStep{bytes: []byte("\t")} +} + +func shiftTab() interactionStep { + return interactionStep{bytes: []byte("\x1b[Z")} +} + +func enter() interactionStep { + return interactionStep{bytes: []byte("\r")} +} + +func toggle() interactionStep { + return interactionStep{bytes: []byte("x")} +} + +func down() interactionStep { + return interactionStep{bytes: []byte("\x1b[B")} +} + +func left() interactionStep { + return interactionStep{bytes: []byte("\x1b[D")} +} + +func right() interactionStep { + return interactionStep{bytes: []byte("\x1b[C")} } -func shiftTabKeypress() tea.KeyPressMsg { - return tea.KeyPressMsg(tea.Key{Code: tea.KeyTab, Mod: tea.ModShift}) +func typeKeys(s string) interactionStep { + return interactionStep{bytes: []byte(s)} } +func pressY() interactionStep { + return interactionStep{bytes: []byte("y")} +} + +func pressN() interactionStep { + return interactionStep{bytes: []byte("n")} +} + +func clearLine() interactionStep { + return interactionStep{bytes: []byte{0x01, 0x0b}} +} + +// waitForOptions adds extra delay to let OptionsFunc load before continuing. +func waitForOptions() interactionStep { + return interactionStep{bytes: nil, delay: 50 * time.Millisecond} +} + +// --- Test harness --- + func newTestHuhPrompter() *huhPrompter { return &huhPrompter{} } -// doAllUpdates processes all batched commands from the form, including async -// OptionsFunc evaluations. Adapted from huh's own test suite. Uses iterative -// rounds with a depth limit to prevent infinite loops from cascading binding updates. -func doAllUpdates(f *huh.Form, cmd tea.Cmd) { - for range 3 { - if cmd == nil { - return - } - cmds := expandBatch(cmd) - var next []tea.Cmd - for _, c := range cmds { - if c == nil { - continue - } - _, result := f.Update(c()) - if result != nil { - next = append(next, result) - } - } - if len(next) == 0 { - return - } - cmd = tea.Batch(next...) - } -} +// runForm runs a huh form with the given interaction, returning any error. +// The form runs in a goroutine using bubbletea's real event loop via io.Pipe. +func runForm(t *testing.T, f *huh.Form, ix interaction) { + t.Helper() + r, w := io.Pipe() + f.WithInput(r).WithOutput(io.Discard) -// expandBatch flattens nested tea.BatchMsg into a flat slice of commands. -func expandBatch(cmd tea.Cmd) []tea.Cmd { - if cmd == nil { - return nil - } - msg := cmd() - if batch, ok := msg.(tea.BatchMsg); ok { - var all []tea.Cmd - for _, sub := range batch { - all = append(all, expandBatch(sub)...) - } - return all + errCh := make(chan error, 1) + go func() { errCh <- f.Run() }() + + ix.run(t, w) + + select { + case err := <-errCh: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("form.Run() did not complete in time") } - return []tea.Cmd{func() tea.Msg { return msg }} } +// --- Tests --- + func TestHuhPrompterInput(t *testing.T) { tests := []struct { name string defaultValue string - input string + ix interaction wantResult string }{ { name: "basic input", - input: "hello", + ix: newInteraction(typeKeys("hello"), enter()), wantResult: "hello", }, { name: "default value returned when no input", defaultValue: "default", + ix: newInteraction(enter()), wantResult: "default", }, } @@ -122,14 +154,7 @@ func TestHuhPrompterInput(t *testing.T) { t.Run(tt.name, func(t *testing.T) { p := newTestHuhPrompter() f, result := p.buildInputForm("Name:", tt.defaultValue) - f.Update(f.Init()) - - var m huh.Model = f - if tt.input != "" { - m = typeText(m, tt.input) - } - batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) - + runForm(t, f, tt.ix) require.Equal(t, tt.wantResult, *result) }) } @@ -140,36 +165,39 @@ func TestHuhPrompterSelect(t *testing.T) { name string options []string defaultValue string - keys []tea.KeyPressMsg // keypresses before Enter + ix interaction wantIndex int }{ { name: "selects first option by default", options: []string{"a", "b", "c"}, + ix: newInteraction(enter()), wantIndex: 0, }, { name: "respects default value", options: []string{"a", "b", "c"}, defaultValue: "b", + ix: newInteraction(enter()), wantIndex: 1, }, { name: "invalid default selects first", options: []string{"a", "b", "c"}, defaultValue: "z", + ix: newInteraction(enter()), wantIndex: 0, }, { name: "navigate down one", options: []string{"a", "b", "c"}, - keys: []tea.KeyPressMsg{keypress('j')}, + ix: newInteraction(down(), enter()), wantIndex: 1, }, { name: "navigate down two", options: []string{"a", "b", "c"}, - keys: []tea.KeyPressMsg{keypress('j'), keypress('j')}, + ix: newInteraction(down(), down(), enter()), wantIndex: 2, }, } @@ -178,14 +206,7 @@ func TestHuhPrompterSelect(t *testing.T) { t.Run(tt.name, func(t *testing.T) { p := newTestHuhPrompter() f, result := p.buildSelectForm("Pick:", tt.defaultValue, tt.options) - f.Update(f.Init()) - - var m huh.Model = f - for _, k := range tt.keys { - m = batchUpdate(m.Update(k)) - } - batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) - + runForm(t, f, tt.ix) require.Equal(t, tt.wantIndex, *result) }) } @@ -196,41 +217,45 @@ func TestHuhPrompterMultiSelect(t *testing.T) { name string options []string defaults []string - keys []tea.KeyPressMsg + ix interaction wantResult []int }{ { name: "no defaults and no toggles returns empty", options: []string{"a", "b", "c"}, + ix: newInteraction(enter()), wantResult: []int{}, }, { name: "defaults are pre-selected", options: []string{"a", "b", "c"}, defaults: []string{"a", "c"}, + ix: newInteraction(enter()), wantResult: []int{0, 2}, }, { - name: "toggle first option", - options: []string{"a", "b", "c"}, - keys: []tea.KeyPressMsg{keypress('x')}, + name: "toggle first option", + options: []string{"a", "b", "c"}, + ix: newInteraction(toggle(), enter()), wantResult: []int{0}, }, { name: "toggle multiple options", options: []string{"a", "b", "c"}, - keys: []tea.KeyPressMsg{ - keypress('x'), // toggle a - keypress('j'), // move to b - keypress('j'), // move to c - keypress('x'), // toggle c - }, + ix: newInteraction( + toggle(), // toggle a + down(), // move to b + down(), // move to c + toggle(), // toggle c + enter(), + ), wantResult: []int{0, 2}, }, { - name: "invalid defaults are excluded", - options: []string{"a", "b"}, - defaults: []string{"z"}, + name: "invalid defaults are excluded", + options: []string{"a", "b"}, + defaults: []string{"z"}, + ix: newInteraction(enter()), wantResult: []int{}, }, } @@ -239,14 +264,7 @@ func TestHuhPrompterMultiSelect(t *testing.T) { t.Run(tt.name, func(t *testing.T) { p := newTestHuhPrompter() f, result := p.buildMultiSelectForm("Pick:", tt.defaults, tt.options) - f.Update(f.Init()) - - var m huh.Model = f - for _, k := range tt.keys { - m = batchUpdate(m.Update(k)) - } - batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) - + runForm(t, f, tt.ix) require.Equal(t, tt.wantResult, *result) }) } @@ -256,41 +274,40 @@ func TestHuhPrompterConfirm(t *testing.T) { tests := []struct { name string defaultValue bool - keys []tea.KeyPressMsg + ix interaction wantResult bool }{ { - name: "default false submitted as-is", - defaultValue: false, - wantResult: false, + name: "default false submitted as-is", + ix: newInteraction(enter()), + wantResult: false, }, { name: "default true submitted as-is", defaultValue: true, + ix: newInteraction(enter()), wantResult: true, }, { - name: "toggle from false to true with left arrow", - defaultValue: false, - keys: []tea.KeyPressMsg{codeKeypress(tea.KeyLeft)}, - wantResult: true, + name: "toggle from false to true with left arrow", + ix: newInteraction(left(), enter()), + wantResult: true, }, { name: "toggle from true to false with right arrow", defaultValue: true, - keys: []tea.KeyPressMsg{codeKeypress(tea.KeyRight)}, + ix: newInteraction(right(), enter()), wantResult: false, }, { - name: "accept with y key", - defaultValue: false, - keys: []tea.KeyPressMsg{keypress('y')}, - wantResult: true, + name: "accept with y key", + ix: newInteraction(pressY(), enter()), + wantResult: true, }, { name: "reject with n key", defaultValue: true, - keys: []tea.KeyPressMsg{keypress('n')}, + ix: newInteraction(pressN(), enter()), wantResult: false, }, } @@ -299,14 +316,7 @@ func TestHuhPrompterConfirm(t *testing.T) { t.Run(tt.name, func(t *testing.T) { p := newTestHuhPrompter() f, result := p.buildConfirmForm("Sure?", tt.defaultValue) - f.Update(f.Init()) - - var m huh.Model = f - for _, k := range tt.keys { - m = batchUpdate(m.Update(k)) - } - batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) - + runForm(t, f, tt.ix) require.Equal(t, tt.wantResult, *result) }) } @@ -315,16 +325,17 @@ func TestHuhPrompterConfirm(t *testing.T) { func TestHuhPrompterPassword(t *testing.T) { tests := []struct { name string - input string + ix interaction wantResult string }{ { name: "basic password", - input: "s3cret", + ix: newInteraction(typeKeys("s3cret"), enter()), wantResult: "s3cret", }, { name: "empty password", + ix: newInteraction(enter()), wantResult: "", }, } @@ -333,14 +344,7 @@ func TestHuhPrompterPassword(t *testing.T) { t.Run(tt.name, func(t *testing.T) { p := newTestHuhPrompter() f, result := p.buildPasswordForm("Password:") - f.Update(f.Init()) - - var m huh.Model = f - if tt.input != "" { - m = typeText(m, tt.input) - } - batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) - + runForm(t, f, tt.ix) require.Equal(t, tt.wantResult, *result) }) } @@ -350,18 +354,19 @@ func TestHuhPrompterMarkdownEditor(t *testing.T) { tests := []struct { name string blankAllowed bool - keys []tea.KeyPressMsg + ix interaction wantResult string }{ { name: "selects launch by default", blankAllowed: true, + ix: newInteraction(enter()), wantResult: "launch", }, { name: "navigate to skip", blankAllowed: true, - keys: []tea.KeyPressMsg{keypress('j')}, + ix: newInteraction(down(), enter()), wantResult: "skip", }, } @@ -370,14 +375,7 @@ func TestHuhPrompterMarkdownEditor(t *testing.T) { t.Run(tt.name, func(t *testing.T) { p := newTestHuhPrompter() f, result := p.buildMarkdownEditorForm("Body:", tt.blankAllowed) - f.Update(f.Init()) - - var m huh.Model = f - for _, k := range tt.keys { - m = batchUpdate(m.Update(k)) - } - batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) - + runForm(t, f, tt.ix) require.Equal(t, tt.wantResult, *result) }) } @@ -401,47 +399,48 @@ func TestHuhPrompterMultiSelectWithSearch(t *testing.T) { name string defaults []string persistent []string - keys []tea.KeyPressMsg + ix interaction wantResult []string }{ { - name: "defaults are pre-selected and returned on immediate submit", - defaults: []string{"result-a"}, - keys: []tea.KeyPressMsg{ - // Tab past the search input to the multi-select, then submit. - codeKeypress(tea.KeyTab), - codeKeypress(tea.KeyEnter), - }, + name: "defaults are pre-selected and returned on immediate submit", + defaults: []string{"result-a"}, + ix: newInteraction(tab(), enter()), wantResult: []string{"result-a"}, }, { - name: "toggle an option from search results", - keys: []tea.KeyPressMsg{ - codeKeypress(tea.KeyTab), // advance to multi-select - keypress('x'), // toggle first option (result-a) - codeKeypress(tea.KeyEnter), // submit - }, + name: "toggle an option from search results", + ix: newInteraction(tab(), waitForOptions(), toggle(), enter()), wantResult: []string{"result-a"}, }, { name: "toggle multiple options", - keys: []tea.KeyPressMsg{ - codeKeypress(tea.KeyTab), // advance to multi-select - keypress('x'), // toggle result-a - keypress('j'), // move to result-b - keypress('x'), // toggle result-b - codeKeypress(tea.KeyEnter), // submit - }, + ix: newInteraction( + tab(), waitForOptions(), + toggle(), // toggle result-a + down(), // move to result-b + toggle(), // toggle result-b + enter(), + ), wantResult: []string{"result-a", "result-b"}, }, { - name: "no selection returns empty", - keys: []tea.KeyPressMsg{ - codeKeypress(tea.KeyTab), - codeKeypress(tea.KeyEnter), - }, + name: "no selection returns empty", + ix: newInteraction(tab(), enter()), wantResult: []string{}, }, + { + name: "persistent options are shown and selectable", + persistent: []string{"persistent-1"}, + ix: newInteraction( + tab(), waitForOptions(), + down(), // skip result-a + down(), // skip result-b + toggle(), // toggle persistent-1 + enter(), + ), + wantResult: []string{"persistent-1"}, + }, } for _, tt := range tests { @@ -450,22 +449,14 @@ func TestHuhPrompterMultiSelectWithSearch(t *testing.T) { f, result := p.buildMultiSelectWithSearchForm( "Select", "Search", tt.defaults, tt.persistent, staticSearchFunc, ) - doAllUpdates(f, f.Init()) - - for _, k := range tt.keys { - _, cmd := f.Update(k) - doAllUpdates(f, cmd) - } - + runForm(t, f, tt.ix) assert.Equal(t, tt.wantResult, *result) }) } } func TestHuhPrompterMultiSelectWithSearchPersistence(t *testing.T) { - callCount := 0 staticSearchFunc := func(query string) MultiSelectSearchResult { - callCount++ if query == "" { return MultiSelectSearchResult{ Keys: []string{"result-a", "result-b"}, @@ -483,26 +474,110 @@ func TestHuhPrompterMultiSelectWithSearchPersistence(t *testing.T) { f, result := p.buildMultiSelectWithSearchForm( "Select", "Search", nil, nil, staticSearchFunc, ) - doAllUpdates(f, f.Init()) - - steps := []tea.KeyPressMsg{ - // Tab to multi-select, toggle result-a. - codeKeypress(tea.KeyTab), - keypress('x'), - // Shift+Tab back to search input, type "foo". - shiftTabKeypress(), - keypress('f'), keypress('o'), keypress('o'), - // Tab back to multi-select — result-a should still be selected. - codeKeypress(tea.KeyTab), - // Submit. - codeKeypress(tea.KeyEnter), + runForm(t, f, newInteraction( + tab(), waitForOptions(), + toggle(), // toggle result-a + shiftTab(), // back to search input + typeKeys("foo"), // change query + tab(), waitForOptions(), + enter(), // submit — result-a should persist + )) + assert.Equal(t, []string{"result-a"}, *result) + }) + t.Run("empty search results shows no-results placeholder", func(t *testing.T) { + emptySearchFunc := func(query string) MultiSelectSearchResult { + return MultiSelectSearchResult{} } + p := newTestHuhPrompter() + f, result := p.buildMultiSelectWithSearchForm( + "Select", "Search", nil, nil, emptySearchFunc, + ) + // With no results, the "No results" placeholder is shown but nothing + // is selected, so submitting returns empty. + runForm(t, f, newInteraction(tab(), waitForOptions(), toggle(), enter())) + assert.Equal(t, []string{""}, *result) + }) +} - for _, k := range steps { - _, cmd := f.Update(k) - doAllUpdates(f, cmd) - } +func TestHuhPrompterAuthToken(t *testing.T) { + tests := []struct { + name string + ix interaction + wantResult string + }{ + { + name: "accepts token input", + ix: newInteraction(typeKeys("ghp_abc123"), enter()), + wantResult: "ghp_abc123", + }, + { + name: "rejects blank then accepts valid input", + ix: newInteraction(enter(), typeKeys("ghp_valid"), enter()), + wantResult: "ghp_valid", + }, + } - assert.Equal(t, []string{"result-a"}, *result) - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildAuthTokenForm() + runForm(t, f, tt.ix) + require.Equal(t, tt.wantResult, *result) + }) + } +} + +func TestHuhPrompterConfirmDeletion(t *testing.T) { + tests := []struct { + name string + requiredValue string + ix interaction + }{ + { + name: "accepts matching input", + requiredValue: "my-repo", + ix: newInteraction(typeKeys("my-repo"), enter()), + }, + { + name: "rejects wrong input then accepts correct input", + requiredValue: "my-repo", + ix: newInteraction(typeKeys("wrong"), enter(), clearLine(), typeKeys("my-repo"), enter()), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f := p.buildConfirmDeletionForm(tt.requiredValue) + runForm(t, f, tt.ix) + }) + } +} + +func TestHuhPrompterInputHostname(t *testing.T) { + tests := []struct { + name string + ix interaction + wantResult string + }{ + { + name: "accepts valid hostname", + ix: newInteraction(typeKeys("github.example.com"), enter()), + wantResult: "github.example.com", + }, + { + name: "rejects blank then accepts valid hostname", + ix: newInteraction(enter(), typeKeys("github.example.com"), enter()), + wantResult: "github.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildInputHostnameForm() + runForm(t, f, tt.ix) + require.Equal(t, tt.wantResult, *result) + }) + } } diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index a9986804409..dcf0e03f121 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -5,8 +5,8 @@ import ( "slices" "strings" - "github.com/AlecAivazis/survey/v2" "charm.land/huh/v2" + "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/surveyext" From 95a59f4431ed8706484813a58df7d879a80cd4e2 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:44:39 -0600 Subject: [PATCH 14/21] fix(accessible prompter): update test expectations for huh v2 Fix accessible prompter tests that broke with the huh v2 upgrade: - Replace 'Input a number' with 'Enter a number' (huh v2 changed text) - Remove trailing CRLF from ExpectString calls that now fail due to ANSI color codes wrapping the title text - Allow ANSI escape codes in password masking regex assertions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/prompter/accessible_prompter_test.go | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 2c26a16a0aa..372b0e6705f 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -109,7 +109,7 @@ func TestAccessiblePrompter(t *testing.T) { go func() { // Wait for prompt to appear without the invalid default value - _, err := console.ExpectString("Select a number \r\n") + _, err := console.ExpectString("Select a number") require.NoError(t, err) // Select option 2 @@ -128,7 +128,7 @@ func TestAccessiblePrompter(t *testing.T) { go func() { // Wait for prompt to appear - _, err := console.ExpectString("Input a number between 0 and 3:") + _, err := console.ExpectString("Enter a number between 0 and 3:") require.NoError(t, err) // Select options 1 and 2 @@ -207,7 +207,7 @@ func TestAccessiblePrompter(t *testing.T) { go func() { // Wait for prompt to appear without the invalid default values - _, err := console.ExpectString("Select a number \r\n") + _, err := console.ExpectString("Select a number") require.NoError(t, err) // Not selecting anything will fail because there are no defaults. @@ -259,7 +259,7 @@ func TestAccessiblePrompter(t *testing.T) { go func() { // Wait for prompt to appear - _, err := console.ExpectString("Select an option \r\n") + _, err := console.ExpectString("Select an option") require.NoError(t, err) // Select the search option, which will always be the first option @@ -279,7 +279,7 @@ func TestAccessiblePrompter(t *testing.T) { require.NoError(t, err) // Wait for the multiselect prompt to re-appear after search - _, err = console.ExpectString("Select an option \r\n") + _, err = console.ExpectString("Select an option") require.NoError(t, err) // Select the first search result @@ -325,7 +325,7 @@ func TestAccessiblePrompter(t *testing.T) { go func() { // Wait for prompt to appear - _, err := console.ExpectString("Select an option (default: Initial Result Label 1) \r\n") + _, err := console.ExpectString("Select an option (default: Initial Result Label 1)") require.NoError(t, err) // This confirms default selections @@ -379,7 +379,7 @@ func TestAccessiblePrompter(t *testing.T) { go func() { // Wait for prompt to appear - _, err := console.ExpectString("Select an option \r\n") + _, err := console.ExpectString("Select an option") require.NoError(t, err) // Select one of our initial search results @@ -524,7 +524,7 @@ func TestAccessiblePrompter(t *testing.T) { // expected string matches any part of the stream, we have to use an // anchored regexp (i.e., with ^ and $) to make sure the password/token // is not printed at all. - _, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$")) + _, err = console.Expect(expect.RegexpPattern(`^(\x1b\[[\d;]*m)* \r\n\r\n$`)) require.NoError(t, err) }) @@ -615,7 +615,7 @@ func TestAccessiblePrompter(t *testing.T) { // expected string matches any part of the stream, we have to use an // anchored regexp (i.e., with ^ and $) to make sure the password/token // is not printed at all. - _, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$")) + _, err = console.Expect(expect.RegexpPattern(`^(\x1b\[[\d;]*m)* \r\n\r\n$`)) require.NoError(t, err) }) @@ -660,7 +660,7 @@ func TestAccessiblePrompter(t *testing.T) { // expected string matches any part of the stream, we have to use an // anchored regexp (i.e., with ^ and $) to make sure the password/token // is not printed at all. - _, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$")) + _, err = console.Expect(expect.RegexpPattern(`^(\x1b\[[\d;]*m)* \r\n\r\n$`)) require.NoError(t, err) }) From 38e10d5ebfd5b0fe45565ac745abdefec35a3f95 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:14:45 -0600 Subject: [PATCH 15/21] fix(huh prompter): use synchronized accessors to eliminate data race Replace Value() pointer bindings with syncAccessor in MultiSelectWithSearch. huh's OptionsFunc runs in a goroutine while the main event loop writes field values, causing a data race on shared variables. syncAccessor implements huh's Accessor interface with a shared mutex, ensuring all reads and writes are synchronized. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/prompter/huh_prompter.go | 70 +++++++++++++++++++------- internal/prompter/huh_prompter_test.go | 6 +-- 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/internal/prompter/huh_prompter.go b/internal/prompter/huh_prompter.go index 7fbd052adfd..a443f0820f0 100644 --- a/internal/prompter/huh_prompter.go +++ b/internal/prompter/huh_prompter.go @@ -3,6 +3,7 @@ package prompter import ( "fmt" "slices" + "sync" "charm.land/huh/v2" "github.com/cli/cli/v2/internal/ghinstance" @@ -101,12 +102,35 @@ type searchOptionsBinding struct { Selected *[]string } -func (p *huhPrompter) buildMultiSelectWithSearchForm(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) (*huh.Form, *[]string) { - selectedValues := make([]string, len(defaultValues)) - copy(selectedValues, defaultValues) +// syncAccessor is a thread-safe huh.Accessor implementation. +// huh calls OptionsFunc from a goroutine while the main event loop +// writes field values via Set(). This accessor synchronizes both +// paths through the same mutex. +type syncAccessor[T any] struct { + mu *sync.Mutex + value T +} + +func (a *syncAccessor[T]) Get() T { + a.mu.Lock() + defer a.mu.Unlock() + return a.value +} + +func (a *syncAccessor[T]) Set(value T) { + a.mu.Lock() + defer a.mu.Unlock() + a.value = value +} + +func (p *huhPrompter) buildMultiSelectWithSearchForm(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) (*huh.Form, *syncAccessor[[]string]) { + var mu sync.Mutex + + queryAccessor := &syncAccessor[string]{mu: &mu} + selectAccessor := &syncAccessor[[]string]{mu: &mu, value: slices.Clone(defaultValues)} optionKeyLabels := make(map[string]string) - for _, k := range selectedValues { + for _, k := range defaultValues { optionKeyLabels[k] = k } @@ -117,12 +141,25 @@ func (p *huhPrompter) buildMultiSelectWithSearchForm(prompt, searchPrompt string var cachedSearchQuery string var cachedSearchResult MultiSelectSearchResult - buildOptions := func(query string) []huh.Option[string] { - if !searchCacheValid || query != cachedSearchQuery { - cachedSearchResult = searchFunc(query) + buildOptions := func() []huh.Option[string] { + mu.Lock() + query := queryAccessor.value + needsFetch := !searchCacheValid || query != cachedSearchQuery + mu.Unlock() + + if needsFetch { + result := searchFunc(query) + mu.Lock() + cachedSearchResult = result cachedSearchQuery = query searchCacheValid = true + mu.Unlock() } + + mu.Lock() + defer mu.Unlock() + + selectedValues := selectAccessor.value result := cachedSearchResult if result.Err != nil { @@ -181,37 +218,36 @@ func (p *huhPrompter) buildMultiSelectWithSearchForm(prompt, searchPrompt string return formOptions } - var searchQuery string binding := &searchOptionsBinding{ - Query: &searchQuery, - Selected: &selectedValues, + Query: &queryAccessor.value, + Selected: &selectAccessor.value, } form := p.newForm( huh.NewGroup( huh.NewInput(). Title(searchPrompt). - Value(&searchQuery), + Accessor(queryAccessor), huh.NewMultiSelect[string](). Title(prompt). - Options(buildOptions("")...). + Options(buildOptions()...). OptionsFunc(func() []huh.Option[string] { - return buildOptions(searchQuery) + return buildOptions() }, binding). - Value(&selectedValues). + Accessor(selectAccessor). Limit(0), ), ) - return form, &selectedValues + return form, selectAccessor } func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) { - form, result := p.buildMultiSelectWithSearchForm(prompt, searchPrompt, defaultValues, persistentValues, searchFunc) + form, accessor := p.buildMultiSelectWithSearchForm(prompt, searchPrompt, defaultValues, persistentValues, searchFunc) err := form.Run() if err != nil { return nil, err } - return *result, nil + return accessor.Get(), nil } func (p *huhPrompter) buildInputForm(prompt, defaultValue string) (*huh.Form, *string) { diff --git a/internal/prompter/huh_prompter_test.go b/internal/prompter/huh_prompter_test.go index 8f1d55c152d..7b5217d5eb9 100644 --- a/internal/prompter/huh_prompter_test.go +++ b/internal/prompter/huh_prompter_test.go @@ -450,7 +450,7 @@ func TestHuhPrompterMultiSelectWithSearch(t *testing.T) { "Select", "Search", tt.defaults, tt.persistent, staticSearchFunc, ) runForm(t, f, tt.ix) - assert.Equal(t, tt.wantResult, *result) + assert.Equal(t, tt.wantResult, result.Get()) }) } } @@ -482,7 +482,7 @@ func TestHuhPrompterMultiSelectWithSearchPersistence(t *testing.T) { tab(), waitForOptions(), enter(), // submit — result-a should persist )) - assert.Equal(t, []string{"result-a"}, *result) + assert.Equal(t, []string{"result-a"}, result.Get()) }) t.Run("empty search results shows no-results placeholder", func(t *testing.T) { emptySearchFunc := func(query string) MultiSelectSearchResult { @@ -495,7 +495,7 @@ func TestHuhPrompterMultiSelectWithSearchPersistence(t *testing.T) { // With no results, the "No results" placeholder is shown but nothing // is selected, so submitting returns empty. runForm(t, f, newInteraction(tab(), waitForOptions(), toggle(), enter())) - assert.Equal(t, []string{""}, *result) + assert.Equal(t, []string{""}, result.Get()) }) } From f38abbe1ca5c2e5585619718cade063e334c9ea6 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:31:37 -0600 Subject: [PATCH 16/21] feat(huh prompter): add placeholder to search input Add 'Type to search, Ctrl+U to clear' placeholder to the MultiSelectWithSearch search input. Set WithWidth(80) in the test harness to prevent textinput placeholder rendering panics when there is no terminal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/prompter/huh_prompter.go | 1 + internal/prompter/huh_prompter_test.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/prompter/huh_prompter.go b/internal/prompter/huh_prompter.go index a443f0820f0..95bf43fab87 100644 --- a/internal/prompter/huh_prompter.go +++ b/internal/prompter/huh_prompter.go @@ -227,6 +227,7 @@ func (p *huhPrompter) buildMultiSelectWithSearchForm(prompt, searchPrompt string huh.NewGroup( huh.NewInput(). Title(searchPrompt). + Placeholder("Type to search, Ctrl+U to clear"). Accessor(queryAccessor), huh.NewMultiSelect[string](). Title(prompt). diff --git a/internal/prompter/huh_prompter_test.go b/internal/prompter/huh_prompter_test.go index 7b5217d5eb9..e039038ad0d 100644 --- a/internal/prompter/huh_prompter_test.go +++ b/internal/prompter/huh_prompter_test.go @@ -113,7 +113,7 @@ func newTestHuhPrompter() *huhPrompter { func runForm(t *testing.T, f *huh.Form, ix interaction) { t.Helper() r, w := io.Pipe() - f.WithInput(r).WithOutput(io.Discard) + f.WithInput(r).WithOutput(io.Discard).WithWidth(80) errCh := make(chan error, 1) go func() { errCh <- f.Run() }() From cfb2224176d879fb60bf0c0f92bdafaf35bb2c60 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:36:13 -0600 Subject: [PATCH 17/21] refactor(huh prompter): custom Field for MultiSelectWithSearch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the OptionsFunc-based MultiSelectWithSearch with a custom huh Field implementation. huh's OptionsFunc runs in a goroutine, causing data races with selection state and stale cache issues that made selections disappear on toggle or search changes. The custom field (multiSelectSearchField) combines a text input and multi-select list in a single field with full control over the update loop. Search runs asynchronously via tea.Cmd when the user presses Enter, with a themed spinner during loading. Selections are stored in a simple map — no goroutine races, no Eval cache, no syncAccessor. Also adds defensive validation for mismatched Keys/Labels slices from searchFunc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/prompter/huh_prompter.go | 156 +----- internal/prompter/huh_prompter_test.go | 47 +- internal/prompter/multi_select_with_search.go | 450 ++++++++++++++++++ 3 files changed, 498 insertions(+), 155 deletions(-) create mode 100644 internal/prompter/multi_select_with_search.go diff --git a/internal/prompter/huh_prompter.go b/internal/prompter/huh_prompter.go index 95bf43fab87..40a27d5076a 100644 --- a/internal/prompter/huh_prompter.go +++ b/internal/prompter/huh_prompter.go @@ -3,7 +3,6 @@ package prompter import ( "fmt" "slices" - "sync" "charm.land/huh/v2" "github.com/cli/cli/v2/internal/ghinstance" @@ -93,162 +92,19 @@ func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []st return *result, nil } -// searchOptionsBinding is used as the OptionsFunc binding for MultiSelectWithSearch. -// By including both the search query and selected values, the binding hash changes -// whenever either changes. This prevents huh's internal Eval cache from serving -// stale option sets that would overwrite the user's current selections. -type searchOptionsBinding struct { - Query *string - Selected *[]string -} - -// syncAccessor is a thread-safe huh.Accessor implementation. -// huh calls OptionsFunc from a goroutine while the main event loop -// writes field values via Set(). This accessor synchronizes both -// paths through the same mutex. -type syncAccessor[T any] struct { - mu *sync.Mutex - value T -} - -func (a *syncAccessor[T]) Get() T { - a.mu.Lock() - defer a.mu.Unlock() - return a.value -} - -func (a *syncAccessor[T]) Set(value T) { - a.mu.Lock() - defer a.mu.Unlock() - a.value = value -} - -func (p *huhPrompter) buildMultiSelectWithSearchForm(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) (*huh.Form, *syncAccessor[[]string]) { - var mu sync.Mutex - - queryAccessor := &syncAccessor[string]{mu: &mu} - selectAccessor := &syncAccessor[[]string]{mu: &mu, value: slices.Clone(defaultValues)} - - optionKeyLabels := make(map[string]string) - for _, k := range defaultValues { - optionKeyLabels[k] = k - } - - // Cache searchFunc results locally keyed by query string. - // This avoids redundant calls when the OptionsFunc binding hash changes - // due to selection changes (not query changes). - searchCacheValid := false - var cachedSearchQuery string - var cachedSearchResult MultiSelectSearchResult - - buildOptions := func() []huh.Option[string] { - mu.Lock() - query := queryAccessor.value - needsFetch := !searchCacheValid || query != cachedSearchQuery - mu.Unlock() - - if needsFetch { - result := searchFunc(query) - mu.Lock() - cachedSearchResult = result - cachedSearchQuery = query - searchCacheValid = true - mu.Unlock() - } - - mu.Lock() - defer mu.Unlock() - - selectedValues := selectAccessor.value - result := cachedSearchResult - - if result.Err != nil { - return nil - } - for i, k := range result.Keys { - optionKeyLabels[k] = result.Labels[i] - } - - var formOptions []huh.Option[string] - seen := make(map[string]bool) - - // 1. Currently selected values (persisted across searches). - for _, k := range selectedValues { - if seen[k] { - continue - } - seen[k] = true - l := optionKeyLabels[k] - if l == "" { - l = k - } - formOptions = append(formOptions, huh.NewOption(l, k).Selected(true)) - } - - // 2. Search results. - for i, k := range result.Keys { - if seen[k] { - continue - } - seen[k] = true - l := result.Labels[i] - if l == "" { - l = k - } - formOptions = append(formOptions, huh.NewOption(l, k)) - } - - // 3. Persistent options. - for _, k := range persistentValues { - if seen[k] { - continue - } - seen[k] = true - l := optionKeyLabels[k] - if l == "" { - l = k - } - formOptions = append(formOptions, huh.NewOption(l, k)) - } - - if len(formOptions) == 0 { - formOptions = append(formOptions, huh.NewOption("No results", "")) - } - - return formOptions - } - - binding := &searchOptionsBinding{ - Query: &queryAccessor.value, - Selected: &selectAccessor.value, - } - - form := p.newForm( - huh.NewGroup( - huh.NewInput(). - Title(searchPrompt). - Placeholder("Type to search, Ctrl+U to clear"). - Accessor(queryAccessor), - huh.NewMultiSelect[string](). - Title(prompt). - Options(buildOptions()...). - OptionsFunc(func() []huh.Option[string] { - return buildOptions() - }, binding). - Accessor(selectAccessor). - Limit(0), - ), - ) - return form, selectAccessor +func (p *huhPrompter) buildMultiSelectWithSearchForm(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) (*huh.Form, *multiSelectSearchField) { + field := newMultiSelectSearchField(prompt, searchPrompt, defaultValues, persistentValues, searchFunc) + form := p.newForm(huh.NewGroup(field)) + return form, field } func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) { - form, accessor := p.buildMultiSelectWithSearchForm(prompt, searchPrompt, defaultValues, persistentValues, searchFunc) + form, field := p.buildMultiSelectWithSearchForm(prompt, searchPrompt, defaultValues, persistentValues, searchFunc) err := form.Run() if err != nil { return nil, err } - return accessor.Get(), nil + return field.selectedKeys(), nil } func (p *huhPrompter) buildInputForm(prompt, defaultValue string) (*huh.Form, *string) { diff --git a/internal/prompter/huh_prompter_test.go b/internal/prompter/huh_prompter_test.go index e039038ad0d..404867d23f3 100644 --- a/internal/prompter/huh_prompter_test.go +++ b/internal/prompter/huh_prompter_test.go @@ -450,7 +450,7 @@ func TestHuhPrompterMultiSelectWithSearch(t *testing.T) { "Select", "Search", tt.defaults, tt.persistent, staticSearchFunc, ) runForm(t, f, tt.ix) - assert.Equal(t, tt.wantResult, result.Get()) + assert.Equal(t, tt.wantResult, result.selectedKeys()) }) } } @@ -482,7 +482,7 @@ func TestHuhPrompterMultiSelectWithSearchPersistence(t *testing.T) { tab(), waitForOptions(), enter(), // submit — result-a should persist )) - assert.Equal(t, []string{"result-a"}, result.Get()) + assert.Equal(t, []string{"result-a"}, result.selectedKeys()) }) t.Run("empty search results shows no-results placeholder", func(t *testing.T) { emptySearchFunc := func(query string) MultiSelectSearchResult { @@ -492,10 +492,10 @@ func TestHuhPrompterMultiSelectWithSearchPersistence(t *testing.T) { f, result := p.buildMultiSelectWithSearchForm( "Select", "Search", nil, nil, emptySearchFunc, ) - // With no results, the "No results" placeholder is shown but nothing - // is selected, so submitting returns empty. + // With no results, the "No results" message is shown. + // Toggle does nothing, submitting returns empty. runForm(t, f, newInteraction(tab(), waitForOptions(), toggle(), enter())) - assert.Equal(t, []string{""}, result.Get()) + assert.Equal(t, []string{}, result.selectedKeys()) }) } @@ -581,3 +581,40 @@ func TestHuhPrompterInputHostname(t *testing.T) { }) } } + +func TestHuhPrompterMultiSelectWithSearchBackspace(t *testing.T) { + // Simulate real API latency and non-overlapping results. + staticSearchFunc := func(query string) MultiSelectSearchResult { + time.Sleep(100 * time.Millisecond) // simulate API latency + if query == "" { + return MultiSelectSearchResult{ + Keys: []string{"alice", "bob"}, + Labels: []string{"Alice", "Bob"}, + } + } + return MultiSelectSearchResult{ + Keys: []string{"frank", "fiona"}, + Labels: []string{"Frank", "Fiona"}, + } + } + + t.Run("selections persist after backspacing search query", func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildMultiSelectWithSearchForm( + "Select", "Search", nil, nil, staticSearchFunc, + ) + longWait := interactionStep{delay: 300 * time.Millisecond} + runForm(t, f, newInteraction( + tab(), longWait, + toggle(), // toggle alice + shiftTab(), // back to search input + typeKeys("f"), // type "f" + longWait, // wait for API + OptionsFunc + typeKeys("\x7f"), // backspace to "" + longWait, // wait for cache/API + tab(), longWait, + enter(), + )) + assert.Equal(t, []string{"alice"}, result.selectedKeys()) + }) +} diff --git a/internal/prompter/multi_select_with_search.go b/internal/prompter/multi_select_with_search.go new file mode 100644 index 00000000000..cc1302fbdb7 --- /dev/null +++ b/internal/prompter/multi_select_with_search.go @@ -0,0 +1,450 @@ +package prompter + +import ( + "fmt" + "io" + "strings" + "time" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" +) + +// multiSelectSearchField is a custom huh Field that combines a text input +// for searching with a multi-select list. Unlike huh's built-in OptionsFunc, +// search results are loaded synchronously when the user presses Enter in +// the search input, avoiding goroutine races with selection state. +type multiSelectSearchField struct { + // configuration + title string + searchTitle string + placeholder string + searchFunc func(string) MultiSelectSearchResult + + // state + mode msMode // which sub-component has focus + search textinput.Model + cursor int + viewport viewport.Model + loading bool + loadingStart time.Time + spinner spinner.Model + + // options and selections + options []msOption + selected map[string]bool // key → selected (source of truth) + optionLabels map[string]string // key → display label + lastQuery string + defaultValues []string + persistent []string + + // field metadata + key string + err error + focused bool + width int + height int + theme huh.Theme + hasDarkBg bool + position huh.FieldPosition +} + +type msMode int + +const ( + msModeSearch msMode = iota + msModeSelect +) + +type msOption struct { + label string + value string +} + +// msSearchResultMsg carries search results back from the background goroutine. +type msSearchResultMsg struct { + query string + result MultiSelectSearchResult +} + +func newMultiSelectSearchField( + title, searchTitle string, + defaults, persistent []string, + searchFunc func(string) MultiSelectSearchResult, +) *multiSelectSearchField { + ti := textinput.New() + ti.Prompt = "> " + ti.Placeholder = "Type to search" + ti.Focus() + + selected := make(map[string]bool) + for _, k := range defaults { + selected[k] = true + } + + m := &multiSelectSearchField{ + title: title, + searchTitle: searchTitle, + searchFunc: searchFunc, + mode: msModeSearch, + search: ti, + selected: selected, + optionLabels: make(map[string]string), + defaultValues: defaults, + persistent: persistent, + height: 10, + spinner: spinner.New(spinner.WithSpinner(spinner.Line)), + } + + // Load initial results synchronously (form hasn't started yet). + m.applySearchResult("", m.searchFunc("")) + + return m +} + +// startSearch launches an async search and returns a tea.Cmd that will +// deliver the result via msSearchResultMsg. +func (m *multiSelectSearchField) startSearch(query string) tea.Cmd { + m.loading = true + m.loadingStart = time.Now() + searchFunc := m.searchFunc + return tea.Batch( + func() tea.Msg { + return msSearchResultMsg{query: query, result: searchFunc(query)} + }, + m.spinner.Tick, + ) +} + +// applySearchResult processes a completed search and rebuilds the option list. +func (m *multiSelectSearchField) applySearchResult(query string, result MultiSelectSearchResult) { + m.loading = false + m.lastQuery = query + if result.Err != nil { + m.err = result.Err + return + } + if len(result.Keys) != len(result.Labels) { + m.err = fmt.Errorf("search returned mismatched keys and labels: %d keys, %d labels", len(result.Keys), len(result.Labels)) + return + } + + for i, k := range result.Keys { + m.optionLabels[k] = result.Labels[i] + } + + // Build option list: selected items first, then results, then persistent. + var options []msOption + seen := make(map[string]bool) + + // 1. Currently selected items. + for _, k := range m.selectedKeys() { + if seen[k] { + continue + } + seen[k] = true + options = append(options, msOption{label: m.label(k), value: k}) + } + + // 2. Search results. + for i, k := range result.Keys { + if seen[k] { + continue + } + seen[k] = true + l := result.Labels[i] + if l == "" { + l = k + } + options = append(options, msOption{label: l, value: k}) + } + + // 3. Persistent options. + for _, k := range m.persistent { + if seen[k] { + continue + } + seen[k] = true + options = append(options, msOption{label: m.label(k), value: k}) + } + + m.options = options + m.cursor = 0 + m.err = nil +} + +func (m *multiSelectSearchField) selectedKeys() []string { + keys := make([]string, 0) + // Maintain order: defaults first, then any added during this session. + seen := make(map[string]bool) + for _, k := range m.defaultValues { + if m.selected[k] && !seen[k] { + keys = append(keys, k) + seen[k] = true + } + } + for _, o := range m.options { + if m.selected[o.value] && !seen[o.value] { + keys = append(keys, o.value) + seen[o.value] = true + } + } + return keys +} + +func (m *multiSelectSearchField) label(key string) string { + if l, ok := m.optionLabels[key]; ok && l != "" { + return l + } + return key +} + +// --- huh.Field interface --- + +func (m *multiSelectSearchField) Init() tea.Cmd { + return nil +} + +func (m *multiSelectSearchField) Update(msg tea.Msg) (huh.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + m.hasDarkBg = msg.IsDark() + + case msSearchResultMsg: + m.applySearchResult(msg.query, msg.result) + m.mode = msModeSelect + m.search.Blur() + return m, nil + + case spinner.TickMsg: + if !m.loading { + break + } + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case tea.KeyPressMsg: + if m.loading { + return m, nil // ignore keys while loading + } + switch m.mode { + case msModeSearch: + return m.updateSearch(msg) + case msModeSelect: + return m.updateSelect(msg) + } + } + return m, nil +} + +func (m *multiSelectSearchField) updateSearch(msg tea.KeyPressMsg) (huh.Model, tea.Cmd) { + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("enter", "tab"))): + query := m.search.Value() + if query == m.lastQuery { + // Query unchanged — just switch to select mode. + m.mode = msModeSelect + m.search.Blur() + return m, nil + } + // New query — search in background with spinner. + return m, m.startSearch(query) + + case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))): + return m, huh.PrevField + + default: + var cmd tea.Cmd + m.search, cmd = m.search.Update(msg) + return m, cmd + } +} + +func (m *multiSelectSearchField) updateSelect(msg tea.KeyPressMsg) (huh.Model, tea.Cmd) { + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))): + // Back to search mode. + m.mode = msModeSearch + m.search.Focus() + return m, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + return m, huh.NextField + + case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))): + if m.cursor > 0 { + m.cursor-- + } + return m, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))): + if m.cursor < len(m.options)-1 { + m.cursor++ + } + return m, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("space", "x"))): + if len(m.options) > 0 { + k := m.options[m.cursor].value + m.selected[k] = !m.selected[k] + if !m.selected[k] { + delete(m.selected, k) + } + } + return m, nil + } + + return m, nil +} + +func (m *multiSelectSearchField) View() string { + styles := m.activeStyles() + var sb strings.Builder + + // Title. + if m.title != "" { + sb.WriteString(styles.Title.Render(m.title)) + sb.WriteString("\n") + } + + // Search input. + if m.searchTitle != "" { + sb.WriteString(styles.Description.Render(m.searchTitle)) + sb.WriteString("\n") + } + sb.WriteString(m.search.View()) + sb.WriteString("\n") + + // Options list. + if m.loading { + m.spinner.Style = styles.MultiSelectSelector.UnsetString() + sb.WriteString(m.spinner.View() + " Loading...") + sb.WriteString("\n") + } else if len(m.options) == 0 { + sb.WriteString(styles.UnselectedOption.Render(" No results")) + sb.WriteString("\n") + } else { + for i, o := range m.options { + cursor := m.mode == msModeSelect && i == m.cursor + isSelected := m.selected[o.value] + sb.WriteString(m.renderOption(o, cursor, isSelected)) + sb.WriteString("\n") + } + } + + return styles.Base.Width(m.width).Height(m.height).Render(sb.String()) +} + +func (m *multiSelectSearchField) renderOption(o msOption, cursor, selected bool) string { + styles := m.activeStyles() + + var parts []string + if cursor { + parts = append(parts, styles.MultiSelectSelector.String()) + } else { + parts = append(parts, strings.Repeat(" ", lipgloss.Width(styles.MultiSelectSelector.String()))) + } + if selected { + parts = append(parts, styles.SelectedPrefix.String()) + parts = append(parts, styles.SelectedOption.Render(o.label)) + } else { + parts = append(parts, styles.UnselectedPrefix.String()) + parts = append(parts, styles.UnselectedOption.Render(o.label)) + } + return lipgloss.JoinHorizontal(lipgloss.Left, parts...) +} + +func (m *multiSelectSearchField) activeStyles() *huh.FieldStyles { + theme := m.theme + if theme == nil { + theme = huh.ThemeFunc(huh.ThemeCharm) + } + if m.focused { + return &theme.Theme(m.hasDarkBg).Focused + } + return &theme.Theme(m.hasDarkBg).Blurred +} + +func (m *multiSelectSearchField) Focus() tea.Cmd { + m.focused = true + if m.mode == msModeSearch { + return m.search.Focus() + } + return nil +} + +func (m *multiSelectSearchField) Blur() tea.Cmd { + m.focused = false + m.search.Blur() + return nil +} + +func (m *multiSelectSearchField) Error() error { return m.err } +func (*multiSelectSearchField) Skip() bool { return false } +func (*multiSelectSearchField) Zoom() bool { return false } +func (m *multiSelectSearchField) GetKey() string { return m.key } +func (m *multiSelectSearchField) GetValue() any { return m.selectedKeys() } +func (m *multiSelectSearchField) Run() error { return huh.Run(m) } +func (m *multiSelectSearchField) RunAccessible(w io.Writer, r io.Reader) error { + _, _ = fmt.Fprintln(w, "MultiSelectWithSearch accessible mode not implemented") + return nil +} + +func (m *multiSelectSearchField) KeyBinds() []key.Binding { + if m.mode == msModeSearch { + return []key.Binding{ + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "search")), + key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), + } + } + return []key.Binding{ + key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "toggle")), + key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")), + key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")), + key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "search")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), + } +} + +func (m *multiSelectSearchField) WithTheme(theme huh.Theme) huh.Field { + if m.theme != nil { + return m + } + m.theme = theme + + styles := theme.Theme(m.hasDarkBg) + st := m.search.Styles() + st.Cursor.Color = styles.Focused.TextInput.Cursor.GetForeground() + st.Focused.Prompt = styles.Focused.TextInput.Prompt + st.Focused.Text = styles.Focused.TextInput.Text + st.Focused.Placeholder = styles.Focused.TextInput.Placeholder + m.search.SetStyles(st) + + return m +} + +func (m *multiSelectSearchField) WithKeyMap(k *huh.KeyMap) huh.Field { + return m +} + +func (m *multiSelectSearchField) WithWidth(width int) huh.Field { + m.width = width + m.search.SetWidth(width) + return m +} + +func (m *multiSelectSearchField) WithHeight(height int) huh.Field { + m.height = height + return m +} + +func (m *multiSelectSearchField) WithPosition(p huh.FieldPosition) huh.Field { + m.position = p + return m +} From 13e47d00787fde0181ed54cafd69209a365201df Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:40:06 -0600 Subject: [PATCH 18/21] feat(huh prompter): clear search input after submitting query Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/prompter/multi_select_with_search.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/prompter/multi_select_with_search.go b/internal/prompter/multi_select_with_search.go index cc1302fbdb7..29308a8edf1 100644 --- a/internal/prompter/multi_select_with_search.go +++ b/internal/prompter/multi_select_with_search.go @@ -253,7 +253,8 @@ func (m *multiSelectSearchField) updateSearch(msg tea.KeyPressMsg) (huh.Model, t m.search.Blur() return m, nil } - // New query — search in background with spinner. + // New query — clear input and search in background with spinner. + m.search.SetValue("") return m, m.startSearch(query) case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))): From f92fab612417beef4ba267466ac359d6c0cc1c52 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:45:56 -0600 Subject: [PATCH 19/21] go mod tidy --- go.mod | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 98195340d99..99413bdefa1 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,10 @@ module github.com/cli/cli/v2 go 1.26.1 require ( + charm.land/bubbles/v2 v2.0.0 + charm.land/bubbletea/v2 v2.0.2 charm.land/huh/v2 v2.0.3 + charm.land/lipgloss/v2 v2.0.2 github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc v1.0.0 github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 @@ -61,9 +64,6 @@ require ( ) require ( - charm.land/bubbles/v2 v2.0.0 // indirect - charm.land/bubbletea/v2 v2.0.2 // indirect - charm.land/lipgloss/v2 v2.0.2 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect From 84a3ba83e469e758280873d1c0c892bfc816487a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:51:40 -0600 Subject: [PATCH 20/21] fix(huh prompter): remove unused fields and imports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/prompter/multi_select_with_search.go | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/internal/prompter/multi_select_with_search.go b/internal/prompter/multi_select_with_search.go index 29308a8edf1..5eeb34d4507 100644 --- a/internal/prompter/multi_select_with_search.go +++ b/internal/prompter/multi_select_with_search.go @@ -4,12 +4,10 @@ import ( "fmt" "io" "strings" - "time" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textinput" - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/huh/v2" "charm.land/lipgloss/v2" @@ -23,17 +21,14 @@ type multiSelectSearchField struct { // configuration title string searchTitle string - placeholder string searchFunc func(string) MultiSelectSearchResult // state - mode msMode // which sub-component has focus - search textinput.Model - cursor int - viewport viewport.Model - loading bool - loadingStart time.Time - spinner spinner.Model + mode msMode // which sub-component has focus + search textinput.Model + cursor int + loading bool + spinner spinner.Model // options and selections options []msOption @@ -111,7 +106,6 @@ func newMultiSelectSearchField( // deliver the result via msSearchResultMsg. func (m *multiSelectSearchField) startSearch(query string) tea.Cmd { m.loading = true - m.loadingStart = time.Now() searchFunc := m.searchFunc return tea.Batch( func() tea.Msg { From cb2b50576ff42e7a51483c8384572d270508838b Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 26 Mar 2026 14:07:40 +0100 Subject: [PATCH 21/21] Ensure huh prompter cleans up --- internal/prompter/huh_prompter.go | 34 ++++++++++++++++++-------- internal/prompter/huh_prompter_test.go | 23 +++++++++++++++++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/internal/prompter/huh_prompter.go b/internal/prompter/huh_prompter.go index 40a27d5076a..c6bec9fb35c 100644 --- a/internal/prompter/huh_prompter.go +++ b/internal/prompter/huh_prompter.go @@ -1,10 +1,12 @@ package prompter import ( + "errors" "fmt" "slices" "charm.land/huh/v2" + "github.com/AlecAivazis/survey/v2/terminal" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/surveyext" ghPrompter "github.com/cli/go-gh/v2/pkg/prompter" @@ -24,6 +26,18 @@ func (p *huhPrompter) newForm(groups ...*huh.Group) *huh.Form { WithOutput(p.stdout) } +func (p *huhPrompter) runForm(form *huh.Form) error { + err := form.Run() + if errors.Is(err, huh.ErrUserAborted) { + // TODO(huh-prompter-improvements) + // It's unfortunate that we take a dependency on survey/terminal here, but our clean cancellation logic + // in cmd.go expects it. Better would be to have a prompter.Cancelled sentinel error, but then we need to + // go and change non-experimental code to do so, and I don't think we should take that on right now. + return terminal.InterruptErr + } + return err +} + func (p *huhPrompter) buildSelectForm(prompt, defaultValue string, options []string) (*huh.Form, *int) { var result int @@ -52,7 +66,7 @@ func (p *huhPrompter) buildSelectForm(prompt, defaultValue string, options []str func (p *huhPrompter) Select(prompt, defaultValue string, options []string) (int, error) { form, result := p.buildSelectForm(prompt, defaultValue, options) - err := form.Run() + err := p.runForm(form) return *result, err } @@ -85,7 +99,7 @@ func (p *huhPrompter) buildMultiSelectForm(prompt string, defaults []string, opt func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { form, result := p.buildMultiSelectForm(prompt, defaults, options) - err := form.Run() + err := p.runForm(form) if err != nil { return nil, err } @@ -100,7 +114,7 @@ func (p *huhPrompter) buildMultiSelectWithSearchForm(prompt, searchPrompt string func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) { form, field := p.buildMultiSelectWithSearchForm(prompt, searchPrompt, defaultValues, persistentValues, searchFunc) - err := form.Run() + err := p.runForm(form) if err != nil { return nil, err } @@ -121,7 +135,7 @@ func (p *huhPrompter) buildInputForm(prompt, defaultValue string) (*huh.Form, *s func (p *huhPrompter) Input(prompt, defaultValue string) (string, error) { form, result := p.buildInputForm(prompt, defaultValue) - err := form.Run() + err := p.runForm(form) return *result, err } @@ -140,7 +154,7 @@ func (p *huhPrompter) buildPasswordForm(prompt string) (*huh.Form, *string) { func (p *huhPrompter) Password(prompt string) (string, error) { form, result := p.buildPasswordForm(prompt) - err := form.Run() + err := p.runForm(form) if err != nil { return "", err } @@ -161,7 +175,7 @@ func (p *huhPrompter) buildConfirmForm(prompt string, defaultValue bool) (*huh.F func (p *huhPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { form, result := p.buildConfirmForm(prompt, defaultValue) - err := form.Run() + err := p.runForm(form) if err != nil { return false, err } @@ -189,7 +203,7 @@ func (p *huhPrompter) buildAuthTokenForm() (*huh.Form, *string) { func (p *huhPrompter) AuthToken() (string, error) { form, result := p.buildAuthTokenForm() - err := form.Run() + err := p.runForm(form) return *result, err } @@ -209,7 +223,7 @@ func (p *huhPrompter) buildConfirmDeletionForm(requiredValue string) *huh.Form { } func (p *huhPrompter) ConfirmDeletion(requiredValue string) error { - return p.buildConfirmDeletionForm(requiredValue).Run() + return p.runForm(p.buildConfirmDeletionForm(requiredValue)) } func (p *huhPrompter) buildInputHostnameForm() (*huh.Form, *string) { @@ -227,7 +241,7 @@ func (p *huhPrompter) buildInputHostnameForm() (*huh.Form, *string) { func (p *huhPrompter) InputHostname() (string, error) { form, result := p.buildInputHostnameForm() - err := form.Run() + err := p.runForm(form) if err != nil { return "", err } @@ -258,7 +272,7 @@ func (p *huhPrompter) buildMarkdownEditorForm(prompt string, blankAllowed bool) func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { form, result := p.buildMarkdownEditorForm(prompt, blankAllowed) - err := form.Run() + err := p.runForm(form) if err != nil { return "", err } diff --git a/internal/prompter/huh_prompter_test.go b/internal/prompter/huh_prompter_test.go index 404867d23f3..30ec22551b1 100644 --- a/internal/prompter/huh_prompter_test.go +++ b/internal/prompter/huh_prompter_test.go @@ -6,6 +6,7 @@ import ( "time" "charm.land/huh/v2" + "github.com/AlecAivazis/survey/v2/terminal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -618,3 +619,25 @@ func TestHuhPrompterMultiSelectWithSearchBackspace(t *testing.T) { assert.Equal(t, []string{"alice"}, result.selectedKeys()) }) } + +func TestRunFormTranslatesErrUserAborted(t *testing.T) { + p := newTestHuhPrompter() + form, _ := p.buildSelectForm("Pick one:", "", []string{"a", "b", "c"}) + + r, w := io.Pipe() + form.WithInput(r).WithOutput(io.Discard).WithWidth(80) + + errCh := make(chan error, 1) + go func() { errCh <- p.runForm(form) }() + + // Send Ctrl+C to trigger huh.ErrUserAborted + _, err := w.Write([]byte{0x03}) + require.NoError(t, err) + + select { + case err := <-errCh: + assert.ErrorIs(t, err, terminal.InterruptErr, "expected huh.ErrUserAborted to be translated to terminal.InterruptErr") + case <-time.After(5 * time.Second): + t.Fatal("runForm did not complete in time") + } +}