From c44f70e0fa28c6fbeca832f807ccb1ddd5b728d8 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 11 May 2026 15:11:06 +0200 Subject: [PATCH 1/6] cmdio: replace promptui with a bubbletea-backed Prompt and Select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the manifoldco/promptui (and transitive chzyer/readline) dependency in favor of hand-rolled bubbletea models that reproduce promptui's rendering and key handling. RunPrompt and Secret share a single-line editor that pins the cursor-block visuals, mask, validate (with inline "✗" glyph and a red ">> " line surfaced after a failed Enter), HideEntered post-submit clearing, Ctrl+B/F as left/right, Ctrl+H as backspace, Ctrl+J as Enter, and Delete/Ctrl+D as EOF. RunSelect, Select, and SelectOrdered share a templated list with viewport scroll and ↑/↓ gutters, a search filter with vim-style nav and "/" toggle, Ctrl+P/N as item-up/down, Ctrl+B/F (and the left/right arrows) as page-up/down, default Active / Inactive / Selected templates that match promptui's defaults, and an empty final frame when HideSelected is set. Both primitives refuse to draw on a non-interactive terminal so callers no longer have to gate on IsPromptSupported themselves; SelectOrdered drops its now-redundant guard. SelectOptions.Items is now validated at construction and normalized to []any so the render path doesn't reflect on every row. The behavior pinned above is verified against the cmdiotest pty- and vt10x-based baseline suite developed in #5231. That suite is kept on a separate branch — and not merged here — because it pulls in test-only dependencies (creack/pty, hinshun/vt10x, x/term) that we'd prefer not to land in the main module. Co-authored-by: Isaac --- NOTICE | 4 - go.mod | 2 - go.sum | 9 - libs/cmdio/color.go | 2 + libs/cmdio/io.go | 41 ++-- libs/cmdio/prompt.go | 234 +++++++++++++++++++-- libs/cmdio/select.go | 477 ++++++++++++++++++++++++++++++++++++++++--- 7 files changed, 694 insertions(+), 75 deletions(-) diff --git a/NOTICE b/NOTICE index 2c90b58f2d..3ff1cc6243 100644 --- a/NOTICE +++ b/NOTICE @@ -66,10 +66,6 @@ google/uuid - https://github.com/google/uuid Copyright (c) 2009,2014 Google Inc. All rights reserved. License - https://github.com/google/uuid/blob/master/LICENSE -manifoldco/promptui - https://github.com/manifoldco/promptui -Copyright (c) 2017, Arigato Machine Inc. All rights reserved. -License - https://github.com/manifoldco/promptui/blob/master/LICENSE.md - hexops/gotextdiff - https://github.com/hexops/gotextdiff Copyright (c) 2009 The Go Authors. All rights reserved. License - https://github.com/hexops/gotextdiff/blob/main/LICENSE diff --git a/go.mod b/go.mod index 29248e31c7..7779e8a1b8 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,6 @@ require ( github.com/hashicorp/terraform-json v0.27.2 // MPL-2.0 github.com/hexops/gotextdiff v1.0.3 // BSD-3-Clause github.com/jackc/pgx/v5 v5.9.2 // MIT - github.com/manifoldco/promptui v0.9.0 // BSD-3-Clause github.com/mattn/go-isatty v0.0.21 // MIT github.com/palantir/pkg/yamlpatch v1.5.0 // BSD-3-Clause github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // BSD-2-Clause @@ -58,7 +57,6 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect diff --git a/go.sum b/go.sum index b9b3843e66..5f8ef429f8 100644 --- a/go.sum +++ b/go.sum @@ -56,12 +56,6 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 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/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= @@ -162,8 +156,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= -github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= @@ -261,7 +253,6 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= diff --git a/libs/cmdio/color.go b/libs/cmdio/color.go index 4066b30f75..ffece82e47 100644 --- a/libs/cmdio/color.go +++ b/libs/cmdio/color.go @@ -11,7 +11,9 @@ import ( const ( ansiReset = "\x1b[0m" ansiBold = "\x1b[1m" + ansiFaint = "\x1b[2m" ansiItalic = "\x1b[3m" + ansiUnderline = "\x1b[4m" ansiRed = "\x1b[31m" ansiGreen = "\x1b[32m" ansiYellow = "\x1b[33m" diff --git a/libs/cmdio/io.go b/libs/cmdio/io.go index c8c61e47f4..3f84be5382 100644 --- a/libs/cmdio/io.go +++ b/libs/cmdio/io.go @@ -2,8 +2,8 @@ package cmdio import ( "context" + "errors" "io" - "os" "strings" "sync" @@ -11,6 +11,24 @@ import ( "github.com/databricks/cli/libs/flags" ) +// errCtrlC is returned when the user cancels a TUI prompt with Ctrl+C. The +// "^C" string matches the historical wire format; goldens depend on it. +var errCtrlC = errors.New("^C") + +// runTUI runs a tea.Program through cmdIO's tea program slot so spinners and +// pagers can't fight a prompt for the terminal. Blocks until the model quits. +func (c *cmdIO) runTUI(m tea.Model) (tea.Model, error) { + p := tea.NewProgram(m, + tea.WithInput(c.in), + tea.WithOutput(c.err), + // Ctrl+C is delivered as a key event so the model can return errCtrlC. + tea.WithoutSignalHandler(), + ) + c.acquireTeaProgram(p) + defer c.releaseTeaProgram() + return p.Run() +} + // cmdIO is the private instance, that is not supposed to be accessed // outside of `cmdio` package. Use the public package-level functions // to access the inner state. @@ -69,27 +87,6 @@ func GetInteractiveMode(ctx context.Context) InteractiveMode { return c.capabilities.InteractiveMode() } -// promptStdin returns the stdin reader for use with promptui. -// If the reader is os.Stdin, it returns nil to let the underlying readline -// library use its platform-specific default. On Windows, this is critical -// because readline's default uses ReadConsoleInputW to read arrow keys -// as virtual key events. Passing a wrapped os.Stdin would bypass this -// and break arrow key navigation in selection prompts. -func (c *cmdIO) promptStdin() io.ReadCloser { - if c.in == os.Stdin { - return nil - } - return io.NopCloser(c.in) -} - -type nopWriteCloser struct { - io.Writer -} - -func (nopWriteCloser) Close() error { - return nil -} - // NewSpinner creates a new spinner for displaying progress indicators. // The returned spinner should be closed when done to release resources. // diff --git a/libs/cmdio/prompt.go b/libs/cmdio/prompt.go index 41b42b62e7..657038b618 100644 --- a/libs/cmdio/prompt.go +++ b/libs/cmdio/prompt.go @@ -2,8 +2,19 @@ package cmdio import ( "context" + "fmt" + "io" + "strings" - "github.com/manifoldco/promptui" + tea "github.com/charmbracelet/bubbletea" +) + +// Glyphs drawn into the prompt's rendered output. cursorBlock stands in for +// the (hidden) OS cursor — promptui's defaultCursor used the same character. +const ( + cursorBlock = "█" + glyphValid = "✔" + glyphInvalid = "✗" ) // PromptOptions configures a single-line text prompt shown by [RunPrompt]. @@ -15,30 +26,30 @@ type PromptOptions struct { // (use '*' for password-style input). Mask rune - // HideEntered hides the entered value after the prompt closes. + // HideEntered clears the prompt line after submission so the entered + // value is not left behind in scrollback. Used by [Secret]. HideEntered bool - // Validate, when set, is called on every keystroke; returning a non-nil - // error keeps the prompt open and shows the error to the user. + // Validate, when set, is called on every keystroke. While it returns a + // non-nil error the leading glyph flips from "✔" to "✗" and Enter is + // inert; pressing Enter while invalid surfaces the returned error + // below the prompt until the next edit. Validate func(input string) error } // RunPrompt shows a single-line text prompt and returns the entered value. +// Returns an error without prompting when the terminal does not support it. func RunPrompt(ctx context.Context, opts PromptOptions) (string, error) { c := fromContext(ctx) - p := promptui.Prompt{ - Label: opts.Label, - Mask: opts.Mask, - HideEntered: opts.HideEntered, - Validate: opts.Validate, - Stdin: c.promptStdin(), - Stdout: nopWriteCloser{c.err}, + if !c.capabilities.SupportsPrompt() { + return "", fmt.Errorf("expected to have %s", opts.Label) } - return p.Run() + return c.runPromptModel(newPromptModel(opts)) } -// Secret prompts the user for a value while masking input with '*' and hiding -// the entered value after submission. +// Secret prompts the user for a value while masking input with '*' and +// clearing the prompt line on submission so the masked value isn't left +// behind in scrollback. func Secret(ctx context.Context, label string) (string, error) { return RunPrompt(ctx, PromptOptions{ Label: label, @@ -46,3 +57,198 @@ func Secret(ctx context.Context, label string) (string, error) { HideEntered: true, }) } + +type promptModel struct { + label string + mask rune + hideEntered bool + validate func(string) error + + // runes holds the editable input as a slice of runes so cursor positions + // remain valid for multibyte characters. + runes []rune + cursor int + + cancelled bool + deleted bool + submitted bool + + // submitErr is the error from the last failed Enter attempt. Rendered + // below the prompt and cleared on the next edit. + submitErr error +} + +func newPromptModel(opts PromptOptions) *promptModel { + return &promptModel{ + label: opts.Label, + mask: opts.Mask, + hideEntered: opts.HideEntered, + validate: opts.Validate, + } +} + +func (m *promptModel) value() string { + return string(m.runes) +} + +// glyph returns the leading status indicator with promptui's color treatment: +// bold-green ✔ when valid, bold-red ✗ when validate rejects the buffer. +func (m *promptModel) glyph() string { + if m.validate != nil && m.validate(m.value()) != nil { + return ansiBold + ansiRed + glyphInvalid + ansiReset + } + return ansiBold + ansiGreen + glyphValid + ansiReset +} + +func (m *promptModel) Init() tea.Cmd { return nil } + +func (m *promptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + switch key.Type { + case tea.KeyCtrlC: + m.cancelled = true + return m, tea.Quit + + case tea.KeyEnter, tea.KeyCtrlJ: + // Enter sends CR, Ctrl+J sends LF. chzyer/readline (under promptui) + // maps both to CharEnter, so we treat them identically. + if m.validate != nil { + if err := m.validate(m.value()); err != nil { + m.submitErr = err + return m, nil + } + } + m.submitted = true + return m, tea.Quit + + case tea.KeyDelete, tea.KeyCtrlD: + // chzyer/readline (under promptui) maps Delete and Ctrl+D to the same + // rune and treats an empty buffer as EOF; promptui's listener resets + // the readline buffer after every keystroke, so both keys always + // land on the EOF path. We pin that surprising behavior here. + m.deleted = true + return m, tea.Quit + + case tea.KeyLeft, tea.KeyCtrlB: + // Ctrl+B is readline's CharBackward; promptui's Cursor.Listen treats + // it the same as the left arrow. + if m.cursor > 0 { + m.cursor-- + } + + case tea.KeyRight, tea.KeyCtrlF: + // Ctrl+F is readline's CharForward; promptui maps it to right arrow. + if m.cursor < len(m.runes) { + m.cursor++ + } + + case tea.KeyBackspace, tea.KeyCtrlH: + // Backspace sends DEL, Ctrl+H sends BS. chzyer/readline maps both + // to CharBackspace, so we treat them identically. + // + // Alt+Backspace is the readline word-delete combo; promptui's + // Cursor.Listen leaves it as a no-op, so we drop it here too rather + // than treating it as a plain backspace. + if key.Alt { + return m, nil + } + if m.cursor == 0 { + return m, nil + } + m.runes = append(m.runes[:m.cursor-1], m.runes[m.cursor:]...) + m.cursor-- + m.submitErr = nil + + case tea.KeyRunes, tea.KeySpace: + // Alt+ (e.g. Alt+f, Alt+b) are readline word-nav combos that + // promptui's Cursor.Listen drops on the floor. Match that behavior + // instead of inserting the rune literally. + if key.Alt { + return m, nil + } + typed := key.Runes + if key.Type == tea.KeySpace { + typed = []rune{' '} + } + tail := append([]rune{}, m.runes[m.cursor:]...) + m.runes = append(m.runes[:m.cursor], typed...) + m.runes = append(m.runes, tail...) + m.cursor += len(typed) + m.submitErr = nil + + default: + // All other key types are intentionally inert (Home/End, + // Ctrl+W/Ctrl+U, Ctrl+P/N, function keys, etc.) — promptui's + // Cursor.Listen drops them and reverts readline's buffer. + } + + return m, nil +} + +func (m *promptModel) View() string { + // HideEntered: empty final frame so the masked value isn't left in + // scrollback after the user presses Enter. + if m.submitted && m.hideEntered { + return "" + } + + display := m.runes + if m.mask != 0 { + display = make([]rune, len(m.runes)) + for i := range display { + display[i] = m.mask + } + } + + // Post-submit frame matches promptui's Success template: faint label, + // faint colon, then the entered value plain. No cursor block. + // + // The trailing "\n" is load-bearing. On tea.Quit, bubbletea's renderer + // flushes one last frame (so this View runs with submitted=true), then + // stop() runs `EraseEntireLine` + "\r" to park the cursor cleanly for + // whatever output follows. EraseEntireLine wipes the row the cursor is + // on — so we end the frame with "\n" to advance the cursor onto an + // empty sacrificial row, leaving the rendered text intact above. Pre- + // submit frames must NOT trail "\n" or every keystroke would consume + // an extra terminal row and risk scrolling at the screen bottom. + if m.submitted { + return ansiFaint + m.label + ":" + ansiReset + " " + string(display) + "\n" + } + + prefix := m.glyph() + " " + ansiBold + m.label + ansiReset + ansiBold + ":" + ansiReset + " " + + var line string + if m.cursor >= len(display) { + line = prefix + string(display) + cursorBlock + } else { + // Cursor block visually replaces the rune at the cursor; the hidden + // rune is still in m.runes and reappears once the cursor moves. + line = prefix + string(display[:m.cursor]) + cursorBlock + string(display[m.cursor+1:]) + } + + if m.submitErr != nil { + // promptui's ">> " line in red, captured at the failed Enter + // and cleared on the next edit. + line += "\n" + ansiRed + ">> " + m.submitErr.Error() + ansiReset + } + return line +} + +func (c *cmdIO) runPromptModel(m *promptModel) (string, error) { + final, err := c.runTUI(m) + if err != nil { + return "", err + } + pm := final.(*promptModel) + switch { + case pm.cancelled: + return "", errCtrlC + case pm.deleted: + return "", io.EOF + } + return strings.TrimRight(pm.value(), "\r\n"), nil +} diff --git a/libs/cmdio/select.go b/libs/cmdio/select.go index c295e76345..c65f77b5ae 100644 --- a/libs/cmdio/select.go +++ b/libs/cmdio/select.go @@ -3,12 +3,64 @@ package cmdio import ( "context" "fmt" + "reflect" "slices" "strings" + "text/template" + "unicode/utf8" - "github.com/manifoldco/promptui" + tea "github.com/charmbracelet/bubbletea" ) +const ( + // viewportSize is the number of list rows shown at once. + viewportSize = 5 + + gutterUp = "↑ " + gutterDown = "↓ " + gutter = " " + + // Default templates match promptui's Select defaults: a blue "?" before + // the label, a bold "▸" + underlined item for the active row, " " + + // item for inactive rows, and a green "✔" + faint item for the + // post-submit summary. They render reasonably for any item type whose + // fmt.Stringer / printf'd form is a single line. + defaultLabelTemplate = `{{ "?" | blue }} {{.}}: ` + defaultActiveTemplate = `{{ "▸" | bold }} {{ . | underline }}` + defaultInactiveTemplate = ` {{.}}` + defaultSelectedTemplate = `{{ "✔" | green }} {{ . | faint }}` + + // helpTextBase is shown above the label when search isn't active and + // HideHelp is unset; promptui renders its Help template entirely faint. + // When a Searcher is configured the " and / toggles search" suffix is + // appended to advertise the toggle. + helpTextBase = "Use the arrow keys to navigate: ↓ ↑ → ←" + helpTextSearch = helpTextBase + " and / toggles search" +) + +// promptFuncMap is the pipeline-form template.FuncMap used by select-prompt +// templates (`{{ . | bold }}`). It always emits SGR codes; color.go's +// RenderFuncMap is printf-style and gates colors on ctx so the two cannot +// share an implementation. +var promptFuncMap = template.FuncMap{ + "bold": promptANSI(ansiBold), + "faint": promptANSI(ansiFaint), + "italic": promptANSI(ansiItalic), + "underline": promptANSI(ansiUnderline), + "red": promptANSI(ansiRed), + "green": promptANSI(ansiGreen), + "yellow": promptANSI(ansiYellow), + "blue": promptANSI(ansiBlue), + "magenta": promptANSI(ansiMagenta), + "cyan": promptANSI(ansiCyan), +} + +func promptANSI(prefix string) func(any) string { + return func(v any) string { + return prefix + fmt.Sprint(v) + ansiReset + } +} + // SelectOptions configures an interactive single-choice picker shown by // [RunSelect]. Template strings use text/template syntax and have access // to the fields of the items in Items. @@ -26,7 +78,8 @@ type SelectOptions struct { // StartInSearchMode opens the prompt with the search input focused. StartInSearchMode bool - // HideHelp hides the navigation help line shown by promptui by default. + // HideHelp hides the navigation help line shown by default when no + // search is active. HideHelp bool // HideSelected hides the rendered selection after the prompt closes. @@ -45,29 +98,23 @@ type SelectOptions struct { Selected string } -// RunSelect shows an interactive picker and returns the index of the chosen item. +// RunSelect shows an interactive picker and returns the index of the chosen +// item. Returns an error without prompting when the terminal does not support +// it. func RunSelect(ctx context.Context, opts SelectOptions) (int, error) { c := fromContext(ctx) - sel := &promptui.Select{ - Label: opts.Label, - Items: opts.Items, - Searcher: opts.Searcher, - StartInSearchMode: opts.StartInSearchMode, - HideHelp: opts.HideHelp, - HideSelected: opts.HideSelected, - Templates: &promptui.SelectTemplates{ - Label: opts.LabelTemplate, - Active: opts.Active, - Inactive: opts.Inactive, - Selected: opts.Selected, - }, - Stdin: c.promptStdin(), - Stdout: nopWriteCloser{c.err}, + if !c.capabilities.SupportsPrompt() { + return 0, fmt.Errorf("expected to have %s", opts.Label) + } + m, err := newSelectModel(opts) + if err != nil { + return 0, err } - idx, _, err := sel.Run() - return idx, err + return c.runSelectModel(m) } +// Tuple pairs a human-friendly Name with an internal Id, used as the row type +// for [Select] and [SelectOrdered]. type Tuple struct{ Name, Id string } // Select shows a selection prompt where the user can pick one of the name/id @@ -87,10 +134,6 @@ func Select[V any](ctx context.Context, names map[string]V, label string) (strin // name/id items. The items appear in the order specified in the "items" // argument. func SelectOrdered(ctx context.Context, items []Tuple, label string) (string, error) { - c := fromContext(ctx) - if !c.capabilities.SupportsInteractive() { - return "", fmt.Errorf("expected to have %s", label) - } idx, err := RunSelect(ctx, SelectOptions{ Label: label, Items: items, @@ -107,3 +150,389 @@ func SelectOrdered(ctx context.Context, items []Tuple, label string) (string, er } return items[idx].Id, nil } + +type selectModel struct { + label string + items []any + + labelTpl *template.Template + activeTpl *template.Template + inactiveTpl *template.Template + selectedTpl *template.Template + + searcher func(input string, idx int) bool + searchActive bool + hideHelp bool + hideSelected bool + + filter string + matches []int + cursor int + viewportTop int + + submitted bool + cancelled bool +} + +func newSelectModel(opts SelectOptions) (*selectModel, error) { + items, err := normalizeItems(opts.Items) + if err != nil { + return nil, err + } + labelTpl, err := parsePromptTemplate("label", defaultIfEmpty(opts.LabelTemplate, defaultLabelTemplate)) + if err != nil { + return nil, err + } + activeTpl, err := parsePromptTemplate("active", defaultIfEmpty(opts.Active, defaultActiveTemplate)) + if err != nil { + return nil, err + } + inactiveTpl, err := parsePromptTemplate("inactive", defaultIfEmpty(opts.Inactive, defaultInactiveTemplate)) + if err != nil { + return nil, err + } + selectedTpl, err := parsePromptTemplate("selected", defaultIfEmpty(opts.Selected, defaultSelectedTemplate)) + if err != nil { + return nil, err + } + + m := &selectModel{ + label: opts.Label, + items: items, + labelTpl: labelTpl, + activeTpl: activeTpl, + inactiveTpl: inactiveTpl, + selectedTpl: selectedTpl, + searcher: opts.Searcher, + searchActive: opts.StartInSearchMode, + hideHelp: opts.HideHelp, + hideSelected: opts.HideSelected, + } + m.recomputeMatches() + return m, nil +} + +func defaultIfEmpty(s, fallback string) string { + if s == "" { + return fallback + } + return s +} + +func parsePromptTemplate(name, src string) (*template.Template, error) { + t, err := template.New(name).Funcs(promptFuncMap).Parse(src) + if err != nil { + return nil, fmt.Errorf("parse %s template: %w", name, err) + } + return t, nil +} + +func (m *selectModel) Init() tea.Cmd { return tea.HideCursor } + +func (m *selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch key.Type { + case tea.KeyCtrlC: + m.cancelled = true + return m, tea.Quit + + case tea.KeyEnter, tea.KeyCtrlJ: + // Enter on an empty filtered list is intentionally inert; only + // Ctrl+C escapes from a "No results" panel. + if len(m.matches) == 0 { + return m, nil + } + m.submitted = true + return m, tea.Quit + + case tea.KeyUp, tea.KeyCtrlP: + // Ctrl+P is readline's CharPrev; promptui's select listener handles + // it identically to the up arrow. + m.cursorUp() + + case tea.KeyDown, tea.KeyCtrlN: + // Ctrl+N is readline's CharNext; same as the down arrow. + m.cursorDown() + + case tea.KeyLeft, tea.KeyCtrlB: + // Left arrow / Ctrl+B page up; promptui binds both to its + // list.PageUp via KeyBackward. + m.pageUp() + + case tea.KeyRight, tea.KeyCtrlF: + // Right arrow / Ctrl+F page down; both map to KeyForward in + // promptui and drive list.PageDown. + m.pageDown() + + case tea.KeyBackspace, tea.KeyCtrlH: + // Backspace sends DEL, Ctrl+H sends BS; chzyer/readline treats both + // as CharBackspace inside the search buffer. + // + // Alt+Backspace is readline's word-delete combo; promptui drops it, + // so we drop it here too instead of deleting one rune. + if key.Alt { + return m, nil + } + if !m.searchActive || m.filter == "" { + return m, nil + } + if r, size := utf8.DecodeLastRuneInString(m.filter); r != utf8.RuneError { + m.filter = m.filter[:len(m.filter)-size] + } + m.recomputeMatches() + + case tea.KeyTab, tea.KeyRunes, tea.KeySpace: + // Alt+ are readline word-nav combos promptui ignores; don't + // let them sneak in as filter input. + if key.Alt { + return m, nil + } + if !m.searchActive { + // Outside search mode, vim-style shortcuts navigate the list and + // "/" toggles search when a Searcher is configured. Anything + // else is dropped. Multiple runes can arrive in a single KeyMsg + // when the user types quickly, so dispatch per-rune. + if key.Type == tea.KeyRunes { + for _, r := range key.Runes { + switch r { + case 'j': + m.cursorDown() + case 'k': + m.cursorUp() + case 'l': + m.pageDown() + case 'h': + m.pageUp() + case '/': + if m.searcher != nil { + m.searchActive = true + } + } + } + } + return m, nil + } + switch key.Type { + case tea.KeyTab: + m.filter += "\t" + case tea.KeySpace: + m.filter += " " + default: + // "/" toggles search off (matching promptui): clear the filter + // and exit search mode. Any other runes in this KeyMsg are + // dropped — promptui dispatches per-rune so this only matters + // when bubbletea batches keystrokes, in which case the user + // almost certainly meant the toggle. + if slices.Contains(key.Runes, '/') { + m.filter = "" + m.searchActive = false + m.recomputeMatches() + return m, nil + } + m.filter += string(key.Runes) + } + m.recomputeMatches() + + case tea.KeyEsc: + // Esc is intentionally inert. + + default: + // Other key types (Home/End, Ctrl+U/W, function keys, …) are no-ops. + } + return m, nil +} + +func (m *selectModel) View() string { + var b strings.Builder + + // After submission render only the Selected template — promptui replaced + // the prompt UI with a single-line confirmation, and the post-quit frame + // stays on screen as the user-visible result. HideSelected callers leave + // the screen blank. + // + // The trailing "\n" is load-bearing for the same reason as in + // promptModel.View: bubbletea's renderer wipes the cursor's row on + // shutdown, so we park the cursor on an empty row below the content. + if m.submitted { + if !m.hideSelected { + if err := m.selectedTpl.Execute(&b, m.items[m.originalIndex()]); err != nil { + fmt.Fprintf(&b, "[selected template error: %v]", err) + } + b.WriteString("\n") + } + return b.String() + } + + switch { + case m.searchActive: + b.WriteString("Search: ") + // Tab stops every 8 columns from col 8 (after "Search: "). Expand to + // spaces because tea's diff-based redraw doesn't reliably clear the + // column the previous cursor occupied when a literal \t lands there, + // leaving a stale █ behind. + b.WriteString(expandTabsFromCol(m.filter, 8)) + b.WriteString(cursorBlock) + b.WriteString("\n") + case !m.hideHelp: + text := helpTextBase + if m.searcher != nil { + text = helpTextSearch + } + b.WriteString(ansiFaint + text + ansiReset) + b.WriteString("\n") + } + + if err := m.labelTpl.Execute(&b, m.label); err != nil { + fmt.Fprintf(&b, "[label template error: %v]", err) + } + b.WriteString("\n") + + if len(m.matches) == 0 { + b.WriteString("\nNo results\n") + return b.String() + } + + end := min(m.viewportTop+viewportSize, len(m.matches)) + hasAbove := m.viewportTop > 0 + hasBelow := end < len(m.matches) + + for i := m.viewportTop; i < end; i++ { + switch { + case i == m.viewportTop && hasAbove: + b.WriteString(gutterUp) + case i == end-1 && hasBelow: + b.WriteString(gutterDown) + default: + b.WriteString(gutter) + } + + tpl := m.inactiveTpl + if i == m.cursor { + tpl = m.activeTpl + } + if err := tpl.Execute(&b, m.items[m.matches[i]]); err != nil { + fmt.Fprintf(&b, "[template error: %v]", err) + } + b.WriteString("\n") + } + + return b.String() +} + +func (m *selectModel) recomputeMatches() { + m.matches = m.matches[:0] + for i := range m.items { + if m.filter == "" || m.searcher == nil || m.searcher(m.filter, i) { + m.matches = append(m.matches, i) + } + } + m.cursor = 0 + m.viewportTop = 0 +} + +func (m *selectModel) cursorUp() { + if m.cursor > 0 { + m.cursor-- + if m.cursor < m.viewportTop { + m.viewportTop = m.cursor + } + } +} + +func (m *selectModel) cursorDown() { + if m.cursor < len(m.matches)-1 { + m.cursor++ + if m.cursor >= m.viewportTop+viewportSize { + m.viewportTop = m.cursor - viewportSize + 1 + } + } +} + +// pageUp shifts the viewport up by one page, then drops the cursor onto the +// new top if it was below it. Mirrors promptui's list.PageUp. +func (m *selectModel) pageUp() { + m.viewportTop = max(m.viewportTop-viewportSize, 0) + if m.viewportTop < m.cursor { + m.cursor = m.viewportTop + } +} + +// pageDown shifts the viewport down by one page, clamping so the last full +// page stays visible, then bumps the cursor up to the new top if it lagged +// behind. Mirrors promptui's list.PageDown. +func (m *selectModel) pageDown() { + max := len(m.matches) - viewportSize + switch newTop := m.viewportTop + viewportSize; { + case len(m.matches) < viewportSize: + m.viewportTop = 0 + case newTop > max: + m.viewportTop = max + default: + m.viewportTop = newTop + } + if m.cursor < m.viewportTop { + m.cursor = m.viewportTop + } +} + +// originalIndex returns the items-slice index of the currently selected match, +// or -1 when nothing is selectable. +func (m *selectModel) originalIndex() int { + if len(m.matches) == 0 { + return -1 + } + return m.matches[m.cursor] +} + +// normalizeItems accepts any slice via reflection and copies its elements +// into a []any so the model can index them without further reflection. A +// non-slice argument is rejected at construction time. +func normalizeItems(in any) ([]any, error) { + if in == nil { + return nil, nil + } + v := reflect.ValueOf(in) + if v.Kind() != reflect.Slice { + return nil, fmt.Errorf("SelectOptions.Items must be a slice, got %s", v.Kind()) + } + out := make([]any, v.Len()) + for i := range out { + out[i] = v.Index(i).Interface() + } + return out, nil +} + +// expandTabsFromCol replaces \t in s with spaces, advancing to the next tab +// stop (every 8 columns) given a starting column. +func expandTabsFromCol(s string, startCol int) string { + var b strings.Builder + col := startCol + for _, r := range s { + if r == '\t' { + stop := ((col / 8) + 1) * 8 + for col < stop { + b.WriteByte(' ') + col++ + } + continue + } + b.WriteRune(r) + col++ + } + return b.String() +} + +func (c *cmdIO) runSelectModel(m *selectModel) (int, error) { + final, err := c.runTUI(m) + if err != nil { + return 0, err + } + sm := final.(*selectModel) + if sm.cancelled { + return 0, errCtrlC + } + return sm.originalIndex(), nil +} From 32253f2c1bb5ee9ca8b90cb818f54b14f14224b8 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 11 May 2026 17:39:40 +0200 Subject: [PATCH 2/6] cmdio: trim promptui references from comments After the PR was opened, sweep through prompt.go and select.go and remove comments that name-drop promptui without saying anything the code doesn't already convey. Keep the comments that document a non-obvious behavior (post-submit "\n", Alt-rune dropping, Delete-as- EOF, "/" toggle batching, ">>" validation line); drop the ones that just trace where a particular spec came from. Co-authored-by: Isaac --- libs/cmdio/prompt.go | 43 +++++++++++++--------------------- libs/cmdio/select.go | 56 +++++++++++++++++--------------------------- 2 files changed, 38 insertions(+), 61 deletions(-) diff --git a/libs/cmdio/prompt.go b/libs/cmdio/prompt.go index 657038b618..fb447e4edb 100644 --- a/libs/cmdio/prompt.go +++ b/libs/cmdio/prompt.go @@ -10,7 +10,7 @@ import ( ) // Glyphs drawn into the prompt's rendered output. cursorBlock stands in for -// the (hidden) OS cursor — promptui's defaultCursor used the same character. +// the (hidden) OS cursor at the current edit position. const ( cursorBlock = "█" glyphValid = "✔" @@ -91,8 +91,8 @@ func (m *promptModel) value() string { return string(m.runes) } -// glyph returns the leading status indicator with promptui's color treatment: -// bold-green ✔ when valid, bold-red ✗ when validate rejects the buffer. +// glyph returns the leading status indicator: bold-green ✔ when valid, +// bold-red ✗ when validate rejects the buffer. func (m *promptModel) glyph() string { if m.validate != nil && m.validate(m.value()) != nil { return ansiBold + ansiRed + glyphInvalid + ansiReset @@ -114,8 +114,7 @@ func (m *promptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case tea.KeyEnter, tea.KeyCtrlJ: - // Enter sends CR, Ctrl+J sends LF. chzyer/readline (under promptui) - // maps both to CharEnter, so we treat them identically. + // Enter sends CR, Ctrl+J sends LF; treat both as submit. if m.validate != nil { if err := m.validate(m.value()); err != nil { m.submitErr = err @@ -126,33 +125,25 @@ func (m *promptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case tea.KeyDelete, tea.KeyCtrlD: - // chzyer/readline (under promptui) maps Delete and Ctrl+D to the same - // rune and treats an empty buffer as EOF; promptui's listener resets - // the readline buffer after every keystroke, so both keys always - // land on the EOF path. We pin that surprising behavior here. + // Delete and Ctrl+D both exit the prompt with EOF, even on a + // non-empty buffer. m.deleted = true return m, tea.Quit case tea.KeyLeft, tea.KeyCtrlB: - // Ctrl+B is readline's CharBackward; promptui's Cursor.Listen treats - // it the same as the left arrow. if m.cursor > 0 { m.cursor-- } case tea.KeyRight, tea.KeyCtrlF: - // Ctrl+F is readline's CharForward; promptui maps it to right arrow. if m.cursor < len(m.runes) { m.cursor++ } case tea.KeyBackspace, tea.KeyCtrlH: - // Backspace sends DEL, Ctrl+H sends BS. chzyer/readline maps both - // to CharBackspace, so we treat them identically. - // - // Alt+Backspace is the readline word-delete combo; promptui's - // Cursor.Listen leaves it as a no-op, so we drop it here too rather - // than treating it as a plain backspace. + // Backspace sends DEL, Ctrl+H sends BS; treat both as backspace. + // Alt+Backspace (the word-delete combo) is dropped rather than + // falling through as a plain backspace. if key.Alt { return m, nil } @@ -164,9 +155,8 @@ func (m *promptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.submitErr = nil case tea.KeyRunes, tea.KeySpace: - // Alt+ (e.g. Alt+f, Alt+b) are readline word-nav combos that - // promptui's Cursor.Listen drops on the floor. Match that behavior - // instead of inserting the rune literally. + // Alt+ (e.g. Alt+f, Alt+b) are word-nav combos we do not + // support; drop them rather than inserting the rune literally. if key.Alt { return m, nil } @@ -182,8 +172,7 @@ func (m *promptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { default: // All other key types are intentionally inert (Home/End, - // Ctrl+W/Ctrl+U, Ctrl+P/N, function keys, etc.) — promptui's - // Cursor.Listen drops them and reverts readline's buffer. + // Ctrl+W/Ctrl+U, Ctrl+P/N, function keys, etc.). } return m, nil @@ -204,8 +193,8 @@ func (m *promptModel) View() string { } } - // Post-submit frame matches promptui's Success template: faint label, - // faint colon, then the entered value plain. No cursor block. + // Post-submit frame: faint label, faint colon, then the entered value + // plain. No cursor block. // // The trailing "\n" is load-bearing. On tea.Quit, bubbletea's renderer // flushes one last frame (so this View runs with submitted=true), then @@ -231,8 +220,8 @@ func (m *promptModel) View() string { } if m.submitErr != nil { - // promptui's ">> " line in red, captured at the failed Enter - // and cleared on the next edit. + // Validation error line captured at the failed Enter; cleared on + // the next edit. line += "\n" + ansiRed + ">> " + m.submitErr.Error() + ansiReset } return line diff --git a/libs/cmdio/select.go b/libs/cmdio/select.go index c65f77b5ae..a64418f800 100644 --- a/libs/cmdio/select.go +++ b/libs/cmdio/select.go @@ -20,20 +20,19 @@ const ( gutterDown = "↓ " gutter = " " - // Default templates match promptui's Select defaults: a blue "?" before - // the label, a bold "▸" + underlined item for the active row, " " + - // item for inactive rows, and a green "✔" + faint item for the - // post-submit summary. They render reasonably for any item type whose - // fmt.Stringer / printf'd form is a single line. + // Default templates: blue "?" before the label, bold "▸" + underlined + // item for the active row, two-space prefix + item for inactive rows, + // green "✔" + faint item for the post-submit summary. They render + // reasonably for any item type whose fmt.Stringer / printf'd form is + // a single line. defaultLabelTemplate = `{{ "?" | blue }} {{.}}: ` defaultActiveTemplate = `{{ "▸" | bold }} {{ . | underline }}` defaultInactiveTemplate = ` {{.}}` defaultSelectedTemplate = `{{ "✔" | green }} {{ . | faint }}` - // helpTextBase is shown above the label when search isn't active and - // HideHelp is unset; promptui renders its Help template entirely faint. - // When a Searcher is configured the " and / toggles search" suffix is - // appended to advertise the toggle. + // helpTextBase is shown faint above the label when search isn't active + // and HideHelp is unset. When a Searcher is configured the trailing + // suffix advertises the "/" toggle. helpTextBase = "Use the arrow keys to navigate: ↓ ↑ → ←" helpTextSearch = helpTextBase + " and / toggles search" ) @@ -249,30 +248,21 @@ func (m *selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case tea.KeyUp, tea.KeyCtrlP: - // Ctrl+P is readline's CharPrev; promptui's select listener handles - // it identically to the up arrow. m.cursorUp() case tea.KeyDown, tea.KeyCtrlN: - // Ctrl+N is readline's CharNext; same as the down arrow. m.cursorDown() case tea.KeyLeft, tea.KeyCtrlB: - // Left arrow / Ctrl+B page up; promptui binds both to its - // list.PageUp via KeyBackward. m.pageUp() case tea.KeyRight, tea.KeyCtrlF: - // Right arrow / Ctrl+F page down; both map to KeyForward in - // promptui and drive list.PageDown. m.pageDown() case tea.KeyBackspace, tea.KeyCtrlH: - // Backspace sends DEL, Ctrl+H sends BS; chzyer/readline treats both - // as CharBackspace inside the search buffer. - // - // Alt+Backspace is readline's word-delete combo; promptui drops it, - // so we drop it here too instead of deleting one rune. + // Backspace sends DEL, Ctrl+H sends BS; treat both as filter + // backspace. Alt+Backspace (word-delete) is dropped rather than + // deleting a single rune. if key.Alt { return m, nil } @@ -285,8 +275,8 @@ func (m *selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.recomputeMatches() case tea.KeyTab, tea.KeyRunes, tea.KeySpace: - // Alt+ are readline word-nav combos promptui ignores; don't - // let them sneak in as filter input. + // Alt+ are word-nav combos; don't let them sneak in as + // filter input. if key.Alt { return m, nil } @@ -321,11 +311,10 @@ func (m *selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeySpace: m.filter += " " default: - // "/" toggles search off (matching promptui): clear the filter - // and exit search mode. Any other runes in this KeyMsg are - // dropped — promptui dispatches per-rune so this only matters - // when bubbletea batches keystrokes, in which case the user - // almost certainly meant the toggle. + // "/" toggles search off: clear the filter and exit search + // mode. Any other runes batched in the same KeyMsg are dropped + // — when the user types "/" the surrounding characters almost + // certainly belong to a toggle, not the filter. if slices.Contains(key.Runes, '/') { m.filter = "" m.searchActive = false @@ -348,10 +337,9 @@ func (m *selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *selectModel) View() string { var b strings.Builder - // After submission render only the Selected template — promptui replaced - // the prompt UI with a single-line confirmation, and the post-quit frame - // stays on screen as the user-visible result. HideSelected callers leave - // the screen blank. + // After submission render only the Selected template; the post-quit + // frame stays on screen as the user-visible result. HideSelected + // callers leave the screen blank. // // The trailing "\n" is load-bearing for the same reason as in // promptModel.View: bubbletea's renderer wipes the cursor's row on @@ -452,7 +440,7 @@ func (m *selectModel) cursorDown() { } // pageUp shifts the viewport up by one page, then drops the cursor onto the -// new top if it was below it. Mirrors promptui's list.PageUp. +// new top if it was below it. func (m *selectModel) pageUp() { m.viewportTop = max(m.viewportTop-viewportSize, 0) if m.viewportTop < m.cursor { @@ -462,7 +450,7 @@ func (m *selectModel) pageUp() { // pageDown shifts the viewport down by one page, clamping so the last full // page stays visible, then bumps the cursor up to the new top if it lagged -// behind. Mirrors promptui's list.PageDown. +// behind. func (m *selectModel) pageDown() { max := len(m.matches) - viewportSize switch newTop := m.viewportTop + viewportSize; { From 61633cbd668da64c49382365d991b55b0b4784a5 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 11 May 2026 17:44:56 +0200 Subject: [PATCH 3/6] cmdio: restore rationale on baseline-parity comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the comment sweep: a few of the trimmed comments were load-bearing — they justified intentionally-surprising behavior the baseline tests pin. Without the "why", a future reader (or refactor) might assume the code is incidental and "fix" it. Restored on: - Delete / Ctrl+D exits with EOF even on a non-empty buffer. - Alt+ word-nav combos are dropped instead of inserted (both the prompt buffer and the select filter). Also gave libs/cmdio/capabilities.go the same trim that the previous commit applied to prompt.go and select.go — the Git Bash gate comment was still naming promptui and pointing at upstream issue links that no longer reflect why we keep the carve-out. Co-authored-by: Isaac --- libs/cmdio/capabilities.go | 11 ++++------- libs/cmdio/prompt.go | 8 ++++++-- libs/cmdio/select.go | 5 +++-- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/libs/cmdio/capabilities.go b/libs/cmdio/capabilities.go index 5d909b8992..70b0b819bb 100644 --- a/libs/cmdio/capabilities.go +++ b/libs/cmdio/capabilities.go @@ -63,13 +63,10 @@ func (c Capabilities) SupportsPager() bool { return c.SupportsPrompt() && c.stdoutIsTTY } -// detectGitBash returns true if running in Git Bash on Windows (has broken promptui support). -// We do not allow prompting in Git Bash on Windows. -// Likely due to fact that Git Bash does not correctly support ANSI escape sequences, -// we cannot use promptui package there. -// See known issues: -// - https://github.com/manifoldco/promptui/issues/208 -// - https://github.com/chzyer/readline/issues/191 +// detectGitBash returns true if running in Git Bash on Windows. We disable +// prompting there because Git Bash's ANSI handling has historically broken +// the line editor; the flag is kept so SupportsPrompt can gate on it and +// callers don't accidentally render a garbled UI. func detectGitBash(ctx context.Context) bool { // Check if the MSYSTEM environment variable is set to "MINGW64" msystem := env.Get(ctx, "MSYSTEM") diff --git a/libs/cmdio/prompt.go b/libs/cmdio/prompt.go index fb447e4edb..8c8ca1a3a3 100644 --- a/libs/cmdio/prompt.go +++ b/libs/cmdio/prompt.go @@ -126,7 +126,9 @@ func (m *promptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyDelete, tea.KeyCtrlD: // Delete and Ctrl+D both exit the prompt with EOF, even on a - // non-empty buffer. + // non-empty buffer. Surprising at first glance, but it matches the + // previous prompt library and is pinned by a baseline test — + // changing it would silently shift caller behavior. m.deleted = true return m, tea.Quit @@ -156,7 +158,9 @@ func (m *promptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyRunes, tea.KeySpace: // Alt+ (e.g. Alt+f, Alt+b) are word-nav combos we do not - // support; drop them rather than inserting the rune literally. + // support. Drop them rather than inserting the rune literally — + // the baseline tests pin this as a no-op, and silently typing + // "f" when the user pressed Alt+f would be a regression. if key.Alt { return m, nil } diff --git a/libs/cmdio/select.go b/libs/cmdio/select.go index a64418f800..2d481139ba 100644 --- a/libs/cmdio/select.go +++ b/libs/cmdio/select.go @@ -275,8 +275,9 @@ func (m *selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.recomputeMatches() case tea.KeyTab, tea.KeyRunes, tea.KeySpace: - // Alt+ are word-nav combos; don't let them sneak in as - // filter input. + // Alt+ are word-nav combos; drop them rather than letting + // the rune sneak into the filter buffer. The baseline tests pin + // this as a no-op for parity with the previous prompt library. if key.Alt { return m, nil } From c2f7525b4f3bfb4747c913f35fe2bcd03e97b05d Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 11 May 2026 20:02:19 +0200 Subject: [PATCH 4/6] cmdio: drop the last two promptui name-drops outside libs/cmdio libs/template/config.go and libs/databrickscfg/cfgpickers/clusters.go each had a one-line comment naming promptui to explain a constraint that still applies (single-line labels in RunSelect, ctx-free template helpers). Reword them to describe the constraint in terms of the current API instead of the removed dependency. Co-authored-by: Isaac --- libs/databrickscfg/cfgpickers/clusters.go | 2 +- libs/template/config.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/databrickscfg/cfgpickers/clusters.go b/libs/databrickscfg/cfgpickers/clusters.go index 6732c1893c..4d6aba6b18 100644 --- a/libs/databrickscfg/cfgpickers/clusters.go +++ b/libs/databrickscfg/cfgpickers/clusters.go @@ -65,7 +65,7 @@ type compatibleCluster struct { compute.ClusterDetails versionName string // renderedState caches the colorized ClusterDetails.State for display in - // promptui templates, which can't access ctx-bound color helpers. + // SelectOptions templates, which can't access ctx-bound color helpers. renderedState string } diff --git a/libs/template/config.go b/libs/template/config.go index 5f6ea3c915..020d155cfa 100644 --- a/libs/template/config.go +++ b/libs/template/config.go @@ -214,8 +214,8 @@ func (c *config) promptOnce(property *jsonschema.Schema, name, defaultVal, descr if err != nil { return err } - // promptui only supports a single-line label, so render any preceding - // lines of the description separately. + // RunSelect's Label is single-line, so render any preceding lines + // of the description separately. label := description if i := strings.LastIndex(description, "\n"); i != -1 { cmdio.LogString(c.ctx, description[:i]) From 09fff325e08c3618dd2e2ce0765bb04d39b6fb97 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 11 May 2026 20:57:06 +0200 Subject: [PATCH 5/6] cmdio: drop promptui name-drop from ssh setup test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final stray reference outside the cmdiotest test branch — reword to describe what the stub avoids (spawning a TUI) instead of naming the removed dependency. Co-authored-by: Isaac --- experimental/ssh/internal/setup/setup_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/experimental/ssh/internal/setup/setup_test.go b/experimental/ssh/internal/setup/setup_test.go index f59b2e2b3a..e96477b2ee 100644 --- a/experimental/ssh/internal/setup/setup_test.go +++ b/experimental/ssh/internal/setup/setup_test.go @@ -295,7 +295,7 @@ func TestSetup_PromptsForClusterWhenNotProvided(t *testing.T) { configPath := filepath.Join(tmpDir, "ssh_config") // Replace the cluster picker with a stub returning a fixed ID. This lets the - // test exercise the empty-ClusterID path of Setup without driving promptui. + // test exercise the empty-ClusterID path of Setup without spawning a TUI. origPrompt := clusterSelectionPrompt t.Cleanup(func() { clusterSelectionPrompt = origPrompt }) promptCalled := false From 0759cf0703dae33ecbbdb6b7c81012ed9ca2e3c3 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 11 May 2026 21:04:18 +0200 Subject: [PATCH 6/6] ssh: reword the setup_test promptui-stub comment Follow the earlier convention: describe what the stub avoids ("prompting") rather than the mechanism being avoided. Co-authored-by: Isaac --- experimental/ssh/internal/setup/setup_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/experimental/ssh/internal/setup/setup_test.go b/experimental/ssh/internal/setup/setup_test.go index e96477b2ee..62dde513f2 100644 --- a/experimental/ssh/internal/setup/setup_test.go +++ b/experimental/ssh/internal/setup/setup_test.go @@ -295,7 +295,7 @@ func TestSetup_PromptsForClusterWhenNotProvided(t *testing.T) { configPath := filepath.Join(tmpDir, "ssh_config") // Replace the cluster picker with a stub returning a fixed ID. This lets the - // test exercise the empty-ClusterID path of Setup without spawning a TUI. + // test exercise the empty-ClusterID path of Setup without prompting. origPrompt := clusterSelectionPrompt t.Cleanup(func() { clusterSelectionPrompt = origPrompt }) promptCalled := false