From 361b88650f073e976f40f318bca00728ad1a38d0 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 30 Sep 2026 09:45:33 -0400 Subject: [PATCH 1/4] Open a yes/no prompt on its default, so Enter picks it tui.Confirm took a default but never used it: the answer started as false, so every prompt opened on No, and pressing Enter answered No even where the question meant Yes. `basecamp setup` asks "Set up Basecamp for your coding agents?" and "Set a default project?" that way, and guided `connect setup` asks "Work in all of your agent's projects?"; pressing Enter there ended with no projects chosen. The prompt now opens on the default it's given. Prompts that default to No, like the skill overwrite, behave as before. --- internal/tui/forms.go | 27 +++++++++++++++++++-------- internal/tui/forms_test.go | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/internal/tui/forms.go b/internal/tui/forms.go index 6ff106e6f..5f820ad21 100644 --- a/internal/tui/forms.go +++ b/internal/tui/forms.go @@ -95,21 +95,32 @@ 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. func Confirm(message string, defaultValue bool) (bool, error) { - var result bool - field := huh.NewConfirm(). - Title(message). - Affirmative("Yes"). - Negative("No"). - Value(&result) + result := defaultValue + 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..67dfee1d8 100644 --- a/internal/tui/forms_test.go +++ b/internal/tui/forms_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/charmbracelet/huh" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -279,3 +280,18 @@ 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. 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. +func TestConfirmAnsweredWithEnterIsItsDefault(t *testing.T) { + prev := runConfirmForm + t.Cleanup(func() { runConfirmForm = prev }) + runConfirmForm = func(...huh.Field) error { return nil } // Enter: submit as opened + + for _, def := range []bool{true, false} { + got, err := Confirm("Go ahead?", def) + require.NoError(t, err) + assert.Equal(t, def, got, "default %v", def) + } +} From 0cb67a48790605150b87efffc44eb951e68a84fb Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 30 Sep 2026 10:13:25 -0400 Subject: [PATCH 2/4] Don't let end of input say Yes in accessible mode With TERM=dumb, huh runs forms in accessible mode, which answers end of input (Ctrl+D) with the prompt's default and can't tell it from Enter. Now that a prompt opens on its default, a question that defaults to Yes, like "Set up Basecamp for your coding agents?", would take Ctrl+D as consent. In accessible mode the prompt opens on No and a Yes must be typed. The test now answers through the field's own input handling, typed answers against the default included, so a prompt that isn't bound to Confirm's result fails it. --- internal/tui/forms.go | 6 ++++- internal/tui/forms_test.go | 52 +++++++++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/internal/tui/forms.go b/internal/tui/forms.go index 5f820ad21..71fc582fb 100644 --- a/internal/tui/forms.go +++ b/internal/tui/forms.go @@ -97,8 +97,12 @@ func runFields(fields ...huh.Field) error { // 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) { - result := defaultValue + result := defaultValue && os.Getenv("TERM") != "dumb" field := confirmField(message, &result) if err := runConfirmForm(field); err != nil { diff --git a/internal/tui/forms_test.go b/internal/tui/forms_test.go index 67dfee1d8..420926091 100644 --- a/internal/tui/forms_test.go +++ b/internal/tui/forms_test.go @@ -5,8 +5,10 @@ import ( "go/ast" "go/parser" "go/token" + "io" "os" "runtime" + "strings" "testing" "time" @@ -281,17 +283,49 @@ func TestPromptFloorCovers(t *testing.T) { } } -// A prompt opens on its default, so pressing Enter picks it. 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. +// 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(...huh.Field) error { return nil } // Enter: submit as opened - - for _, def := range []bool{true, false} { - got, err := Confirm("Go ahead?", def) - require.NoError(t, err) - assert.Equal(t, def, got, "default %v", def) + runConfirmForm = func(fields ...huh.Field) error { + require.Len(t, fields, 1) + return fields[0].(*huh.Confirm).RunAccessible(io.Discard, strings.NewReader(typed)) } } From 9aaf0bf7a633ae2ca7c26571d80ef8c7b6840540 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 30 Sep 2026 11:16:24 -0400 Subject: [PATCH 3/4] Test the interactive prompt key by key The earlier tests answer through huh's text-input path. This one drives Confirm's field with the key map runForm gives every form, as the interactive prompt does: Enter submits what the prompt opened on, y and n answer, and the arrows toggle. It sets TERM itself, so it means the same on a CI runner whose terminal is dumb. bubbletea v1 was already in the module graph; the test's import makes it a direct requirement. --- go.mod | 2 +- internal/tui/forms_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) 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_test.go b/internal/tui/forms_test.go index 420926091..81b6e1764 100644 --- a/internal/tui/forms_test.go +++ b/internal/tui/forms_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -329,3 +330,31 @@ func answerConfirmWith(t *testing.T, typed string) { 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() + for _, k := range tc.keys { + field.Update(k) + } + assert.Equal(t, tc.want, result, "default %v, keys %v", tc.def, tc.keys) + } +} From 37b6db3d2057f98e5de73ea25a4238d5d1f25d57 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 30 Sep 2026 11:27:28 -0400 Subject: [PATCH 4/4] Check that answering the prompt from the keyboard moves on Enter leaves a prompt's value as it opened, so a test asserting only the value passed even with Enter unbound. The keyboard test now also checks that the last key of each answer submits the field. --- internal/tui/forms_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/tui/forms_test.go b/internal/tui/forms_test.go index 81b6e1764..500458653 100644 --- a/internal/tui/forms_test.go +++ b/internal/tui/forms_test.go @@ -352,9 +352,13 @@ func TestConfirmFieldAnswersFromTheKeyboard(t *testing.T) { 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 { - field.Update(k) + _, 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) } }