Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
31 changes: 23 additions & 8 deletions internal/tui/forms.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions internal/tui/forms_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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))
Comment thread
robzolkos marked this conversation as resolved.
}
}

// 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)
}
}
Loading