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/experimental/ssh/internal/setup/setup_test.go b/experimental/ssh/internal/setup/setup_test.go index f59b2e2b3a..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 driving promptui. + // test exercise the empty-ClusterID path of Setup without prompting. origPrompt := clusterSelectionPrompt t.Cleanup(func() { clusterSelectionPrompt = origPrompt }) promptCalled := false 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/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/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..8c8ca1a3a3 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 at the current edit position. +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,191 @@ 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: 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; treat both as submit. + 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: + // Delete and Ctrl+D both exit the prompt with EOF, even on a + // 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 + + case tea.KeyLeft, tea.KeyCtrlB: + if m.cursor > 0 { + m.cursor-- + } + + case tea.KeyRight, tea.KeyCtrlF: + if m.cursor < len(m.runes) { + m.cursor++ + } + + case tea.KeyBackspace, tea.KeyCtrlH: + // 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 + } + 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 word-nav combos we do not + // 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 + } + 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.). + } + + 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: 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 { + // Validation error line captured at the failed Enter; 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..2d481139ba 100644 --- a/libs/cmdio/select.go +++ b/libs/cmdio/select.go @@ -3,12 +3,63 @@ 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: 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 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" +) + +// 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 +77,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 +97,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 +133,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 +149,379 @@ 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: + m.cursorUp() + + case tea.KeyDown, tea.KeyCtrlN: + m.cursorDown() + + case tea.KeyLeft, tea.KeyCtrlB: + m.pageUp() + + case tea.KeyRight, tea.KeyCtrlF: + m.pageDown() + + case tea.KeyBackspace, tea.KeyCtrlH: + // 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 + } + 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 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 + } + 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: 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 + 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; 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. +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. +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 +} 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])