diff --git a/go.mod b/go.mod index 14709182e..4bb86991e 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d github.com/basecamp/surfguard/go v0.1.0 github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/x/ansi v0.11.8 @@ -44,7 +45,6 @@ require ( github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect diff --git a/internal/tui/forms.go b/internal/tui/forms.go index 6ff106e6f..71fc582fb 100644 --- a/internal/tui/forms.go +++ b/internal/tui/forms.go @@ -95,21 +95,36 @@ func runFields(fields ...huh.Field) error { return runForm(huh.NewForm(huh.NewGroup(fields...))) } -// Confirm shows a yes/no confirmation prompt. Escape or Ctrl+C cancels. +// Confirm shows a yes/no confirmation prompt, opening on defaultValue, so +// Enter alone answers it. Escape or Ctrl+C cancels. +// +// Except in huh's accessible mode (TERM=dumb), which answers end of input +// with the prompt's default and can't tell it from Enter: there a Yes default +// would let Ctrl+D consent, so the prompt opens on No and Yes must be typed. func Confirm(message string, defaultValue bool) (bool, error) { - var result bool - field := huh.NewConfirm(). - Title(message). - Affirmative("Yes"). - Negative("No"). - Value(&result) + result := defaultValue && os.Getenv("TERM") != "dumb" + field := confirmField(message, &result) - if err := runFields(field); err != nil { + if err := runConfirmForm(field); err != nil { return defaultValue, err } return result, nil } +// runConfirmForm runs Confirm's form. A variable so a test can answer it as +// Enter does: by submitting whatever the prompt opened on. +var runConfirmForm = runFields + +// confirmField is Confirm's field. The answer highlighted when it opens is +// whatever result holds. +func confirmField(message string, result *bool) *huh.Confirm { + return huh.NewConfirm(). + Title(message). + Affirmative("Yes"). + Negative("No"). + Value(result) +} + // ConfirmDangerous shows a confirmation prompt for dangerous actions. Escape or Ctrl+C cancels. func ConfirmDangerous(message string) (bool, error) { var result bool diff --git a/internal/tui/forms_test.go b/internal/tui/forms_test.go index 13f51e324..500458653 100644 --- a/internal/tui/forms_test.go +++ b/internal/tui/forms_test.go @@ -5,11 +5,15 @@ import ( "go/ast" "go/parser" "go/token" + "io" "os" "runtime" + "strings" "testing" "time" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -279,3 +283,82 @@ func TestPromptFloorCovers(t *testing.T) { "forms.go exports %s but promptFloors() does not exercise its non-interactive floor", fn.Name.Name) } } + +// A prompt opens on its default, so pressing Enter picks it, and an answer +// typed against the default wins. It used to open on No whatever the default, +// and a guided setup that asked "Work in all of your agent's projects?" +// (default Yes) ended with none chosen. The answers go through the field's own +// input handling, so the prompt must be bound to what Confirm returns. +func TestConfirmAnsweredWithEnterIsItsDefault(t *testing.T) { + t.Setenv("TERM", "xterm-256color") + for _, tc := range []struct { + def bool + typed string + want bool + }{ + {def: true, typed: "\n", want: true}, + {def: false, typed: "\n", want: false}, + {def: true, typed: "n\n", want: false}, + {def: false, typed: "y\n", want: true}, + } { + answerConfirmWith(t, tc.typed) + got, err := Confirm("Go ahead?", tc.def) + require.NoError(t, err) + assert.Equal(t, tc.want, got, "default %v, typed %q", tc.def, tc.typed) + } +} + +// Accessible mode answers end of input with the default, so Ctrl+D must not +// consent to a question that defaults to Yes. +func TestConfirmInAccessibleModeNeverTakesYesFromEndOfInput(t *testing.T) { + t.Setenv("TERM", "dumb") + answerConfirmWith(t, "") // end of input, no answer + + got, err := Confirm("Set up Basecamp for your coding agents?", true) + require.NoError(t, err) + assert.False(t, got) +} + +// answerConfirmWith makes Confirm's form read typed from its field's own +// accessible input handling, as a person at a dumb terminal would type it. +func answerConfirmWith(t *testing.T, typed string) { + t.Helper() + prev := runConfirmForm + t.Cleanup(func() { runConfirmForm = prev }) + runConfirmForm = func(fields ...huh.Field) error { + require.Len(t, fields, 1) + return fields[0].(*huh.Confirm).RunAccessible(io.Discard, strings.NewReader(typed)) + } +} + +// The interactive prompt, key by key: Enter submits whatever the prompt +// opened on, y and n answer, and the arrows toggle. These are huh's key +// bindings, as runForm sets them, working on Confirm's own field. +func TestConfirmFieldAnswersFromTheKeyboard(t *testing.T) { + t.Setenv("TERM", "xterm-256color") + enter := tea.KeyMsg{Type: tea.KeyEnter} + for _, tc := range []struct { + def bool + keys []tea.KeyMsg + want bool + }{ + {def: true, keys: []tea.KeyMsg{enter}, want: true}, + {def: false, keys: []tea.KeyMsg{enter}, want: false}, + {def: true, keys: []tea.KeyMsg{{Type: tea.KeyRunes, Runes: []rune("n")}}, want: false}, + {def: false, keys: []tea.KeyMsg{{Type: tea.KeyRunes, Runes: []rune("y")}}, want: true}, + {def: true, keys: []tea.KeyMsg{{Type: tea.KeyLeft}, enter}, want: false}, + } { + result := tc.def + field := confirmField("Go ahead?", &result) + field.WithKeyMap(escKeyMap()) // the key map runForm gives every form + field.Focus() + var last tea.Cmd + for _, k := range tc.keys { + _, last = field.Update(k) + } + assert.Equal(t, tc.want, result, "default %v, keys %v", tc.def, tc.keys) + // Every sequence ends on a key that answers, so the prompt moves on. + require.NotNil(t, last, "keys %v", tc.keys) + assert.Equal(t, huh.NextField(), last(), "keys %v", tc.keys) + } +}