diff --git a/.changesets/1791409317-72b900b2.yaml b/.changesets/1791409317-72b900b2.yaml new file mode 100644 index 000000000..d7e11f70b --- /dev/null +++ b/.changesets/1791409317-72b900b2.yaml @@ -0,0 +1,10 @@ +id: 1791409317-72b900b2 +features: + - core +targets: + - cli +type: chore +bump: patch +description: replace the huh form library with an in-repo forms package +author: ThomasRooney +date: "2026-10-07" diff --git a/templates/perms.go b/templates/perms.go index 1a0c810b9..a58567ebe 100644 --- a/templates/perms.go +++ b/templates/perms.go @@ -41,6 +41,8 @@ var fileMode = map[string]fs.FileMode{ "templates/cli/auxiliary/internal/flagutil/flags.go.stmpl": 0o644, "templates/cli/auxiliary/internal/flagutil/metadata.go.stmpl": 0o644, "templates/cli/auxiliary/internal/flagutil/preset.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms": 0o20000000755, + "templates/cli/auxiliary/internal/forms/forms.go.stmpl": 0o644, "templates/cli/auxiliary/internal/interactive": 0o20000000755, "templates/cli/auxiliary/internal/interactive/interactive.go.stmpl": 0o644, "templates/cli/auxiliary/internal/interactive/policy.go.stmpl": 0o644, @@ -187,6 +189,7 @@ var fileMode = map[string]fs.FileMode{ "templates/cli/tests/primary/exitcodes_test.go.stmpl": 0o644, "templates/cli/tests/primary/flagutilmeta_test.go.stmpl": 0o644, "templates/cli/tests/primary/flattening_test.go.stmpl": 0o644, + "templates/cli/tests/primary/forms_test.go.stmpl": 0o644, "templates/cli/tests/primary/globals_additional_test.go.stmpl": 0o644, "templates/cli/tests/primary/headers_additional_test.go.stmpl": 0o644, "templates/cli/tests/primary/hooks_additional_test.go.stmpl": 0o644, diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index 9728e9e9c..fe8baae85 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -169,7 +169,9 @@ The generator follows a **generated commands + shared runtime** pattern: │ keyring.go ─ OS keychain integration (go-keyring) │ │ interactive/ │ │ policy.go ─ Shared prompt/form/explorer policy │ -│ interactive.go ─ Missing-flag prompting via huh forms │ +│ interactive.go ─ Missing-flag prompting via forms │ +│ forms/ │ +│ forms.go ─ Input/select/confirm terminal forms │ │ explorer/ │ │ *.go ─ Interactive command explorer TUI │ │ testclient/ │ @@ -761,7 +763,7 @@ cli users create --no-interactive - `interactive.Intercept` is installed after generated intents, both custom-command registration surfaces, Cobra's default help/completion commands, and declared command ordering. `usage.Intercept` is installed immediately afterward as the outer wrapper; `--usage`, `--schema`, and any other flag annotated as a documentation surface never open a prompt. - `interactive.Resolve` is the single runtime gate for required-input prompts, `configure` / `auth login` forms, and direct explorer validation. `interactive.IsInteractive` remains a compatibility wrapper for hand-written commands. The compatibility-only bare-root auto-launch retains `shouldAutoExplore` so its process-entry behavior is unchanged. `interactiveByDefault` selects the generated default of `--interactive`; `--no-interactive` and active agent mode suppress prompts and forms. -- Required-input prompting needs both stdin and stdout to be TTYs unless a context-injected test prompter supplies terminal capability. Guided forms use the rich TUI on a terminal pair and huh's accessible line prompts off-TTY. Consequently, compatibility builds (`interactiveByDefault: true`) retain their existing off-TTY accessible forms, while non-interactive-by-default builds never read stdin for a form off-TTY unless `--interactive` is passed. `configure` and `auth login` resolve through `Decision.SetupFormMode`: run bare on a terminal pair they open the form regardless of `interactiveByDefault`, unless `--no-interactive`, `--interactive=false`, or agent mode is active. `configure --default-output-format` stores the default output format without a form; `--output-format` keeps controlling only the current command's rendering. +- Required-input prompting needs both stdin and stdout to be TTYs unless a context-injected test prompter supplies terminal capability. Guided forms use the rich TUI on a terminal pair and the accessible line prompts of `internal/forms` off-TTY. Consequently, compatibility builds (`interactiveByDefault: true`) retain their existing off-TTY accessible forms, while non-interactive-by-default builds never read stdin for a form off-TTY unless `--interactive` is passed. `configure` and `auth login` resolve through `Decision.SetupFormMode`: run bare on a terminal pair they open the form regardless of `interactiveByDefault`, unless `--no-interactive`, `--interactive=false`, or agent mode is active. `configure --default-output-format` stores the default output format without a form; `--output-format` keeps controlling only the current command's rendering. - Direct `explore` is itself an explicit request, but it rejects `--no-interactive` / `--interactive=false` as `CLI_VALIDATION` (`validation_error`, exit 2) and requires both stdin and stdout to be TTYs before Bubble Tea is called. A missing terminal remains a runtime error (exit 1). Agent mode retains the existing behavior of showing root help instead. - Interception is additive. When prompting is suppressed or no required promptable input is missing, the command keeps its existing behavior exactly: operation request builders report missing flags, bare required-input intents return typed usage help errors, and hand-written commands retain their own errors/help/exit codes. A `speakeasy:prompt-direct` field participates only after a missing required input has already triggered a form. - Required flags are prompted when unresolved from **all** declared sources: an explicit/default-resolving flag, environment variable, config value, or changed whole-body flag for a body-derived field. Resolution checks are pure; environment and config values remain for the request builder to apply with its existing precedence. Piped stdin is deliberately not part of this check because a pipe makes the session non-interactive. @@ -812,7 +814,7 @@ _ = interactive.Declare(cmd, interactive.CommandSpec{Args: []interactive.ArgSpec Tests should use `interactive.WithPrompter(ctx, p)` (or the generated harness's `RunInteractive(args, p)`). The injected prompter substitutes only for the TTY check; it does not bypass a false `--interactive` value, `--no-interactive`, agent mode, usage, or documentation surfaces. The seam exposes stable field IDs (`arg:`, `flag:`), field order, applied values, and exported `PromptField.Direct` without driving Bubble Tea through a pipe. -**Startup cost**: the interactive packages (Bubble Tea, Lip Gloss, huh, bubbles) are the `charm.land/*/v2` modules. Unlike v1, they never query the terminal at package initialization, so every command starts without emitting background-color or cursor-position requests, including on PTYs that never answer. `tests/startup_tty_test.go` runs `version` on an unanswered pseudo-terminal and fails if a query or a stall reappears. +**Startup cost**: the interactive packages (Bubble Tea, Lip Gloss, bubbles) are the `charm.land/*/v2` modules, and `internal/forms` builds on them. Unlike v1, they never query the terminal at package initialization, so every command starts without emitting background-color or cursor-position requests, including on PTYs that never answer. `tests/startup_tty_test.go` runs `version` on an unanswered pseudo-terminal and fails if a query or a stall reappears. **Generator config knobs**: diff --git a/templates/templates/cli/auth.go.stmpl b/templates/templates/cli/auth.go.stmpl index 736dd67b6..ca258d9e6 100644 --- a/templates/templates/cli/auth.go.stmpl +++ b/templates/templates/cli/auth.go.stmpl @@ -2,12 +2,8 @@ {{- addImport "github.com/spf13/cobra" -}} {{- addImport "internal/config" true -}} {{- addImport "internal/flagutil" true -}} -{{- if templateHasSecuritySchemeGroups}}{{- addImport "internal/interactive" true -}}{{- end}} +{{- if templateHasSecuritySchemeGroups}}{{- addImport "internal/interactive" true -}}{{- addImport "internal/forms" true -}}{{- end}} {{- addImport "fmt" -}} -{{- addImport "os" -}} -{{- addImport "golang.org/x/term" -}} -{{- addImport "charm.land/huh/v2" -}} -{{- addImport "charm.land/lipgloss/v2" -}} package cli {{genImports}} @@ -84,7 +80,7 @@ This removes all credentials previously set via auth login or configure.`, return nil } -// runAuthLoginCmd executes the auth login command using huh forms. +// runAuthLoginCmd executes the auth login command using guided forms. func runAuthLoginCmd(cmd *cobra.Command, args []string) error { if dryRunLocalNoop(cmd, "auth login changes local credentials only (no API request); nothing was changed.") { return nil @@ -148,64 +144,3 @@ func runAuthLogoutCmd(cmd *cobra.Command, args []string) error { fmt.Fprintf(out, "Configuration saved to %s\n", config.GetConfigPath()) return nil } - -// authFormTheme builds the form theme for auth login. -func authFormTheme() huh.Theme { - return huh.ThemeFunc(authFormStyles) -} - -func authFormStyles(isDark bool) *huh.Styles { - t := *huh.ThemeBase(isDark) - - accent := {{themeAccent}} - dimmed := {{themeDimmed}} - subtle := {{themeSubtle}} - errColor := {{themeError}} - greenColor := {{themeSuccess}} - - t.Focused.Base = t.Focused.Base. - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(accent). - PaddingLeft(1) - t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true) - t.Focused.Description = t.Focused.Description.Foreground(dimmed).Italic(true) - t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errColor) - t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(greenColor).SetString("✓ ").Bold(true) - t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") - t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) - t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - t.Focused.Next = t.Focused.FocusedButton - - t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) - t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent) - - t.Blurred.Base = t.Blurred.Base. - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(subtle). - PaddingLeft(1) - t.Blurred.Title = t.Blurred.Title.Foreground(dimmed) - t.Blurred.Description = t.Blurred.Description.Foreground(subtle).Italic(true) - t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(dimmed) - t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) - t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - t.Blurred.SelectedPrefix = lipgloss.NewStyle().Foreground(dimmed).SetString("✓ ") - t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") - - return &t -} - -// authFormWidth returns the terminal width for sizing huh forms. -func authFormWidth() int { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - width = 80 - } - return width -} diff --git a/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl new file mode 100644 index 000000000..012ba8fd9 --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl @@ -0,0 +1,763 @@ +{{- if isAnyInteractiveEnabled}} +// Package forms asks the questions behind the CLI's guided setup: pages of +// text, secret, JSON, choice and yes/no fields. Terminals that can redraw get +// an interactive form; everything else gets plain line prompts. +package forms + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "golang.org/x/term" +) + +// ErrUserAborted is returned by Run when the user cancels with ctrl+c. +var ErrUserAborted = errors.New("user aborted") + +var ( + accent = {{themeAccent}} + dimmed = {{themeDimmed}} + subtle = {{themeSubtle}} + errColor = {{themeError}} +) + +type fieldKind int + +const ( + kindInput fieldKind = iota + kindText + kindSelect + kindConfirm +) + +// Option is a choice in a select field: Label is shown, Value is stored. +type Option struct { + Label string + Value string +} + +// NewOption returns a select option. +func NewOption(label, value string) Option { + return Option{Label: label, Value: value} +} + +// Field is one question. Its answer is written to the variable passed to the +// constructor as the user edits it. +type Field struct { + kind fieldKind + title string + description string + validate func(string) error + err error + + text *string // input, text and select answers + yes *bool // confirm answer + secret bool + + options []Option + cursor int + halfPage int // rows moved by ctrl+d and ctrl+u, set from the visible window + + input textinput.Model + area textarea.Model +} + +// NewInput asks for a single line of text, starting from *value. +func NewInput(value *string) *Field { + in := textinput.New() + in.SetValue(*value) + return &Field{kind: kindInput, text: value, input: in} +} + +// textRows is the editor height when the terminal has room for it. +const textRows = 6 + +// NewText asks for multi-line text such as JSON, starting from *value. +// alt+enter or ctrl+j inserts a newline; enter moves on. +func NewText(value *string) *Field { + area := textarea.New() + area.ShowLineNumbers = false + area.Prompt = "" + area.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j") + area.SetHeight(textRows) + area.SetValue(*value) + return &Field{kind: kindText, text: value, area: area} +} + +// NewSelect asks for one of options, starting at the option matching *value. +func NewSelect(value *string, options ...Option) *Field { + f := &Field{kind: kindSelect, text: value, options: options} + for i, o := range options { + if o.Value == *value { + f.cursor = i + break + } + } + if len(options) > 0 { + *value = options[f.cursor].Value + } + return f +} + +// NewConfirm asks a yes/no question, starting from *value. +func NewConfirm(value *bool) *Field { + return &Field{kind: kindConfirm, yes: value} +} + +// Title sets the question. +func (f *Field) Title(title string) *Field { f.title = title; return f } + +// Description sets the hint shown under the question. +func (f *Field) Description(description string) *Field { f.description = description; return f } + +// Placeholder sets the text shown while an input is empty. +func (f *Field) Placeholder(placeholder string) *Field { + f.input.Placeholder = placeholder + return f +} + +// Password hides what is typed into an input. +func (f *Field) Password() *Field { + f.secret = true + f.input.EchoMode = textinput.EchoPassword + return f +} + +// Validate rejects answers before the form moves past the field. +func (f *Field) Validate(validate func(string) error) *Field { + f.validate = validate + return f +} + +func (f *Field) check() error { + if f.text == nil { + return nil + } + return f.checkValue(*f.text) +} + +func (f *Field) checkValue(value string) error { + if f.validate == nil { + return nil + } + return f.validate(value) +} + +func (f *Field) focus() tea.Cmd { + switch f.kind { + case kindInput: + return f.input.Focus() + case kindText: + return f.area.Focus() + } + return nil +} + +func (f *Field) blur() { + switch f.kind { + case kindInput: + f.input.Blur() + case kindText: + f.area.Blur() + } +} + +func (f *Field) setWidth(width int) { + inner := max(width-4, 1) + switch f.kind { + case kindInput: + f.input.SetWidth(max(inner-lipgloss.Width(f.input.Prompt)-1, 1)) + case kindText: + f.area.SetWidth(inner) + } +} + +// update applies a message to the focused field. advance reports that the +// key also answered the question (y or n on a confirm). +func (f *Field) update(msg tea.Msg) (cmd tea.Cmd, advance bool) { + key, isKey := msg.(tea.KeyPressMsg) + if isKey { + f.err = nil + } + switch f.kind { + case kindInput: + f.input, cmd = f.input.Update(msg) + *f.text = f.input.Value() + case kindText: + f.area, cmd = f.area.Update(msg) + *f.text = f.area.Value() + case kindSelect: + if !isKey || len(f.options) == 0 { + return nil, false + } + n := len(f.options) + switch key.String() { + case "up", "k", "ctrl+p": + f.cursor = (f.cursor - 1 + n) % n + case "down", "j", "ctrl+n": + f.cursor = (f.cursor + 1) % n + case "home", "g": + f.cursor = 0 + case "end", "G": + f.cursor = n - 1 + case "ctrl+d": + f.cursor = min(f.cursor+f.halfPage, n-1) + case "ctrl+u": + f.cursor = max(f.cursor-f.halfPage, 0) + } + *f.text = f.options[f.cursor].Value + case kindConfirm: + if !isKey { + return nil, false + } + switch key.String() { + case "left", "right", "h", "l": + *f.yes = !*f.yes + case "y", "Y": + *f.yes = true + return nil, true + case "n", "N": + *f.yes = false + return nil, true + } + } + return cmd, false +} + +// Page is a set of fields shown together under an optional heading. +type Page struct { + title string + fields []*Field +} + +// NewPage groups fields onto one screen. +func NewPage(title string, fields ...*Field) *Page { + return &Page{title: title, fields: fields} +} + +// Form steps through pages of fields. enter and tab move forward, shift+tab +// moves back, and enter on the last field submits. +type Form struct { + pages []*Page + page int + field int + accessible bool + in io.Reader + out io.Writer + width int + height int + scroll int // first rendered line when the page is taller than the terminal + finished bool + aborted bool +} + +// New returns a form over pages. +func New(pages ...*Page) *Form { + f := &Form{pages: pages, in: os.Stdin, out: os.Stdout} + f.resize(terminalWidth(), 0) + return f +} + +// Accessible switches between the interactive form and line prompts. Dumb +// terminals cannot redraw, so they always get line prompts. +func (f *Form) Accessible(on bool) *Form { + f.accessible = on + return f +} + +func (f *Form) linePrompts() bool { + return f.accessible || os.Getenv("TERM") == "dumb" +} + +// IO sets where line prompts read answers and write questions. The +// interactive form also reads keys from in unless it is standard input, and +// always draws on the terminal. +func (f *Form) IO(in io.Reader, out io.Writer) *Form { + f.in, f.out = in, out + return f +} + +func terminalWidth() int { + if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && width > 0 { + return width + } + return 80 +} + +// Run asks every question and returns once the form is submitted. Answers are +// written to the variables bound to each field. +func (f *Form) Run() error { + if f.empty() { + return nil + } + if f.linePrompts() { + return f.prompt(f.out, f.in) + } + opts := []tea.ProgramOption{tea.WithOutput(os.Stderr)} + if in := keyInput(f.in); in != os.Stdin { + opts = append(opts, tea.WithInput(in)) + } + if _, err := tea.NewProgram(f, opts...).Run(); err != nil { + if errors.Is(err, tea.ErrInterrupted) { + return ErrUserAborted + } + return fmt.Errorf("form: %w", err) + } + if f.aborted { + return ErrUserAborted + } + return nil +} + +// keyInput returns where the interactive form reads keys: the file behind a +// shared Reader so a terminal can be put in raw mode, otherwise in itself. +// Standard input is left to bubbletea, which falls back to the terminal when +// stdin is piped. +func keyInput(in io.Reader) io.Reader { + if shared, ok := in.(*Reader); ok && shared.file != nil { + return shared.file + } + return in +} + +func (f *Form) empty() bool { + for _, p := range f.pages { + if len(p.fields) > 0 { + return false + } + } + return true +} + +func (f *Form) resize(width, height int) { + f.width, f.height = width, height + for _, p := range f.pages { + for _, field := range p.fields { + field.setWidth(width) + } + } +} + +func (f *Form) current() *Field { + return f.pages[f.page].fields[f.field] +} + +func (f *Form) onLastField() bool { + _, _, ok := f.neighbour(1) + return !ok +} + +// neighbour finds the field one step away across page boundaries, skipping +// empty pages. ok is false when there is no field in that direction. +func (f *Form) neighbour(delta int) (page, field int, ok bool) { + page, field = f.page, f.field+delta + for { + switch { + case field < 0: + if page--; page < 0 { + return 0, 0, false + } + field = len(f.pages[page].fields) - 1 + case field >= len(f.pages[page].fields): + if page++; page >= len(f.pages) { + return 0, 0, false + } + field = 0 + default: + return page, field, true + } + } +} + +// Init implements [tea.Model]. +func (f *Form) Init() tea.Cmd { + if f.empty() { + return nil + } + for f.field >= len(f.pages[f.page].fields) { + f.page++ + } + return tea.Batch(f.current().focus(), tea.RequestWindowSize) +} + +// Update implements [tea.Model]. +func (f *Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + f.resize(msg.Width, msg.Height) + return f, nil + case tea.KeyPressMsg: + switch msg.String() { + case "ctrl+c": + f.aborted = true + return f, tea.Quit + case "shift+tab": + return f, f.move(-1) + case "tab": + if f.onLastField() { + return f, nil + } + return f, f.forward() + case "enter": + return f, f.forward() + } + } + cmd, advance := f.current().update(msg) + if advance { + return f, tea.Batch(cmd, f.forward()) + } + return f, cmd +} + +// forward validates the focused field, then moves on or submits. +func (f *Form) forward() tea.Cmd { + cur := f.current() + if cur.err = cur.check(); cur.err != nil { + return nil + } + if f.onLastField() { + f.finished = true + return tea.Quit + } + return f.move(1) +} + +func (f *Form) move(delta int) tea.Cmd { + page, field, ok := f.neighbour(delta) + if !ok { + return nil + } + f.current().blur() + f.page, f.field = page, field + return f.current().focus() +} + +// View implements [tea.Model]. +func (f *Form) View() tea.View { + if f.finished || f.aborted { + return tea.NewView("") + } + return tea.NewView(f.render()) +} + +func (f *Form) render() string { + page := f.pages[f.page] + var lines []string + if page.title != "" { + title := wrap(page.title, max(f.width, 1)) + lines = append(lines, strings.Split(lipgloss.NewStyle().Foreground(accent).Bold(true).Render(title), "\n")...) + lines = append(lines, "") + } + focusStart, focusEnd := 0, 0 + for i, field := range page.fields { + if i > 0 { + lines = append(lines, "") + } + if i == f.field { + focusStart = len(lines) + } + block, errRows := f.renderField(field, i == f.field) + lines = append(lines, strings.Split(block, "\n")...) + if i == f.field { + focusEnd = len(lines) + if f.height > 0 && focusEnd-focusStart > f.height && errRows > 1 { + // Too tall for the terminal: keep the input in view with the + // first line of its error under it. + focusEnd -= errRows - 1 + } + } + } + if f.field == 0 && (f.height <= 0 || focusEnd <= f.height) { + focusStart = 0 // keep the page title in view with the first field + } + return strings.Join(f.scrollTo(lines, focusStart, focusEnd), "\n") +} + +// scrollTo returns the lines that fit the terminal, moving the window as +// little as possible to show lines[start:end]. A block taller than the +// terminal shows its end, where the input is. +func (f *Form) scrollTo(lines []string, start, end int) []string { + if f.height <= 0 || len(lines) <= f.height { + f.scroll = 0 + return lines + } + if end-start > f.height { + f.scroll = end - f.height + } else if start < f.scroll { + f.scroll = start + } else if end > f.scroll+f.height { + f.scroll = end - f.height + } + f.scroll = min(max(f.scroll, 0), len(lines)-f.height) + return lines[f.scroll : f.scroll+f.height] +} + +// renderField draws a field inside its border and reports how many of the +// rows at its end are the validation error. +func (f *Form) renderField(field *Field, focused bool) (string, int) { + inner := max(f.width-4, 1) + bar, title := subtle, lipgloss.NewStyle().Foreground(dimmed) + if focused { + bar, title = accent, lipgloss.NewStyle().Foreground(accent).Bold(true) + } + lines := []string{} + if field.title != "" { + lines = append(lines, title.Render(wrap(field.title, inner))) + } + if field.description != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(subtle).Italic(true).Render(wrap(field.description, inner))) + } + var errLines []string + if field.err != nil { + errLines = append(errLines, lipgloss.NewStyle().Foreground(errColor).Render(wrap("! "+field.err.Error(), inner))) + } + // Rows left for the editor or options once the heading and error fit. + avail := f.height - rows(lines) - rows(errLines) + switch field.kind { + case kindInput: + lines = append(lines, field.input.View()) + case kindText: + if f.height > 0 { + field.area.SetHeight(min(max(avail, 1), textRows)) + } + lines = append(lines, field.area.View()) + case kindSelect: + lines = append(lines, f.renderOptions(field, focused, avail-1)) // minus the position line + case kindConfirm: + lines = append(lines, renderButtons(*field.yes, focused, inner)) + } + lines = append(lines, errLines...) + return lipgloss.NewStyle(). + BorderStyle(lipgloss.ThickBorder()).BorderLeft(true).BorderForeground(bar). + PaddingLeft(1). + Render(strings.Join(lines, "\n")), rows(errLines) +} + +func rows(blocks []string) int { + n := 0 + for _, b := range blocks { + n += lipgloss.Height(b) + } + return n +} + +// renderOptions shows the options around the cursor that fit the terminal +// height. Multi-line labels stay aligned under their first line. +func (f *Form) renderOptions(field *Field, focused bool, height int) string { + picked := lipgloss.NewStyle().Foreground(dimmed) + if focused { + picked = lipgloss.NewStyle().Foreground(accent).Bold(true) + } + labelWidth := max(f.width-6, 1) + blocks := make([]string, len(field.options)) + for i, o := range field.options { + label := strings.ReplaceAll(wrap(o.Label, labelWidth), "\n", "\n ") + if i == field.cursor { + blocks[i] = picked.Render("> " + label) + } else { + blocks[i] = " " + label + } + } + + budget := 0 + if f.height > 0 { + budget = max(height, 1) + } + start, end := 0, len(blocks) + if budget > 0 && len(blocks) > 0 { + start, end = field.cursor, field.cursor+1 + used := lipgloss.Height(blocks[field.cursor]) + for grew := true; grew; { + grew = false + if end < len(blocks) && used+lipgloss.Height(blocks[end]) <= budget { + used += lipgloss.Height(blocks[end]) + end++ + grew = true + } + if start > 0 && used+lipgloss.Height(blocks[start-1]) <= budget { + start-- + used += lipgloss.Height(blocks[start]) + grew = true + } + } + } + field.halfPage = max((end-start)/2, 1) + + out := strings.Join(blocks[start:end], "\n") + if start > 0 || end < len(blocks) { + out += "\n" + lipgloss.NewStyle().Foreground(subtle).Render(fmt.Sprintf(" %d/%d", field.cursor+1, len(blocks))) + } + return out +} + +func renderButtons(yes, focused bool, width int) string { + pad, yesLabel, noLabel := 2, "Yes", "No" + if width < 14 { + pad = 0 + } + if width < 6 { + yesLabel, noLabel = "Y", "N" + } + on := lipgloss.NewStyle().Padding(0, pad).Foreground(lipgloss.Color("#FFFFFF")).Background(subtle) + if focused { + on = on.Background(accent) + } + off := lipgloss.NewStyle().Padding(0, pad).Foreground(dimmed) + if yes { + return on.Render(yesLabel) + " " + off.Render(noLabel) + } + return off.Render(yesLabel) + " " + on.Render(noLabel) +} + +// Reader buffers answers for line prompts. Pass the same Reader to every form +// a command runs over one input so that answers read ahead by one form are +// left for the next. +type Reader struct { + buf *bufio.Reader + file *os.File // the terminal secrets are read from without echo +} + +// NewReader wraps r for sharing between forms. A Reader is returned as is. +func NewReader(r io.Reader) *Reader { + if shared, ok := r.(*Reader); ok { + return shared + } + file, _ := r.(*os.File) + return &Reader{buf: bufio.NewReader(r), file: file} +} + +// Read implements [io.Reader]. +func (r *Reader) Read(p []byte) (int, error) { return r.buf.Read(p) } + +// prompt asks every question as a plain line prompt. Blank answers keep the +// current value; end of input leaves the remaining answers unchanged. +func (f *Form) prompt(w io.Writer, r io.Reader) error { + in := NewReader(r) + for _, page := range f.pages { + for _, field := range page.fields { + err := field.ask(w, in) + fmt.Fprintln(w) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + } + } + return nil +} + +// ask prompts for one field until the answer is valid. It returns io.EOF at +// end of input. +func (f *Field) ask(w io.Writer, in *Reader) error { + if f.title != "" { + fmt.Fprintln(w, f.title) + } + if f.description != "" { + fmt.Fprintln(w, f.description) + } + switch f.kind { + case kindSelect: + for i, o := range f.options { + fmt.Fprintf(w, " %d. %s\n", i+1, o.Label) + } + } + for { + switch f.kind { + case kindSelect: + fmt.Fprintf(w, "Choose 1-%d [%d]: ", len(f.options), f.cursor+1) + case kindConfirm: + if *f.yes { + fmt.Fprint(w, "[Y/n]: ") + } else { + fmt.Fprint(w, "[y/N]: ") + } + default: + fmt.Fprint(w, "> ") + } + line, err := readAnswer(w, in, f.secret) + if err != nil { + return err + } + if msg := f.answer(line); msg != "" { + fmt.Fprintln(w, msg) + continue + } + return nil + } +} + +// answer applies a typed line, returning a message when it must be re-asked. +func (f *Field) answer(line string) string { + blank := strings.TrimSpace(line) == "" + if f.kind == kindConfirm { + switch strings.ToLower(strings.TrimSpace(line)) { + case "": + case "y", "yes": + *f.yes = true + case "n", "no": + *f.yes = false + default: + return "Answer y or n." + } + return "" + } + value, pick := *f.text, f.cursor + if f.kind == kindSelect { + if len(f.options) == 0 { + return "" + } + if !blank { + n, err := strconv.Atoi(strings.TrimSpace(line)) + if err != nil || n < 1 || n > len(f.options) { + return fmt.Sprintf("Enter a number from 1 to %d.", len(f.options)) + } + pick = n - 1 + } + value = f.options[pick].Value + } else if !blank { + value = line + } + if err := f.checkValue(value); err != nil { + return err.Error() + } + f.cursor = pick + *f.text = value + return "" +} + +// readAnswer reads one line, without echo for secrets typed at a terminal. +// It returns io.EOF only when no answer was read. +func readAnswer(w io.Writer, in *Reader, secret bool) (string, error) { + if in.file != nil && secret && term.IsTerminal(int(in.file.Fd())) { + b, err := term.ReadPassword(int(in.file.Fd())) + fmt.Fprintln(w) + return string(b), err + } + line, err := in.buf.ReadString('\n') + if err != nil && (line == "" || !errors.Is(err, io.EOF)) { + return "", err + } + return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil +} + +// wrap breaks s at spaces to fit width, without trailing spaces that some +// terminals scroll on in the last column. +func wrap(s string, width int) string { + lines := strings.Split(lipgloss.Wrap(s, width, " "), "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " ") + } + return strings.Join(lines, "\n") +} +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl b/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl index 5ae75e8fa..d5fd0e64b 100644 --- a/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl @@ -13,14 +13,13 @@ import ( "strings" "time" - "charm.land/huh/v2" "charm.land/lipgloss/v2" "github.com/spf13/cobra" "github.com/spf13/pflag" - "golang.org/x/term" "{{getGolangPackage}}/internal/config" "{{getGolangPackage}}/internal/flagutil" + "{{getGolangPackage}}/internal/forms" "{{getGolangPackage}}/internal/output" "{{getGolangPackage}}/internal/usage" ) @@ -204,7 +203,7 @@ func interceptCommand(cmd *cobra.Command) { if missing { prompter := injectedPrompter(runCmd) if prompter == nil { - prompter = huhPrompter{} + prompter = formPrompter{} } answers, err := prompter.Prompt(runCmd, fields) if err != nil { @@ -610,57 +609,6 @@ func applyAnswers(cmd *cobra.Command, args []string, fields []PromptField, answe return effective, nil } -// formWidth returns the terminal width for sizing huh forms, with a sensible default. -func formWidth() int { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - width = 80 - } - return width -} - -// formTheme builds the interactive prompt theme. -func formTheme() huh.Theme { - return huh.ThemeFunc(formStyles) -} - -func formStyles(isDark bool) *huh.Styles { - t := *huh.ThemeBase(isDark) - - accent := {{themeAccent}} - dimmed := {{themeDimmed}} - subtle := {{themeSubtle}} - errColor := {{themeError}} - greenColor := {{themeSuccess}} - - t.Focused.Base = t.Focused.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(accent).PaddingLeft(1) - t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true) - t.Focused.Description = t.Focused.Description.Foreground(dimmed).Italic(true) - t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errColor) - t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(greenColor).SetString("✓ ").Bold(true) - t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") - t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) - t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - t.Focused.Next = t.Focused.FocusedButton - t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) - t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent) - - t.Blurred.Base = t.Blurred.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(subtle).PaddingLeft(1) - t.Blurred.Title = t.Blurred.Title.Foreground(dimmed) - t.Blurred.Description = t.Blurred.Description.Foreground(subtle).Italic(true) - t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(dimmed) - t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) - t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - t.Blurred.SelectedPrefix = lipgloss.NewStyle().Foreground(dimmed).SetString("✓ ") - t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") - return &t -} - func printCommandHeader(cmd *cobra.Command, totalRequired, totalOptional int) { accent := {{themeAccent}} dimmed := {{themeDimmed}} @@ -688,9 +636,9 @@ func printCommandHeader(cmd *cobra.Command, totalRequired, totalOptional int) { fmt.Fprintln(os.Stderr) } -type huhPrompter struct{} +type formPrompter struct{} -func (huhPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAnswer, error) { +func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAnswer, error) { answers := make([]PromptAnswer, len(fields)) required := make([]int, 0, len(fields)) optionalArgs := make([]int, 0, len(fields)) @@ -708,9 +656,10 @@ func (huhPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAns } } printCommandHeader(cmd, len(required), len(optionalArgs)+len(direct)+len(optionalFlags)) - theme, width := formTheme(), formWidth() + // Every prompt reads from one reader so piped answers carry over. + in := forms.NewReader(cmd.InOrStdin()) for _, index := range required { - answer, err := runPromptField(fields[index], theme, width) + answer, err := runPromptField(cmd, in, fields[index]) if err != nil { return nil, err } @@ -719,14 +668,14 @@ func (huhPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAns for _, index := range optionalArgs { field := fields[index] field.Summary = optionalArgDescription(field.Summary) - answer, err := runPromptField(field, theme, width) + answer, err := runPromptField(cmd, in, field) if err != nil { return nil, err } answers[index] = answer } for _, index := range direct { - answer, err := runPromptField(fields[index], theme, width) + answer, err := runPromptField(cmd, in, fields[index]) if err != nil { return nil, err } @@ -740,16 +689,15 @@ func (huhPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAns for _, index := range optionalFlags { names = append(names, fields[index].Name) } - form := huh.NewForm(huh.NewGroup(huh.NewConfirm(). + form := forms.New(forms.NewPage("", forms.NewConfirm(&fillOptional). Title(fmt.Sprintf("Fill in %d optional field(s)?", len(optionalFlags))). - Description(strings.Join(names, ", ")).Value(&fillOptional))). - WithWidth(width).WithTheme(theme).WithShowHelp(false) + Description(strings.Join(names, ", ")))).IO(in, cmd.ErrOrStderr()) if err := form.Run(); err != nil { return nil, err } if fillOptional { for _, index := range optionalFlags { - answer, err := runPromptField(fields[index], theme, width) + answer, err := runPromptField(cmd, in, fields[index]) if err != nil { return nil, err } @@ -766,119 +714,65 @@ func optionalArgDescription(summary string) string { return summary + " · optional, leave empty to skip" } -func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer, error) { +func runPromptField(cmd *cobra.Command, in *forms.Reader, field PromptField) (PromptAnswer, error) { if field.Repeatable && field.target == promptTargetFlag { - return runRepeatableFlagPrompt(field, theme, width) + return runRepeatableFlagPrompt(cmd, in, field) + } + + ask := func(f *forms.Field) error { + return forms.New(forms.NewPage("", f.Title(field.Name).Description(field.Summary))).IO(in, cmd.ErrOrStderr()).Run() } - var answer PromptAnswer - var formField huh.Field switch field.Kind { case "bool": if field.Required { value := false - formField = huh.NewConfirm().Title(field.Name).Description(field.Summary).Value(&value) - answer.Set = true - answer.Values = []string{strconv.FormatBool(value)} - form := huh.NewForm(huh.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) - if err := form.Run(); err != nil { + if err := ask(forms.NewConfirm(&value)); err != nil { return PromptAnswer{}, err } - answer.Values[0] = strconv.FormatBool(value) - return answer, nil + return PromptAnswer{Set: true, Values: []string{strconv.FormatBool(value)}}, nil } value := "" - formField = huh.NewSelect[string]().Title(field.Name).Description(field.Summary).Options( - huh.NewOption("Yes", "true"), huh.NewOption("No", "false"), huh.NewOption("Skip (use default)", ""), - ).Value(&value) - form := huh.NewForm(huh.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) - if err := form.Run(); err != nil { + if err := ask(forms.NewSelect(&value, + forms.NewOption("Yes", "true"), forms.NewOption("No", "false"), forms.NewOption("Skip (use default)", ""), + )); err != nil { return PromptAnswer{}, err } return PromptAnswer{Set: value != "", Values: []string{value}}, nil case "enum": value := "" - options := make([]huh.Option[string], 0, len(field.Options)+1) + options := make([]forms.Option, 0, len(field.Options)+1) if !field.Required { - options = append(options, huh.NewOption[string]("Skip (use default)", "")) + options = append(options, forms.NewOption("Skip (use default)", "")) } for _, option := range field.Options { - options = append(options, huh.NewOption(option, option)) + options = append(options, forms.NewOption(option, option)) } - formField = huh.NewSelect[string]().Title(field.Name).Description(field.Summary).Options(options...).Value(&value) - form := huh.NewForm(huh.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) - if err := form.Run(); err != nil { + if err := ask(forms.NewSelect(&value, options...)); err != nil { return PromptAnswer{}, err } return PromptAnswer{Set: value != "", Values: []string{value}}, nil } value := "" - description := field.Summary - var validate func(string) error - if field.Required { - validate = func(value string) error { - if strings.TrimSpace(value) == "" { + var formField *forms.Field + if field.Kind == "json" { + formField = forms.NewText(&value) + field.Summary += " (JSON)" + } else { + formField = forms.NewInput(&value) + } + formField.Validate(func(value string) error { + if strings.TrimSpace(value) == "" { + if field.Required { return fmt.Errorf("required") } return nil } - } - switch field.Kind { - case "json": - formField = huh.NewText().Title(field.Name).Description(description + " (JSON)").Value(&value) - if validate != nil { - formField = formField.(*huh.Text).Validate(validate) - } - case "int64": - formField = huh.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { - if strings.TrimSpace(value) == "" { - if validate != nil { - return validate(value) - } - return nil - } - if _, err := strconv.ParseInt(value, 10, 64); err != nil { - return fmt.Errorf("must be an integer") - } - return nil - }) - case "float64": - formField = huh.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { - if strings.TrimSpace(value) == "" { - if validate != nil { - return validate(value) - } - return nil - } - if _, err := strconv.ParseFloat(value, 64); err != nil { - return fmt.Errorf("must be a number") - } - return nil - }) - case "duration": - formField = huh.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { - if strings.TrimSpace(value) == "" { - if validate != nil { - return validate(value) - } - return nil - } - if _, err := time.ParseDuration(value); err != nil { - return fmt.Errorf("must be a duration") - } - return nil - }) - default: - input := huh.NewInput().Title(field.Name).Description(description).Value(&value) - if validate != nil { - input = input.Validate(validate) - } - formField = input - } - form := huh.NewForm(huh.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) - if err := form.Run(); err != nil { + return checkPromptValue(field.Kind, value) + }) + if err := ask(formField); err != nil { return PromptAnswer{}, err } if strings.TrimSpace(value) == "" { @@ -887,7 +781,26 @@ func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer return PromptAnswer{Set: true, Values: []string{value}}, nil } -func runRepeatableFlagPrompt(field PromptField, theme huh.Theme, width int) (PromptAnswer, error) { +// checkPromptValue rejects a non-empty answer that does not parse as kind. +func checkPromptValue(kind, value string) error { + switch kind { + case "int64": + if _, err := strconv.ParseInt(value, 10, 64); err != nil { + return fmt.Errorf("must be an integer") + } + case "float64": + if _, err := strconv.ParseFloat(value, 64); err != nil { + return fmt.Errorf("must be a number") + } + case "duration": + if _, err := time.ParseDuration(value); err != nil { + return fmt.Errorf("must be a duration") + } + } + return nil +} + +func runRepeatableFlagPrompt(cmd *cobra.Command, in *forms.Reader, field PromptField) (PromptAnswer, error) { values := make([]string, 0, 1) for { value := "" @@ -897,7 +810,7 @@ func runRepeatableFlagPrompt(field PromptField, theme huh.Theme, width int) (Pro title = "Add another " + displayName(strings.TrimSuffix(field.Name, " (required)"), "", false) + "?" description = "Leave empty to finish." } - input := huh.NewInput().Title(title).Description(description).Value(&value) + input := forms.NewInput(&value).Title(title).Description(description) if field.Required && len(values) == 0 { input = input.Validate(func(value string) error { if strings.TrimSpace(value) == "" { @@ -906,8 +819,7 @@ func runRepeatableFlagPrompt(field PromptField, theme huh.Theme, width int) (Pro return nil }) } - form := huh.NewForm(huh.NewGroup(input)).WithWidth(width).WithTheme(theme).WithShowHelp(false) - if err := form.Run(); err != nil { + if err := forms.New(forms.NewPage("", input)).IO(in, cmd.ErrOrStderr()).Run(); err != nil { return PromptAnswer{}, err } if strings.TrimSpace(value) == "" { diff --git a/templates/templates/cli/config.ts b/templates/templates/cli/config.ts index 221661ce6..a08f591eb 100644 --- a/templates/templates/cli/config.ts +++ b/templates/templates/cli/config.ts @@ -80,14 +80,6 @@ function getTemplateDependencies(): Record { ecosystem: "Go", category: "runtime", }, - "charm.land/huh/v2": { - name: "charm.land/huh/v2", - version: "v2.0.3", - cpe: "cpe:2.3:a:charmbracelet:huh:*:*:*:*:*:go:*:*", - ecosystem: "Go", - category: "runtime", - condition: "interactive", - }, "charm.land/lipgloss/v2": { name: "charm.land/lipgloss/v2", version: "v2.0.6", @@ -102,7 +94,7 @@ function getTemplateDependencies(): Record { cpe: "cpe:2.3:a:charmbracelet:bubbletea:*:*:*:*:*:go:*:*", ecosystem: "Go", category: "runtime", - condition: "interactiveMode", + condition: "interactive", }, "charm.land/bubbles/v2": { name: "charm.land/bubbles/v2", @@ -110,7 +102,7 @@ function getTemplateDependencies(): Record { cpe: "cpe:2.3:a:charmbracelet:bubbles:*:*:*:*:*:go:*:*", ecosystem: "Go", category: "runtime", - condition: "interactiveMode", + condition: "interactive", }, "github.com/creack/pty": { name: "github.com/creack/pty", @@ -433,7 +425,7 @@ function getConfigFields( Required: false, DefaultValue: true, Description: - "Generate interactive auth login flows using bubbletea/huh forms.", + "Generate interactive auth login flows using bubbletea forms.", ValidationRegex: /^(true|false)$/.source, ValidationMessage: "true or false only", }, diff --git a/templates/templates/cli/configure.go.stmpl b/templates/templates/cli/configure.go.stmpl index 1258e86f7..1bdfeeb96 100644 --- a/templates/templates/cli/configure.go.stmpl +++ b/templates/templates/cli/configure.go.stmpl @@ -14,10 +14,7 @@ {{- addImport "fmt" -}} {{- addImport "encoding/json" -}} {{- if isInteractiveAuthEnabled -}} -{{- addImport "os" -}} -{{- addImport "golang.org/x/term" -}} -{{- addImport "charm.land/huh/v2" -}} -{{- addImport "charm.land/lipgloss/v2" -}} +{{- addImport "internal/forms" true -}} {{- else -}} {{- addImport "bufio" -}} {{- addImport "strings" -}} @@ -78,37 +75,33 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { {{- end}} accessible := formMode == interactive.FormAccessible - var groups []*huh.Group + var pages []*forms.Page {{- if hasGlobalSecurity}} - securityFields := []huh.Field{ + securityFields := []*forms.Field{ {{templateAuthLoginFormFields | indent 12 | trim}} } - groups = append(groups, huh.NewGroup(securityFields...).Title("Authentication")) + pages = append(pages, forms.NewPage("Authentication", securityFields...)) {{- end}} {{- if hasGlobals}} - globalFields := []huh.Field{ + globalFields := []*forms.Field{ {{templateConfigureGlobalFormFields | indent 12 | trim}} } - groups = append(groups, huh.NewGroup(globalFields...).Title("Global Parameters")) + pages = append(pages, forms.NewPage("Global Parameters", globalFields...)) {{- end}} - // Preference fields use huh.Select which loops forever on EOF in - // accessible mode (non-TTY). Only show them when truly interactive. + // Preferences are only offered in the interactive form; line prompts + // cover credentials and global parameters. {{templateConfigurePreferenceVarDeclarations | indent 8 | trim}} if !accessible { - preferenceFields := []huh.Field{ + preferenceFields := []*forms.Field{ {{templateConfigurePreferenceFormFields | indent 16 | trim}} } - groups = append(groups, huh.NewGroup(preferenceFields...).Title("Preferences")) + pages = append(pages, forms.NewPage("Preferences", preferenceFields...)) } - form := huh.NewForm(groups...). - WithAccessible(accessible). - WithTheme(configureFormTheme()). - WithWidth(configureFormWidth()). - WithShowHelp(false) + form := forms.New(pages...).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("configure: %w", err) @@ -202,66 +195,3 @@ func dryRunLocalNoop(cmd *cobra.Command, message string) bool { } return true } - -{{- if isInteractiveAuthEnabled}} -// configureFormTheme builds the form theme for the configure command. -func configureFormTheme() huh.Theme { - return huh.ThemeFunc(configureFormStyles) -} - -func configureFormStyles(isDark bool) *huh.Styles { - t := *huh.ThemeBase(isDark) - - accent := {{themeAccent}} - dimmed := {{themeDimmed}} - subtle := {{themeSubtle}} - errColor := {{themeError}} - greenColor := {{themeSuccess}} - - t.Focused.Base = t.Focused.Base. - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(accent). - PaddingLeft(1) - t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true) - t.Focused.Description = t.Focused.Description.Foreground(dimmed).Italic(true) - t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errColor) - t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(greenColor).SetString("✓ ").Bold(true) - t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") - t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) - t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - t.Focused.Next = t.Focused.FocusedButton - - t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) - t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent) - - t.Blurred.Base = t.Blurred.Base. - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(subtle). - PaddingLeft(1) - t.Blurred.Title = t.Blurred.Title.Foreground(dimmed) - t.Blurred.Description = t.Blurred.Description.Foreground(subtle).Italic(true) - t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(dimmed) - t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) - t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - t.Blurred.SelectedPrefix = lipgloss.NewStyle().Foreground(dimmed).SetString("✓ ") - t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") - - return &t -} - -// configureFormWidth returns the terminal width for sizing huh forms. -func configureFormWidth() int { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - width = 80 - } - return width -} -{{- end}} diff --git a/templates/templates/cli/includes/dependencies.ts b/templates/templates/cli/includes/dependencies.ts index 45b87fa2c..66718edec 100644 --- a/templates/templates/cli/includes/dependencies.ts +++ b/templates/templates/cli/includes/dependencies.ts @@ -362,8 +362,10 @@ function getDefaultDependencies(): Record { // Interactive mode dependencies (Charm ecosystem) if (isInteractiveAuthEnabled() || isInteractiveModeEnabled()) { - defaultDependencies["charm.land/huh/v2"] = - deps["charm.land/huh/v2"].version; + defaultDependencies["charm.land/bubbletea/v2"] = + deps["charm.land/bubbletea/v2"].version; + defaultDependencies["charm.land/bubbles/v2"] = + deps["charm.land/bubbles/v2"].version; defaultDependencies["charm.land/lipgloss/v2"] = deps["charm.land/lipgloss/v2"].version; if ( @@ -374,12 +376,6 @@ function getDefaultDependencies(): Record { deps["github.com/creack/pty"].version; } } - if (isInteractiveModeEnabled()) { - defaultDependencies["charm.land/bubbletea/v2"] = - deps["charm.land/bubbletea/v2"].version; - defaultDependencies["charm.land/bubbles/v2"] = - deps["charm.land/bubbles/v2"].version; - } return defaultDependencies; } diff --git a/templates/templates/cli/includes/security.ts b/templates/templates/cli/includes/security.ts index 2d3c9124c..fcc491da3 100644 --- a/templates/templates/cli/includes/security.ts +++ b/templates/templates/cli/includes/security.ts @@ -2152,23 +2152,21 @@ registerTemplateFunc( function templateConfigurePreferenceFormFields(): string { const lines: string[] = []; - lines.push(`huh.NewSelect[string]().`); - lines.push(` Title("Default output format").`); + lines.push(`forms.NewSelect(&cfgOutputFormat,`); + lines.push(` forms.NewOption("Keep current", ""),`); lines.push( - ` Description("Choose the default response rendering format for this CLI").`, + ` forms.NewOption("Clear (use built-in default: pretty)", "__CLEAR__"),`, ); - lines.push(` Options(`); - lines.push(` huh.NewOption("Keep current", ""),`); + lines.push(` forms.NewOption("pretty", "pretty"),`); + lines.push(` forms.NewOption("json", "json"),`); + lines.push(` forms.NewOption("yaml", "yaml"),`); + lines.push(` forms.NewOption("table", "table"),`); + lines.push(` forms.NewOption("toon", "toon"),`); + lines.push(`).`); + lines.push(` Title("Default output format").`); lines.push( - ` huh.NewOption("Clear (use built-in default: pretty)", "__CLEAR__"),`, + ` Description("Choose the default response rendering format for this CLI"),`, ); - lines.push(` huh.NewOption("pretty", "pretty"),`); - lines.push(` huh.NewOption("json", "json"),`); - lines.push(` huh.NewOption("yaml", "yaml"),`); - lines.push(` huh.NewOption("table", "table"),`); - lines.push(` huh.NewOption("toon", "toon"),`); - lines.push(` ).`); - lines.push(` Value(&cfgOutputFormat),`); return lines.join("\n "); } registerTemplateFunc( @@ -2284,7 +2282,7 @@ registerTemplateFunc( /** * Generate Go variable declarations for the auth login form. - * Each security field gets a string variable that huh binds to. + * Each security field gets a string variable that the form binds to. */ function templateAuthLoginVarDeclarations(): string { const fields = getCLISecurityFields(); @@ -2303,70 +2301,10 @@ registerTemplateFunc( ); /** - * Generate huh form field construction for the auth login form. - * Each security field is mapped to the appropriate huh component: - * - Secrets → huh.NewInput().EchoMode(huh.EchoModePassword) - * - Non-secrets → huh.NewInput() - * - Arrays → huh.NewInput() with comma-separated hint + * Generate form fields for every CLI security field (configure command). */ function templateAuthLoginFormFields(): string { - const fields = getCLISecurityFields(); - if (fields.length === 0) return ""; - - const acc = securityConfigAccessor(); - const lines: string[] = []; - - for (const field of fields) { - const varName = `auth${caser().ToPascal(field.field.Name)}`; - const title = escapeGoString(field.description); - const fieldName = caser().ToPascal(field.field.Name); - const cfgExpr = `${acc}${fieldName}`; - - if (field.isArray) { - lines.push(`huh.NewInput().`); - lines.push(` Title("${title}").`); - lines.push( - ` Description("${buildFormDescription( - field.flagName, - field.field, - "(comma-separated)", - )}").`, - ); - lines.push(` Value(&${varName}),`); - } else if (field.isSecret) { - lines.push(`huh.NewInput().`); - lines.push(` Title("${title}").`); - lines.push( - ` Description("${buildFormDescription( - field.flagName, - field.field, - )}").`, - ); - lines.push(` EchoMode(huh.EchoModePassword).`); - lines.push( - ` Placeholder(maskSecret(${buildFormPlaceholder( - `config.GetStoredSecret("${field.flagName}", ${cfgExpr})`, - field.field, - )})).`, - ); - lines.push(` Value(&${varName}),`); - } else { - lines.push(`huh.NewInput().`); - lines.push(` Title("${title}").`); - lines.push( - ` Description("${buildFormDescription( - field.flagName, - field.field, - )}").`, - ); - lines.push( - ` Placeholder(${buildFormPlaceholder(cfgExpr, field.field)}).`, - ); - lines.push(` Value(&${varName}),`); - } - } - - return lines.join("\n "); + return genSchemeFormFields(getCLISecurityFields()).join("\n "); } registerTemplateFunc( "templateAuthLoginFormFields", @@ -2596,18 +2534,16 @@ function templateAuthLoginBody(): string { lines.push(`// #region custom-auth-vars`); lines.push(`// #endregion custom-auth-vars`); lines.push(``); - lines.push(`fields := []huh.Field{`); + lines.push(`fields := []*forms.Field{`); lines.push(...genSchemeFormFields(group.fields)); lines.push(`}`); lines.push(``); lines.push(`// #region custom-auth-groups`); lines.push(`// #endregion custom-auth-groups`); lines.push(``); - lines.push(`form := huh.NewForm(huh.NewGroup(fields...)).`); - lines.push(` WithAccessible(accessible).`); - lines.push(` WithTheme(authFormTheme()).`); - lines.push(` WithWidth(authFormWidth()).`); - lines.push(` WithShowHelp(false)`); + lines.push( + `form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout())`, + ); lines.push(``); lines.push(`if err := form.Run(); err != nil {`); lines.push(` return fmt.Errorf("auth login: %w", err)`); @@ -2615,26 +2551,26 @@ function templateAuthLoginBody(): string { lines.push(``); lines.push(...genSchemeStore(group.fields)); } else { - // Multiple OR alternatives — show scheme selector first + // Multiple OR alternatives — show scheme selector first. Both forms read + // line prompts from one shared reader so piped answers carry over. lines.push(`accessible := formMode == interactive.FormAccessible`); + lines.push(`promptInput := forms.NewReader(cmd.InOrStdin())`); lines.push(``); lines.push(`var selectedScheme string`); - lines.push(`schemeSelect := huh.NewSelect[string]().`); - lines.push(` Title("Authentication Method").`); - lines.push(` Description("Choose which credentials to configure").`); - lines.push(` Options(`); + lines.push(`schemeSelect := forms.NewSelect(&selectedScheme,`); for (const group of groups) { lines.push( - ` huh.NewOption("${escapeGoString(group.label)}", "${ + ` forms.NewOption("${escapeGoString(group.label)}", "${ group.key }"),`, ); } - lines.push(` ).`); - lines.push(` Value(&selectedScheme)`); + lines.push(`).`); + lines.push(` Title("Authentication Method").`); + lines.push(` Description("Choose which credentials to configure")`); lines.push(``); lines.push( - `if err := huh.NewForm(huh.NewGroup(schemeSelect)).WithAccessible(accessible).WithTheme(authFormTheme()).WithWidth(authFormWidth()).WithShowHelp(false).Run(); err != nil {`, + `if err := forms.New(forms.NewPage("", schemeSelect)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()).Run(); err != nil {`, ); lines.push(` return fmt.Errorf("auth login: %w", err)`); lines.push(`}`); @@ -2648,17 +2584,15 @@ function templateAuthLoginBody(): string { lines.push(`case "${group.key}":`); lines.push(...genSchemeVarDecls(group.fields).map((l) => ` ${l}`)); lines.push(``); - lines.push(` fields := []huh.Field{`); + lines.push(` fields := []*forms.Field{`); lines.push( ...genSchemeFormFields(group.fields).map((l) => ` ${l}`), ); lines.push(` }`); lines.push(``); - lines.push(` form := huh.NewForm(huh.NewGroup(fields...)).`); - lines.push(` WithAccessible(accessible).`); - lines.push(` WithTheme(authFormTheme()).`); - lines.push(` WithWidth(authFormWidth()).`); - lines.push(` WithShowHelp(false)`); + lines.push( + ` form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout())`, + ); lines.push(``); lines.push(` if err := form.Run(); err != nil {`); lines.push(` return fmt.Errorf("auth login: %w", err)`); @@ -2814,7 +2748,7 @@ function genSchemeVarDecls(fields: CLISecurityFieldInfo[]): string[] { } /** - * Build the Description() string for a huh form field. + * Build the Description() string for a form field. * Includes the flag name and appends example from the spec if available. */ function buildFormDescription( @@ -2835,7 +2769,7 @@ function buildFormDescription( } /** - * Build the Placeholder() Go expression for a huh form field. + * Build the Placeholder() Go expression for a form field. * Falls back to the field's default value when the stored config value is empty. */ function buildFormPlaceholder(configExpr: string, field: FieldDef): string { @@ -2851,59 +2785,51 @@ function buildFormPlaceholder(configExpr: string, field: FieldDef): string { return configExpr; } -/** Generate huh form field construction for a set of scheme fields */ +/** + * Render a Go builder chain as one composite-literal element: + * `head.\n call1.\n call2,`. + */ +function goChain(head: string, calls: string[]): string[] { + return [ + `${head}.`, + ...calls.map((c, i) => ` ${c}${i === calls.length - 1 ? "," : "."}`), + ]; +} + +/** Generate form fields for a set of scheme fields */ function genSchemeFormFields(fields: CLISecurityFieldInfo[]): string[] { const acc = securityConfigAccessor(); const lines: string[] = []; for (const field of fields) { const varName = `auth${caser().ToPascal(field.field.Name)}`; - const title = escapeGoString(field.description); - const fieldName = caser().ToPascal(field.field.Name); - const cfgExpr = `${acc}${fieldName}`; + const cfgExpr = `${acc}${caser().ToPascal(field.field.Name)}`; + const calls = [`Title("${escapeGoString(field.description)}")`]; if (field.isArray) { - lines.push(`huh.NewInput().`); - lines.push(` Title("${title}").`); - lines.push( - ` Description("${buildFormDescription( + calls.push( + `Description("${buildFormDescription( field.flagName, field.field, "(comma-separated)", - )}").`, + )}")`, ); - lines.push(` Value(&${varName}),`); } else if (field.isSecret) { - lines.push(`huh.NewInput().`); - lines.push(` Title("${title}").`); - lines.push( - ` Description("${buildFormDescription( - field.flagName, - field.field, - )}").`, - ); - lines.push(` EchoMode(huh.EchoModePassword).`); - lines.push( - ` Placeholder(maskSecret(${buildFormPlaceholder( + calls.push( + `Description("${buildFormDescription(field.flagName, field.field)}")`, + `Password()`, + `Placeholder(maskSecret(${buildFormPlaceholder( `config.GetStoredSecret("${field.flagName}", ${cfgExpr})`, field.field, - )})).`, + )}))`, ); - lines.push(` Value(&${varName}),`); } else { - lines.push(`huh.NewInput().`); - lines.push(` Title("${title}").`); - lines.push( - ` Description("${buildFormDescription( - field.flagName, - field.field, - )}").`, + calls.push( + `Description("${buildFormDescription(field.flagName, field.field)}")`, + `Placeholder(${buildFormPlaceholder(cfgExpr, field.field)})`, ); - lines.push( - ` Placeholder(${buildFormPlaceholder(cfgExpr, field.field)}).`, - ); - lines.push(` Value(&${varName}),`); } + lines.push(...goChain(`forms.NewInput(&${varName})`, calls)); } return lines; @@ -2951,8 +2877,8 @@ function genSchemeStore(fields: CLISecurityFieldInfo[]): string[] { registerTemplateFunc("templateAuthLoginBody", templateAuthLoginBody); -// ─── Configure Huh Form Helpers ────────────────────────────────────────────── -// These generate huh-based form fields for the configure command when +// ─── Configure Form Helpers ────────────────────────────────────────────────── +// These generate form fields for the configure command when // interactiveAuth is enabled. They cover both security AND global parameters. /** @@ -2977,7 +2903,7 @@ registerTemplateFunc( ); /** - * Generate huh form fields for global parameters. + * Generate form fields for global parameters. */ function templateConfigureGlobalFormFields(): string { if (!hasGlobals()) return ""; @@ -2996,11 +2922,13 @@ function templateConfigureGlobalFormFields(): string { `Global ${sanitizeFlagName(field.Name)} parameter`; const flagName = sanitizeFlagName(field.Name); - lines.push(`huh.NewInput().`); - lines.push(` Title("${escapeGoString(desc)}").`); - lines.push(` Description("${buildFormDescription(flagName, field)}").`); - lines.push(` Placeholder(${buildFormPlaceholder(cfgExpr, field)}).`); - lines.push(` Value(&${varName}),`); + lines.push( + ...goChain(`forms.NewInput(&${varName})`, [ + `Title("${escapeGoString(desc)}")`, + `Description("${buildFormDescription(flagName, field)}")`, + `Placeholder(${buildFormPlaceholder(cfgExpr, field)})`, + ]), + ); } return lines.join("\n "); @@ -3011,7 +2939,7 @@ registerTemplateFunc( ); /** - * Generate store code for global parameters after huh form submission. + * Generate store code for global parameters after form submission. */ function templateConfigureGlobalStore(): string { if (!hasGlobals()) return ""; diff --git a/templates/templates/cli/tests/primary/auth_login_additional_test.go.stmpl b/templates/templates/cli/tests/primary/auth_login_additional_test.go.stmpl index 316ab2a4b..d4c0b5794 100644 --- a/templates/templates/cli/tests/primary/auth_login_additional_test.go.stmpl +++ b/templates/templates/cli/tests/primary/auth_login_additional_test.go.stmpl @@ -187,4 +187,20 @@ func TestAuthLogoutClearsCredentials(t *testing.T) { }) } } + +func TestAuthLoginLinePromptsReadMethodAndCredentialsFromOneInput(t *testing.T) { + installKeyring(t, false, nil) + home := t.TempDir() + + h := NewCLITestHarness(t).WithEnv("HOME", home) + // Option 3 is basic auth, which prompts for the password before the username. + stdin := strings.NewReader("3\nsekr1t-pass\nalice\n") + require.NoError(t, h.RunPolicyDefaultWithInput([]string{"auth", "login", "--interactive"}, stdin), "stderr: %s", h.GetStderr()) + assert.Contains(t, h.GetStdout(), "Authentication Method") + assert.Contains(t, h.GetStdout(), "3. HTTP Basic (Username + Password)") + + cfg := readAuthConfig(t, home) + assert.Contains(t, cfg, "username: alice") + assert.Contains(t, cfg, "password: sekr1t-pass") +} {{- end}} diff --git a/templates/templates/cli/tests/primary/configure_test.go.stmpl b/templates/templates/cli/tests/primary/configure_test.go.stmpl index de7c6efae..842204b66 100644 --- a/templates/templates/cli/tests/primary/configure_test.go.stmpl +++ b/templates/templates/cli/tests/primary/configure_test.go.stmpl @@ -166,7 +166,7 @@ func TestConfigureSkipsOptionalCredentials(t *testing.T) { output := h.GetStdout() {{- if not (isInteractiveAuthEnabled)}} - // Verify intro messages (readline mode only — huh forms don't print these) + // Verify intro messages (readline mode only — guided forms don't print these) assert.Contains(t, output, "Configure ") assert.Contains(t, output, "Enter - to clear a previously set value.") {{- end}} @@ -264,7 +264,7 @@ func TestConfigureMaskSecretDisplay(t *testing.T) { // In readline mode, existing value should be masked inline in the prompt assert.Contains(t, output, "sk******ef") {{- end}} - // In huh form mode, the masked value is used as a Placeholder which may not + // In guided form mode, the masked value is used as a Placeholder which may not // appear in accessible text output. The key assertion is that the full secret // is never printed. } diff --git a/templates/templates/cli/tests/primary/forms_test.go.stmpl b/templates/templates/cli/tests/primary/forms_test.go.stmpl new file mode 100644 index 000000000..dcdac62b6 --- /dev/null +++ b/templates/templates/cli/tests/primary/forms_test.go.stmpl @@ -0,0 +1,1014 @@ +{{- if isAnyInteractiveEnabled}} +package tests + +import ( + "bytes" + "errors" + "fmt" + "regexp" + "slices" + "strconv" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "{{getGolangPackage}}/internal/forms" +) + +var formsANSI = regexp.MustCompile(`\x1b\[[0-9;:?]*[a-zA-Z]`) + +func formsView(f *forms.Form) string { + return formsANSI.ReplaceAllString(f.View().Content, "") +} + +func formsStart(t *testing.T, width, height int, pages ...*forms.Page) *forms.Form { + t.Helper() + f := forms.New(pages...) + f.Init() + f.Update(tea.WindowSizeMsg{Width: width, Height: height}) + return f +} + +func formsKey(k string) tea.KeyPressMsg { + switch k { + case "enter": + return tea.KeyPressMsg{Code: tea.KeyEnter} + case "alt+enter": + return tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModAlt} + case "tab": + return tea.KeyPressMsg{Code: tea.KeyTab} + case "shift+tab": + return tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} + case "up": + return tea.KeyPressMsg{Code: tea.KeyUp} + case "down": + return tea.KeyPressMsg{Code: tea.KeyDown} + case "left": + return tea.KeyPressMsg{Code: tea.KeyLeft} + case "right": + return tea.KeyPressMsg{Code: tea.KeyRight} + case "home": + return tea.KeyPressMsg{Code: tea.KeyHome} + case "end": + return tea.KeyPressMsg{Code: tea.KeyEnd} + case "backspace": + return tea.KeyPressMsg{Code: tea.KeyBackspace} + } + if mod, r, ok := strings.Cut(k, "+"); ok && mod == "ctrl" { + return tea.KeyPressMsg{Code: rune(r[0]), Mod: tea.ModCtrl} + } + return tea.KeyPressMsg{Code: rune(k[0]), Text: k} +} + +// formsPress sends keys and returns the command produced by the last one. +func formsPress(f *forms.Form, keys ...string) tea.Cmd { + var cmd tea.Cmd + for _, k := range keys { + _, cmd = f.Update(formsKey(k)) + } + return cmd +} + +func formsType(f *forms.Form, s string) { + for _, r := range s { + f.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) + } +} + +// formsQuits reports whether cmd, or any command batched inside it, formsQuits. +func formsQuits(cmd tea.Cmd) bool { + if cmd == nil { + return false + } + switch msg := cmd().(type) { + case tea.QuitMsg: + return true + case tea.BatchMsg: + for _, c := range msg { + if formsQuits(c) { + return true + } + } + } + return false +} + +func formsContains(t *testing.T, s, want string) { + t.Helper() + if !strings.Contains(s, want) { + t.Fatalf("expected %q in:\n%s", want, s) + } +} + +func formsNotContains(t *testing.T, s, unwanted string) { + t.Helper() + if strings.Contains(s, unwanted) { + t.Fatalf("did not expect %q in:\n%s", unwanted, s) + } +} + +func formsRequired(v string) error { + if strings.TrimSpace(v) == "" { + return errors.New("required") + } + return nil +} + +func TestFormsFormWalksPagesAndCollectsAnswers(t *testing.T) { + var shell, base, name, instructions string + var discount bool + f := formsStart(t, 80, 40, + forms.NewPage("Taco", + forms.NewSelect(&shell, forms.NewOption("Soft", "Soft"), forms.NewOption("Hard", "Hard")). + Title("Shell?"). + Description("Our tortillas are made fresh in-house every day."). + Validate(func(v string) error { + if v == "Hard" { + return errors.New("we're out of hard shells, sorry") + } + return nil + }), + forms.NewSelect(&base, forms.NewOption("Chicken", "Chicken"), forms.NewOption("Beef", "Beef")).Title("Base"), + ), + forms.NewPage("Details", + forms.NewInput(&name).Title("What's your name?").Placeholder("Margaret").Description("For when your order is ready."), + forms.NewText(&instructions).Title("Special Instructions").Description("Anything we should know?"), + forms.NewConfirm(&discount).Title("Would you like 15% off?"), + ), + ) + + v := formsView(f) + formsContains(t, v, "┃ Shell?") + formsContains(t, v, "Our tortillas are made fresh in-house every day.") + formsContains(t, v, "Base") + formsNotContains(t, v, "What's your name?") + + formsPress(f, "j", "tab") + formsContains(t, formsView(f), "we're out of hard shells, sorry") + + formsPress(f, "k") + formsNotContains(t, formsView(f), "we're out of hard shells") + formsPress(f, "enter") + formsContains(t, formsView(f), "┃ > Chicken") + + formsPress(f, "enter") + v = formsView(f) + formsContains(t, v, "What's your name?") + formsContains(t, v, "Special Instructions") + formsContains(t, v, "Would you like 15% off?") + formsNotContains(t, v, "Shell?") + + formsType(f, "Glen") + formsContains(t, formsView(f), "Glen") + formsPress(f, "enter") + formsType(f, "Ring twice") + formsPress(f, "enter") + if !formsQuits(formsPress(f, "y")) { + t.Fatal("answering the last confirm should submit") + } + if formsView(f) != "" { + t.Fatal("a submitted form should render nothing") + } + if shell != "Soft" || base != "Chicken" || name != "Glen" || instructions != "Ring twice" || !discount { + t.Fatalf("got shell=%q base=%q name=%q instructions=%q discount=%v", shell, base, name, instructions, discount) + } +} + +func TestFormsInput(t *testing.T) { + var v string + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewInput(&v))) + formsContains(t, formsView(f), ">") + formsType(f, "Huh") + formsContains(t, formsView(f), "Huh") + if v != "Huh" { + t.Fatalf("got %q", v) + } + formsPress(f, "backspace") + if v != "Hu" { + t.Fatalf("backspace: got %q", v) + } +} + +func TestFormsInputStartsFromBoundValue(t *testing.T) { + v := "existing" + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewInput(&v))) + formsContains(t, formsView(f), "existing") + formsType(f, "!") + if v != "existing!" { + t.Fatalf("got %q", v) + } +} + +func TestFormsInputPlaceholderAndPassword(t *testing.T) { + var user, token string + f := formsStart(t, 80, 24, forms.NewPage("", + forms.NewInput(&token).Title("Token").Password().Placeholder("sk_****45"), + forms.NewInput(&user).Title("User").Placeholder("alice"), + )) + v := formsView(f) + formsContains(t, v, "sk_****45") + formsContains(t, v, "alice") + formsType(f, "hunter2") + formsNotContains(t, formsView(f), "hunter2") + if token != "hunter2" { + t.Fatalf("got %q", token) + } +} + +func TestFormsPasteNotDuplicated(t *testing.T) { + var v string + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewInput(&v).Title("Name"))) + f.Update(tea.PasteMsg{Content: "hello"}) + if v != "hello" { + t.Fatalf("expected %q, got %q", "hello", v) + } +} + +func TestFormsText(t *testing.T) { + var v string + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewText(&v).Title("Body"))) + formsType(f, `{"a":`) + formsPress(f, "ctrl+j") + formsType(f, "1,") + formsPress(f, "alt+enter") + formsType(f, `"b":2}`) + formsContains(t, formsView(f), `"b":2}`) + if v != "{\"a\":\n1,\n\"b\":2}" { + t.Fatalf("got %q", v) + } + if !formsQuits(formsPress(f, "enter")) { + t.Fatal("enter should submit a text field") + } +} + +func TestFormsTextPaste(t *testing.T) { + var v string + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewText(&v))) + f.Update(tea.PasteMsg{Content: "line one\nline two"}) + if v != "line one\nline two" { + t.Fatalf("got %q", v) + } +} + +func TestFormsConfirm(t *testing.T) { + var v bool + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewConfirm(&v).Title("Are you sure?"))) + view := formsView(f) + formsContains(t, view, "Yes") + formsContains(t, view, "No") + formsContains(t, view, "Are you sure?") + if v { + t.Fatal("expected false") + } + formsPress(f, "left") + if !v { + t.Fatal("left should toggle to true") + } + formsPress(f, "right") + if v { + t.Fatal("right should toggle back to false") + } + formsPress(f, "h") + if !v { + t.Fatal("h should toggle to true") + } + formsPress(f, "l") + if v { + t.Fatal("l should toggle back to false") + } + if !formsQuits(formsPress(f, "enter")) || v { + t.Fatal("enter should submit the current choice") + } +} + +func TestFormsConfirmAnswerKeys(t *testing.T) { + for _, tt := range []struct { + key string + from bool + want bool + }{ + {"y", false, true}, + {"Y", false, true}, + {"n", true, false}, + {"N", true, false}, + } { + t.Run(tt.key, func(t *testing.T) { + v := tt.from + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewConfirm(&v))) + if !formsQuits(formsPress(f, tt.key)) || v != tt.want { + t.Fatalf("%s: quit and value %v expected, got %v", tt.key, tt.want, v) + } + }) + } +} + +func TestFormsSelect(t *testing.T) { + var v string + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewSelect(&v, + forms.NewOption("Foo\nLine 2", "foo"), + forms.NewOption("Bar\nLine 2", "bar"), + forms.NewOption("Baz\nLine 2", "baz"), + forms.NewOption("Ban\nLine 2", "ban"), + ).Title("Which one?"))) + + view := formsView(f) + formsContains(t, view, "Which one?") + formsContains(t, view, "> Foo") + formsContains(t, view, "┃ Line 2") + if v != "foo" { + t.Fatalf("the first option should be selected initially, got %q", v) + } + + formsPress(f, "down") + view = formsView(f) + formsNotContains(t, view, "> Foo") + formsContains(t, view, "> Bar") + if !formsQuits(formsPress(f, "enter")) || v != "bar" { + t.Fatalf("expected bar, got %q", v) + } +} + +func TestFormsSelectStartsAtBoundValue(t *testing.T) { + v := "c" + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewSelect(&v, + forms.NewOption("A", "a"), forms.NewOption("B", "b"), forms.NewOption("C", "c")))) + formsContains(t, formsView(f), "> C") + formsPress(f, "down") + if v != "a" { + t.Fatalf("down from the last option should wrap to the first, got %q", v) + } + formsPress(f, "up") + if v != "c" { + t.Fatalf("up from the first option should wrap to the last, got %q", v) + } + formsPress(f, "k", "k") + if v != "a" { + t.Fatalf("k should move up, got %q", v) + } + formsPress(f, "j", "ctrl+n", "ctrl+p") + if v != "b" { + t.Fatalf("j, ctrl+n, ctrl+p: got %q", v) + } +} + +func TestFormsSelectUnknownBoundValuePicksFirst(t *testing.T) { + v := "missing" + formsStart(t, 80, 24, forms.NewPage("", forms.NewSelect(&v, forms.NewOption("A", "a"), forms.NewOption("B", "b")))) + if v != "a" { + t.Fatalf("got %q", v) + } +} + +func TestFormsSelectPageNavigation(t *testing.T) { + names := []string{"Qux", "Quux", "Foo", "Bar", "Baz", "Corge", "Grault", "Garply", "Waldo", "Fred", + "Plugh", "Xyzzy", "Thud", "Norf", "Blip", "Flob", "Zorp", "Smurf", "Bloop", "Ping"} + options := make([]forms.Option, len(names)) + for i, n := range names { + options[i] = forms.NewOption(n, n) + } + var v string + f := formsStart(t, 80, 16, forms.NewPage("", forms.NewSelect(&v, options...).Title("Choose"))) + + visible := func() []string { + var shown []string + for _, n := range names { + if regexp.MustCompile(`(?m)^┃ [> ] ` + n + ` *$`).MatchString(formsView(f)) { + shown = append(shown, n) + } + } + return shown + } + if n := len(visible()); n == 0 || n >= len(names) { + t.Fatalf("expected a window smaller than the option list, got %d options", n) + } + formsContains(t, formsView(f), "> Qux") + + formsPress(f, "G") + formsContains(t, formsView(f), "> Ping") + formsPress(f, "g") + formsContains(t, formsView(f), "> Qux") + formsPress(f, "end") + formsContains(t, formsView(f), "> Ping") + formsPress(f, "home") + formsContains(t, formsView(f), "> Qux") + + index := func() int { return slices.Index(names, v) } + half := len(visible()) / 2 + formsPress(f, "ctrl+d") + if got := index(); got != half { + t.Fatalf("ctrl+d from the top should move %d rows, got option %d", half, got) + } + formsContains(t, formsView(f), "> "+v) + + formsPress(f, "end") + half = len(visible()) / 2 + formsPress(f, "ctrl+u") + if got := index(); got != len(names)-1-half { + t.Fatalf("ctrl+u from the bottom should move %d rows, got option %d", half, got) + } + formsContains(t, formsView(f), "> "+v) + for range 30 { + formsPress(f, "ctrl+u") + } + if v != "Qux" { + t.Fatalf("ctrl+u should stop at the first option, got %q", v) + } + for range 30 { + formsPress(f, "ctrl+d") + } + if v != "Ping" { + t.Fatalf("ctrl+d should stop at the last option, got %q", v) + } + + for i := range names { + formsPress(f, "down") + want := names[i] + formsContains(t, formsView(f), "> "+want) + } +} + +func TestFormsSelectWindowFitsTerminal(t *testing.T) { + options := make([]forms.Option, 40) + for i := range options { + options[i] = forms.NewOption(fmt.Sprintf("option %d\nsecond line", i), fmt.Sprint(i)) + } + var v string + for _, height := range []int{8, 12, 24} { + f := formsStart(t, 80, height, forms.NewPage("", forms.NewSelect(&v, options...).Title("Pick"))) + for range 25 { + formsPress(f, "down") + if got := lipgloss.Height(formsView(f)); got > height { + t.Fatalf("height %d: rendered %d lines", height, got) + } + formsContains(t, formsView(f), "> option "+v) + } + } +} + +func TestFormsPreviousPage(t *testing.T) { + var a, b, c string + f := formsStart(t, 80, 24, + forms.NewPage("First", forms.NewInput(&a).Title("Bar")), + forms.NewPage("Second", forms.NewInput(&b).Title("Foo")), + forms.NewPage("Third", forms.NewInput(&c).Title("Baz")), + ) + formsPress(f, "enter", "enter") + formsContains(t, formsView(f), "Baz") + formsPress(f, "shift+tab", "shift+tab") + v := formsView(f) + formsContains(t, v, "Bar") + formsNotContains(t, v, "Baz") + formsPress(f, "shift+tab") + formsContains(t, formsView(f), "Bar") +} + +func TestFormsTabDoesNotSubmit(t *testing.T) { + var a, b string + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewInput(&a).Title("A"), forms.NewInput(&b).Title("B"))) + formsPress(f, "tab") + if formsQuits(formsPress(f, "tab")) || formsView(f) == "" { + t.Fatal("tab on the last field should not submit") + } + if !formsQuits(formsPress(f, "enter")) { + t.Fatal("enter on the last field should submit") + } +} + +func TestFormsEmptyPagesAreSkipped(t *testing.T) { + var a, b string + f := formsStart(t, 80, 24, + forms.NewPage("Leading"), + forms.NewPage("One", forms.NewInput(&a).Title("A")), + forms.NewPage("Middle"), + forms.NewPage("Two", forms.NewInput(&b).Title("B")), + forms.NewPage("Trailing"), + ) + formsContains(t, formsView(f), "One") + formsPress(f, "enter") + formsContains(t, formsView(f), "Two") + if !formsQuits(formsPress(f, "enter")) { + t.Fatal("enter on the last real field should submit despite a trailing empty page") + } +} + +func TestFormsValidationBlocksForwardOnly(t *testing.T) { + var a, b string + f := formsStart(t, 80, 24, forms.NewPage("", + forms.NewInput(&a).Title("A").Validate(formsRequired), + forms.NewText(&b).Title("B").Validate(formsRequired), + )) + formsPress(f, "enter") + formsContains(t, formsView(f), "required") + formsPress(f, "tab") + formsContains(t, formsView(f), "required") + formsType(f, "x") + formsNotContains(t, formsView(f), "required") + formsPress(f, "enter") + if formsQuits(formsPress(f, "enter")) { + t.Fatal("an invalid last field must not submit") + } + formsContains(t, formsView(f), "required") + formsPress(f, "shift+tab") + if a != "x" { + t.Fatalf("got %q", a) + } + formsPress(f, "backspace", "tab") + formsContains(t, formsView(f), "required") +} + +func TestFormsCtrlCAborts(t *testing.T) { + var v string + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewInput(&v).Title("Foo"))) + if !formsQuits(formsPress(f, "ctrl+c")) { + t.Fatal("ctrl+c should quit") + } + if formsView(f) != "" { + t.Fatal("an aborted form should render nothing") + } +} + +func TestFormsEmptyFormRunsWithoutPrompting(t *testing.T) { + var out bytes.Buffer + if err := forms.New(forms.NewPage("Nothing")).Accessible(true).IO(strings.NewReader("x\n"), &out).Run(); err != nil { + t.Fatal(err) + } + if out.Len() != 0 { + t.Fatalf("expected no output, got %q", out.String()) + } +} + +func TestFormsTitlesAndDescriptions(t *testing.T) { + builders := map[string]func() *forms.Field{ + "input": func() *forms.Field { var v string; return forms.NewInput(&v) }, + "text": func() *forms.Field { var v string; return forms.NewText(&v) }, + "select": func() *forms.Field { var v string; return forms.NewSelect(&v, forms.NewOption("one", "1")) }, + "confirm": func() *forms.Field { var v bool; return forms.NewConfirm(&v) }, + } + for name, build := range builders { + t.Run(name, func(t *testing.T) { + bare := formsView(formsStart(t, 80, 24, forms.NewPage("", build()))) + titled := formsView(formsStart(t, 80, 24, forms.NewPage("", build().Title("A Title")))) + described := formsView(formsStart(t, 80, 24, forms.NewPage("", build().Description("A Description")))) + formsContains(t, titled, "A Title") + formsContains(t, described, "A Description") + if lipgloss.Height(titled) != lipgloss.Height(bare)+1 || lipgloss.Height(described) != lipgloss.Height(bare)+1 { + t.Fatalf("title and description should each add one line: bare=%d titled=%d described=%d", + lipgloss.Height(bare), lipgloss.Height(titled), lipgloss.Height(described)) + } + }) + } +} + +func TestFormsRenderFitsWidth(t *testing.T) { + long := strings.Repeat("a fairly long description word ", 8) + for _, width := range []int{30, 50, 100} { + var a, b string + var c bool + f := formsStart(t, width, 40, forms.NewPage(long, + forms.NewInput(&a).Title(long).Description(long).Placeholder("p"), + forms.NewSelect(&b, forms.NewOption(long, "x"), forms.NewOption("y", "y")).Title(long), + forms.NewConfirm(&c).Title(long), + )) + lines := strings.Split(formsView(f), "\n") + for _, line := range lines { + if w := lipgloss.Width(line); w > width { + t.Fatalf("width %d: line is %d wide: %q", width, w, line) + } + } + // Writing the bottom-right cell scrolls some terminals. + if last := lines[len(lines)-1]; lipgloss.Width(last) >= width { + t.Fatalf("width %d: the last line reaches the final column: %q", width, last) + } + } +} + +func TestFormsResizeReflowsInput(t *testing.T) { + var v string + f := formsStart(t, 100, 24, forms.NewPage("", forms.NewInput(&v).Title(strings.Repeat("word ", 30)))) + wide := lipgloss.Height(formsView(f)) + f.Update(tea.WindowSizeMsg{Width: 30, Height: 24}) + if narrow := lipgloss.Height(formsView(f)); narrow <= wide { + t.Fatalf("narrowing should wrap the title: %d -> %d lines", wide, narrow) + } +} + +type formsPromptCase struct { + name string + input string + field func() (*forms.Field, func() any) + output []string + value any +} + +func TestFormsAccessiblePrompts(t *testing.T) { + str := func(initial string, build func(*string) *forms.Field) func() (*forms.Field, func() any) { + return func() (*forms.Field, func() any) { + v := initial + return build(&v), func() any { return v } + } + } + boolean := func(initial bool) func() (*forms.Field, func() any) { + return func() (*forms.Field, func() any) { + v := initial + return forms.NewConfirm(&v).Title("Choose"), func() any { return v } + } + } + abc := []forms.Option{forms.NewOption("a", "a"), forms.NewOption("b", "b"), forms.NewOption("c", "c"), forms.NewOption("d", "d")} + for _, tt := range []formsPromptCase{ + {name: "input", input: "Hello\n", field: str("", func(v *string) *forms.Field { return forms.NewInput(v).Title("Input:") }), output: []string{"Input:\n> "}, value: "Hello"}, + {name: "input without trailing newline", input: "Hello", field: str("", func(v *string) *forms.Field { return forms.NewInput(v) }), value: "Hello"}, + {name: "input with default", input: "\n", field: str("hi", func(v *string) *forms.Field { return forms.NewInput(v) }), value: "hi"}, + {name: "input keeps surrounding spaces", input: " spaced secret \n", field: str("", func(v *string) *forms.Field { return forms.NewInput(v) }), value: " spaced secret "}, + {name: "input re-asks until valid", input: "\n \nok\n", field: str("", func(v *string) *forms.Field { return forms.NewInput(v).Validate(formsRequired) }), output: []string{"> required\n> required\n> "}, value: "ok"}, + {name: "input with description", input: "x\n", field: str("", func(v *string) *forms.Field { return forms.NewInput(v).Title("Name").Description("--name") }), output: []string{"Name\n--name\n> "}, value: "x"}, + {name: "password from a pipe", input: "s3cret\n", field: str("", func(v *string) *forms.Field { return forms.NewInput(v).Password() }), value: "s3cret"}, + {name: "crlf line ending", input: "value\r\n", field: str("", func(v *string) *forms.Field { return forms.NewInput(v) }), value: "value"}, + {name: "confirm", input: "Y\n", field: boolean(false), output: []string{"Choose\n[y/N]: "}, value: true}, + {name: "confirm with default", input: "\n", field: boolean(true), output: []string{"[Y/n]: "}, value: true}, + {name: "confirm with default choose", input: "n\n", field: boolean(true), output: []string{"Y/n"}, value: false}, + {name: "confirm long answers", input: "yes\n", field: boolean(false), value: true}, + {name: "confirm invalid", input: "maybe\nno\n", field: boolean(true), output: []string{"Answer y or n."}, value: false}, + {name: "select", input: "2\n", field: str("", func(v *string) *forms.Field { return forms.NewSelect(v, abc[:2]...).Title("Select:") }), output: []string{"Select:\n 1. a\n 2. b\n"}, value: "b"}, + {name: "select default value", input: "\n", field: str("c", func(v *string) *forms.Field { return forms.NewSelect(v, abc...) }), output: []string{"Choose 1-4 [3]: "}, value: "c"}, + {name: "select invalid", input: "0\nx\n5\n2\n", field: str("", func(v *string) *forms.Field { return forms.NewSelect(v, abc[:2]...) }), output: []string{"Enter a number from 1 to 2."}, value: "b"}, + {name: "select single option", input: "\n", field: str("", func(v *string) *forms.Field { return forms.NewSelect(v, abc[:1]...) }), output: []string{"Choose 1-1 [1]: "}, value: "a"}, + {name: "select end of input", input: "", field: str("b", func(v *string) *forms.Field { return forms.NewSelect(v, abc...) }), value: "b"}, + {name: "select multi-line label", input: "1\n", field: str("", func(v *string) *forms.Field { + return forms.NewSelect(v, forms.NewOption("Hi\nthere", "hi")) + }), output: []string{"Hi", "there"}, value: "hi"}, + {name: "text", input: "hello world\n", field: str("", func(v *string) *forms.Field { return forms.NewText(v).Title("Text:") }), output: []string{"Text:"}, value: "hello world"}, + {name: "text default value", input: "\n", field: str("test", func(v *string) *forms.Field { return forms.NewText(v) }), value: "test"}, + } { + t.Run(tt.name, func(t *testing.T) { + field, value := tt.field() + var out bytes.Buffer + if err := forms.New(forms.NewPage("", field)).Accessible(true).IO(strings.NewReader(tt.input), &out).Run(); err != nil { + t.Fatal(err) + } + for _, want := range tt.output { + formsContains(t, out.String(), want) + } + if got := value(); got != tt.value { + t.Fatalf("got %#v, want %#v", got, tt.value) + } + formsNotContains(t, out.String(), "s3cret") + }) + } +} + +func TestFormsAccessibleFormAnswersEveryField(t *testing.T) { + var token, user, region string + var ok bool + var out bytes.Buffer + err := forms.New( + forms.NewPage("Authentication", + forms.NewInput(&token).Title("Token").Password(), + forms.NewInput(&user).Title("User"), + ), + forms.NewPage("Settings", + forms.NewSelect(®ion, forms.NewOption("us", "us"), forms.NewOption("eu", "eu")).Title("Region"), + forms.NewConfirm(&ok).Title("Save?"), + ), + ).Accessible(true).IO(strings.NewReader("tok\nbob\n2\ny\n"), &out).Run() + if err != nil { + t.Fatal(err) + } + if token != "tok" || user != "bob" || region != "eu" || !ok { + t.Fatalf("got token=%q user=%q region=%q ok=%v\n%s", token, user, region, ok, out.String()) + } + formsContains(t, out.String(), "Token\n> ") + formsContains(t, out.String(), "Region\n") +} + +func TestFormsAccessibleEndOfInputStops(t *testing.T) { + a, b := "", "kept" + var out bytes.Buffer + err := forms.New(forms.NewPage("", + forms.NewInput(&a).Title("A"), + forms.NewInput(&b).Title("B").Validate(formsRequired), + )).Accessible(true).IO(strings.NewReader("first\n"), &out).Run() + if err != nil { + t.Fatal(err) + } + if a != "first" || b != "kept" { + t.Fatalf("got a=%q b=%q", a, b) + } +} + +type formsFailingReader struct{} + +func (formsFailingReader) Read([]byte) (int, error) { return 0, errors.New("read failed") } + +func TestFormsAccessibleReadErrorIsReturned(t *testing.T) { + var v string + err := forms.New(forms.NewPage("", forms.NewInput(&v))).Accessible(true).IO(formsFailingReader{}, &bytes.Buffer{}).Run() + if err == nil || !strings.Contains(err.Error(), "read failed") { + t.Fatalf("expected the read error, got %v", err) + } +} + +func formsTallPage(answers []string) *forms.Page { + fields := make([]*forms.Field, len(answers)) + for i := range answers { + fields[i] = forms.NewInput(&answers[i]).Title(fmt.Sprintf("Field %d", i)).Description("--flag") + } + return forms.NewPage("Tall page", fields...) +} + +func TestFormsTallPageKeepsFocusedFieldVisible(t *testing.T) { + for _, height := range []int{4, 6, 10, 15} { + t.Run(fmt.Sprint(height), func(t *testing.T) { + answers := make([]string, 8) + f := formsStart(t, 60, height, formsTallPage(answers)) + if height >= 6 { // room for the page title and the first field + formsContains(t, formsView(f), "Tall page") + } + for i := range answers { + formsType(f, fmt.Sprintf("m%d", i)) + v := formsView(f) + if got := lipgloss.Height(v); got > height { + t.Fatalf("field %d: rendered %d lines for height %d", i, got, height) + } + formsContains(t, v, fmt.Sprintf("> m%d", i)) + formsPress(f, "tab") + } + for i := len(answers) - 1; i >= 0; i-- { + formsContains(t, formsView(f), fmt.Sprintf("> m%d", i)) + formsPress(f, "shift+tab") + } + }) + } +} + +func TestFormsTallPageKeepsTitleWithFirstField(t *testing.T) { + answers := make([]string, 8) + f := formsStart(t, 60, 10, formsTallPage(answers)) + // Render after every key, as the terminal does. + for _, k := range []string{"tab", "tab", "tab", "shift+tab", "shift+tab", "shift+tab"} { + formsPress(f, k) + formsView(f) + } + v := formsView(f) + formsContains(t, v, "Tall page") + formsContains(t, v, "Field 0") +} + +func TestFormsSelectFitsWithItsHeading(t *testing.T) { + options := make([]forms.Option, 20) + for i := range options { + options[i] = forms.NewOption(fmt.Sprintf("choice %d", i), fmt.Sprint(i)) + } + for _, height := range []int{6, 9, 14} { + var v string + f := formsStart(t, 40, height, forms.NewPage("", forms.NewSelect(&v, options...). + Title("A select title").Description("A description that wraps onto a second line here"))) + for range 22 { + view := formsView(f) + if lipgloss.Height(view) > height { + t.Fatalf("height %d: rendered %d lines", height, lipgloss.Height(view)) + } + formsContains(t, view, "A select title") + formsContains(t, view, "> choice "+v) + formsPress(f, "down") + } + } +} + +func TestFormsNarrowTerminalsDoNotOverflow(t *testing.T) { + // Below 6 columns the field border and input prompt alone overflow, so + // those widths only need to render without panicking. + for width := 1; width <= 20; width++ { + var a, b string + var c bool + f := formsStart(t, width, 40, forms.NewPage("Settings", + forms.NewInput(&a).Title("Credential").Description("--credential").Placeholder("unset"), + forms.NewSelect(&b, forms.NewOption("Keep current", ""), forms.NewOption("json", "json")).Title("Format"), + forms.NewConfirm(&c).Title("Save?"), + )) + view := formsView(f) + if width < 6 { + continue + } + for _, line := range strings.Split(view, "\n") { + if w := lipgloss.Width(line); w > width { + t.Fatalf("width %d: %q is %d wide", width, line, w) + } + } + } +} + +func TestFormsDumbTerminalUsesLinePrompts(t *testing.T) { + t.Setenv("TERM", "dumb") + var v string + var out bytes.Buffer + if err := forms.New(forms.NewPage("", forms.NewInput(&v).Title("Name"))).Accessible(false).IO(strings.NewReader("ada\n"), &out).Run(); err != nil { + t.Fatal(err) + } + if v != "ada" { + t.Fatalf("got %q", v) + } + formsContains(t, out.String(), "Name\n> ") +} + +func TestFormsFieldTallerThanTerminalShowsItsInput(t *testing.T) { + var v string + f := formsStart(t, 20, 4, forms.NewPage("Page", + forms.NewInput(&v).Title("A title long enough to wrap over several narrow lines").Description("--a-long-flag-name"))) + formsType(f, "typed") + view := formsView(f) + if lipgloss.Height(view) > 4 { + t.Fatalf("rendered %d lines for height 4", lipgloss.Height(view)) + } + formsContains(t, view, "typed") +} + +func formsIntegerOnly(v string) error { + if _, err := strconv.Atoi(strings.TrimSpace(v)); err != nil { + return errors.New("must be an integer") + } + return nil +} + +func formsAssertFits(t *testing.T, view string, width, height int) { + t.Helper() + if got := lipgloss.Height(view); got > height { + t.Fatalf("rendered %d lines for height %d:\n%s", got, height, view) + } + for _, line := range strings.Split(view, "\n") { + if w := lipgloss.Width(line); w > width { + t.Fatalf("width %d: %q is %d wide", width, line, w) + } + } +} + +// formsUnwrapped joins a view's text with borders and spaces removed, so +// wrapped words can be matched whole. +func formsUnwrapped(view string) string { + return strings.NewReplacer("┃", "", " ", "", "\n", "").Replace(view) +} + +func TestFormsValidationErrorWrapsToWidth(t *testing.T) { + for _, width := range []int{8, 12, 20} { + t.Run(fmt.Sprint(width), func(t *testing.T) { + var a string + f := formsStart(t, width, 12, forms.NewPage("", forms.NewInput(&a).Title("Count").Validate(formsIntegerOnly))) + formsType(f, "x") + formsPress(f, "enter") + view := formsView(f) + formsAssertFits(t, view, width, 12) + formsContains(t, formsUnwrapped(view), "!mustbeaninteger") + formsContains(t, view, "> x") + + b := "bad" + f = formsStart(t, width, 12, forms.NewPage("", forms.NewSelect(&b, forms.NewOption("bad", "bad"), forms.NewOption("ok", "ok")). + Title("Pick").Validate(formsIntegerOnly))) + formsPress(f, "enter") + view = formsView(f) + formsAssertFits(t, view, width, 12) + formsContains(t, formsUnwrapped(view), "!mustbeaninteger") + formsContains(t, formsUnwrapped(view), ">bad") + }) + } +} + +func TestFormsSelectKeepsHeadingWhenValidationFails(t *testing.T) { + options := make([]forms.Option, 20) + for i := range options { + options[i] = forms.NewOption(fmt.Sprintf("choice %d", i), fmt.Sprint(i)) + } + var v string + f := formsStart(t, 80, 10, forms.NewPage("", forms.NewSelect(&v, options...).Title("Choose"). + Validate(func(string) error { return errors.New("nope") }))) + formsPress(f, "down", "down") + formsPress(f, "enter") + view := formsView(f) + formsAssertFits(t, view, 80, 10) + formsContains(t, view, "Choose") + formsContains(t, view, "> choice 2") + formsContains(t, view, "nope") +} + +func TestFormsTextFitsShortTerminals(t *testing.T) { + for _, height := range []int{4, 5, 6, 12, 30} { + t.Run(fmt.Sprint(height), func(t *testing.T) { + var value string + f := formsStart(t, 20, height, forms.NewPage("", forms.NewText(&value).Title("JSON"))) + f.Update(tea.PasteMsg{Content: "VISIBLE"}) + view := formsView(f) + formsAssertFits(t, view, 20, height) + formsContains(t, view, "VISIBLE") + formsContains(t, view, "JSON") + + formsType(f, "typed") + formsContains(t, formsView(f), "VISIBLEtyped") + }) + } +} + +func TestFormsTextResizesAroundValidationError(t *testing.T) { + var value string + f := formsStart(t, 20, 30, forms.NewPage("", forms.NewText(&value).Title("JSON").Validate(formsRequired))) + f.Update(tea.WindowSizeMsg{Width: 20, Height: 4}) + formsPress(f, "enter") + view := formsView(f) + formsAssertFits(t, view, 20, 4) + formsContains(t, view, "required") + formsType(f, "now") + view = formsView(f) + formsAssertFits(t, view, 20, 4) + formsContains(t, view, "now") + f.Update(tea.WindowSizeMsg{Width: 20, Height: 30}) + if got := lipgloss.Height(formsView(f)); got < 6 { + t.Fatalf("a tall terminal should give the editor its default rows, got %d lines", got) + } +} + +func TestFormsInputStaysVisibleWhenErrorOverflows(t *testing.T) { + for _, height := range []int{2, 3, 4} { + var a string + f := formsStart(t, 8, height, forms.NewPage("Page", forms.NewInput(&a).Title("Count").Validate(formsIntegerOnly))) + formsType(f, "x") + formsPress(f, "enter") + view := formsView(f) + formsAssertFits(t, view, 8, height) + formsContains(t, view, "> x") + formsContains(t, view, "!") + } +} + +func TestFormsTextFollowsCursorOnShortTerminals(t *testing.T) { + var value string + f := formsStart(t, 30, 4, forms.NewPage("", forms.NewText(&value).Title("JSON"))) + for i := range 8 { + formsType(f, fmt.Sprintf("line%d", i)) + formsPress(f, "ctrl+j") + formsView(f) + } + formsType(f, "last") + view := formsView(f) + formsAssertFits(t, view, 30, 4) + formsContains(t, view, "last") + formsContains(t, view, "line7") +} + +func TestFormsAccessibleSelectValidates(t *testing.T) { + options := []forms.Option{forms.NewOption("Rejected", "bad"), forms.NewOption("Accepted", "good")} + for _, tt := range []struct{ name, initial, input string }{ + {name: "rejected pick", input: "1\n2\n"}, + {name: "rejected default", initial: "bad", input: "\n2\n"}, + } { + t.Run(tt.name, func(t *testing.T) { + calls := 0 + v := tt.initial + var out bytes.Buffer + err := forms.New(forms.NewPage("", forms.NewSelect(&v, options...).Validate(func(v string) error { + calls++ + if v == "bad" { + return errors.New("not that one") + } + return nil + }))).Accessible(true).IO(strings.NewReader(tt.input), &out).Run() + if err != nil { + t.Fatal(err) + } + if v != "good" || calls != 2 { + t.Fatalf("got %q after %d validations\n%s", v, calls, out.String()) + } + formsContains(t, out.String(), "not that one\nChoose 1-2") + }) + } +} + +func TestFormsSharedReaderSpansForms(t *testing.T) { + var method, user, secret string + var out bytes.Buffer + in := forms.NewReader(strings.NewReader("1\nalice\nsecret\n")) + err := forms.New(forms.NewPage("", forms.NewSelect(&method, forms.NewOption("Basic", "basic"), forms.NewOption("Token", "token")))). + Accessible(true).IO(in, &out).Run() + if err != nil { + t.Fatal(err) + } + err = forms.New(forms.NewPage("", forms.NewInput(&user).Title("User"), forms.NewInput(&secret).Title("Secret").Password())). + Accessible(true).IO(in, &out).Run() + if err != nil { + t.Fatal(err) + } + if method != "basic" || user != "alice" || secret != "secret" { + t.Fatalf("got method=%q user=%q secret=%q\n%s", method, user, secret, out.String()) + } + if forms.NewReader(in) != in { + t.Fatal("wrapping a shared reader again should return it unchanged") + } +} + +func TestFormsRunReturnsErrUserAbortedOnCtrlC(t *testing.T) { + t.Setenv("TERM", "xterm-256color") // a dumb terminal would get line prompts + var v string + err := forms.New(forms.NewPage("", forms.NewInput(&v).Title("Foo"))).IO(strings.NewReader("\x03"), &bytes.Buffer{}).Run() + if !errors.Is(err, forms.ErrUserAborted) { + t.Fatalf("got %v, want ErrUserAborted", err) + } +} +{{- end}} diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index f6b9eb209..f473cebe7 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -58,15 +58,15 @@ trackedFiles: cmd/gendocs/main_test.go: last_write_checksum: sha1:80e37af8940a8575a635b643431bb16f204209bb go.mod: - last_write_checksum: sha1:872be2b9e087d4dc3511651c8a92450a7cf5aa54 + last_write_checksum: sha1:75fd840a41af9780eddf7431ab9168a0c2c2facd internal/cli/auth.go: - last_write_checksum: sha1:2f660d9f61e378b1f263fabd87577ca889bef180 + last_write_checksum: sha1:a64e4aae57deeb18045a8601f4aff422b39931c0 internal/cli/binaryandstringupload.go: last_write_checksum: sha1:20d8028b7618e73dcf1763372f9145ea325b32b0 internal/cli/chat.go: last_write_checksum: sha1:11730e18c9c6f403253ba394cb00e5b6e8beec59 internal/cli/configure.go: - last_write_checksum: sha1:81f421f92ba05428359b905815e1dd23c64e7539 + last_write_checksum: sha1:72b513337b8bdbb36773683424a76635e6b6a631 internal/cli/createuser.go: last_write_checksum: sha1:5b60000059db85dcf4c90f5e67c37b7fff3cf852 internal/cli/createwithunion.go: @@ -249,8 +249,10 @@ trackedFiles: last_write_checksum: sha1:1936c82d8e36a6612364827ffc05dcbea0e1dd8f internal/flagutil/preset.go: last_write_checksum: sha1:a6cf90b7dd09d11ffbd438fc3aba76806de5752a + internal/forms/forms.go: + last_write_checksum: sha1:c21352632fd50812eda8d9ac9159208e26c15cfa internal/interactive/interactive.go: - last_write_checksum: sha1:cb2abdd024fe02c87a0e558c1573cdc91832edfc + last_write_checksum: sha1:ee537c6deda49ba3f24ee2d06a6df4e7619e8e55 internal/interactive/policy.go: last_write_checksum: sha1:3cf3eec72867230610aa5e7c308fddcaf916b81b internal/interactive/policy_test.go: diff --git a/zSDKs/sdk-cli/go.mod b/zSDKs/sdk-cli/go.mod index 951a79368..148bc4b03 100644 --- a/zSDKs/sdk-cli/go.mod +++ b/zSDKs/sdk-cli/go.mod @@ -5,7 +5,6 @@ go 1.26.8 require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.0.10 - charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.6 github.com/alpkeskin/gotoon v0.1.1 github.com/creack/pty v1.1.24 @@ -24,12 +23,9 @@ require ( require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/catppuccin/go v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect github.com/charmbracelet/x/ansi v0.11.8 // indirect - github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect @@ -38,13 +34,11 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/mattn/go-runewidth v0.0.27 // indirect - github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/zSDKs/sdk-cli/go.sum b/zSDKs/sdk-cli/go.sum index 208e56430..f026e084a 100644 --- a/zSDKs/sdk-cli/go.sum +++ b/zSDKs/sdk-cli/go.sum @@ -4,8 +4,6 @@ charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o= charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc= -charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= -charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -16,32 +14,20 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= -github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= -github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= -github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= -github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= -github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= -github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= -github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= -github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= -github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= @@ -54,8 +40,6 @@ github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ericlagergren/decimal v0.0.0-20221120152707-495c53812d05 h1:S92OBrGuLLZsyM5ybUzgc/mPjIYk2AZqufieooe98uw= github.com/ericlagergren/decimal v0.0.0-20221120152707-495c53812d05/go.mod h1:M9R1FoZ3y//hwwnJtO51ypFGwm8ZfpxPT/ZLtO1mcgQ= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= @@ -72,8 +56,6 @@ github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBql github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= -github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= -github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/zSDKs/sdk-cli/internal/cli/auth.go b/zSDKs/sdk-cli/internal/cli/auth.go index 05433e8b8..a70f4341b 100644 --- a/zSDKs/sdk-cli/internal/cli/auth.go +++ b/zSDKs/sdk-cli/internal/cli/auth.go @@ -3,16 +3,13 @@ package cli import ( - "charm.land/huh/v2" - "charm.land/lipgloss/v2" "cmp" "fmt" "github.com/spf13/cobra" - "golang.org/x/term" "openapi/internal/config" "openapi/internal/flagutil" + "openapi/internal/forms" "openapi/internal/interactive" - "os" ) // initAuthCmd registers the auth command group with login, whoami, and logout subcommands. @@ -90,7 +87,7 @@ This removes all credentials previously set via auth login or configure.`, return nil } -// runAuthLoginCmd executes the auth login command using huh forms. +// runAuthLoginCmd executes the auth login command using guided forms. func runAuthLoginCmd(cmd *cobra.Command, args []string) error { if dryRunLocalNoop(cmd, "auth login changes local credentials only (no API request); nothing was changed.") { return nil @@ -186,23 +183,22 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { } else { accessible := formMode == interactive.FormAccessible + promptInput := forms.NewReader(cmd.InOrStdin()) var selectedScheme string - schemeSelect := huh.NewSelect[string](). + schemeSelect := forms.NewSelect(&selectedScheme, + forms.NewOption("HTTP Basic (Username + Password)", "user-pass-auth"), + forms.NewOption("HTTP Bearer + API Key", "option2"), + forms.NewOption("OAuth2 Authorization", "option3"), + forms.NewOption("Custom (Custom authentication credential + Custom authentication credential)", "option4"), + forms.NewOption("OAuth2 Password flow, a flow that is too\nlong to describe in a single line.", "option5"), + forms.NewOption("OAuth2 Client Credentials", "option6"), + forms.NewOption("API Key", "my-api-key"), + ). Title("Authentication Method"). - Description("Choose which credentials to configure"). - Options( - huh.NewOption("HTTP Basic (Username + Password)", "user-pass-auth"), - huh.NewOption("HTTP Bearer + API Key", "option2"), - huh.NewOption("OAuth2 Authorization", "option3"), - huh.NewOption("Custom (Custom authentication credential + Custom authentication credential)", "option4"), - huh.NewOption("OAuth2 Password flow, a flow that is too\nlong to describe in a single line.", "option5"), - huh.NewOption("OAuth2 Client Credentials", "option6"), - huh.NewOption("API Key", "my-api-key"), - ). - Value(&selectedScheme) - - if err := huh.NewForm(huh.NewGroup(schemeSelect)).WithAccessible(accessible).WithTheme(authFormTheme()).WithWidth(authFormWidth()).WithShowHelp(false).Run(); err != nil { + Description("Choose which credentials to configure") + + if err := forms.New(forms.NewPage("", schemeSelect)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()).Run(); err != nil { return fmt.Errorf("auth login: %w", err) } @@ -211,25 +207,19 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { var authUsername string var authPassword string - fields := []huh.Field{ - huh.NewInput(). + fields := []*forms.Field{ + forms.NewInput(&authUsername). Title("HTTP Basic username"). Description("--username"). - Placeholder(cfg.Security.Username). - Value(&authUsername), - huh.NewInput(). + Placeholder(cfg.Security.Username), + forms.NewInput(&authPassword). Title("HTTP Basic password"). Description("--password"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("password", cfg.Security.Password))). - Value(&authPassword), + Password(). + Placeholder(maskSecret(config.GetStoredSecret("password", cfg.Security.Password))), } - form := huh.NewForm(huh.NewGroup(fields...)). - WithAccessible(accessible). - WithTheme(authFormTheme()). - WithWidth(authFormWidth()). - WithShowHelp(false) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -249,26 +239,20 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { var authBearerAuth string var authMyApiKey string - fields := []huh.Field{ - huh.NewInput(). + fields := []*forms.Field{ + forms.NewInput(&authBearerAuth). Title("HTTP Bearer"). Description("--bearer-auth"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("bearer-auth", cfg.Security.BearerAuth))). - Value(&authBearerAuth), - huh.NewInput(). + Password(). + Placeholder(maskSecret(config.GetStoredSecret("bearer-auth", cfg.Security.BearerAuth))), + forms.NewInput(&authMyApiKey). Title("API Key"). Description("--my-api-key"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))). - Value(&authMyApiKey), + Password(). + Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))), } - form := huh.NewForm(huh.NewGroup(fields...)). - WithAccessible(accessible). - WithTheme(authFormTheme()). - WithWidth(authFormWidth()). - WithShowHelp(false) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -289,20 +273,15 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { case "option3": var authOauth2 string - fields := []huh.Field{ - huh.NewInput(). + fields := []*forms.Field{ + forms.NewInput(&authOauth2). Title("OAuth2 Authorization"). Description("--oauth2"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("oauth2", cfg.Security.Oauth2))). - Value(&authOauth2), + Password(). + Placeholder(maskSecret(config.GetStoredSecret("oauth2", cfg.Security.Oauth2))), } - form := huh.NewForm(huh.NewGroup(fields...)). - WithAccessible(accessible). - WithTheme(authFormTheme()). - WithWidth(authFormWidth()). - WithShowHelp(false) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -318,26 +297,20 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { var authAppId string var authSecret string - fields := []huh.Field{ - huh.NewInput(). + fields := []*forms.Field{ + forms.NewInput(&authAppId). Title("Custom authentication credential"). Description("--app-id"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("app-id", cfg.Security.AppId))). - Value(&authAppId), - huh.NewInput(). + Password(). + Placeholder(maskSecret(config.GetStoredSecret("app-id", cfg.Security.AppId))), + forms.NewInput(&authSecret). Title("Custom authentication credential"). Description("--secret"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("secret", cfg.Security.Secret))). - Value(&authSecret), + Password(). + Placeholder(maskSecret(config.GetStoredSecret("secret", cfg.Security.Secret))), } - form := huh.NewForm(huh.NewGroup(fields...)). - WithAccessible(accessible). - WithTheme(authFormTheme()). - WithWidth(authFormWidth()). - WithShowHelp(false) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -358,20 +331,15 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { case "option5": var authMobileAuth string - fields := []huh.Field{ - huh.NewInput(). + fields := []*forms.Field{ + forms.NewInput(&authMobileAuth). Title("OAuth2 Password flow, a flow that is too\nlong to describe in a single line."). Description("--mobile-auth"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("mobile-auth", cfg.Security.MobileAuth))). - Value(&authMobileAuth), + Password(). + Placeholder(maskSecret(config.GetStoredSecret("mobile-auth", cfg.Security.MobileAuth))), } - form := huh.NewForm(huh.NewGroup(fields...)). - WithAccessible(accessible). - WithTheme(authFormTheme()). - WithWidth(authFormWidth()). - WithShowHelp(false) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -388,31 +356,24 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { var authClientSecret string var authTokenURL string - fields := []huh.Field{ - huh.NewInput(). + fields := []*forms.Field{ + forms.NewInput(&authClientID). Title("Client Credentials flow. client identifier"). Description("--client-id"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("client-id", cfg.Security.ClientID))). - Value(&authClientID), - huh.NewInput(). + Password(). + Placeholder(maskSecret(config.GetStoredSecret("client-id", cfg.Security.ClientID))), + forms.NewInput(&authClientSecret). Title("Client Credentials flow. client secret"). Description("--client-secret"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("client-secret", cfg.Security.ClientSecret))). - Value(&authClientSecret), - huh.NewInput(). + Password(). + Placeholder(maskSecret(config.GetStoredSecret("client-secret", cfg.Security.ClientSecret))), + forms.NewInput(&authTokenURL). Title("Client Credentials flow. token URL"). Description("--token-url"). - Placeholder(cmp.Or(cfg.Security.TokenURL, "/clientcredentials/token")). - Value(&authTokenURL), + Placeholder(cmp.Or(cfg.Security.TokenURL, "/clientcredentials/token")), } - form := huh.NewForm(huh.NewGroup(fields...)). - WithAccessible(accessible). - WithTheme(authFormTheme()). - WithWidth(authFormWidth()). - WithShowHelp(false) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -437,20 +398,15 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { case "my-api-key": var authMyApiKey string - fields := []huh.Field{ - huh.NewInput(). + fields := []*forms.Field{ + forms.NewInput(&authMyApiKey). Title("API Key"). Description("--my-api-key"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))). - Value(&authMyApiKey), + Password(). + Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))), } - form := huh.NewForm(huh.NewGroup(fields...)). - WithAccessible(accessible). - WithTheme(authFormTheme()). - WithWidth(authFormWidth()). - WithShowHelp(false) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -540,64 +496,3 @@ func runAuthLogoutCmd(cmd *cobra.Command, args []string) error { fmt.Fprintf(out, "Configuration saved to %s\n", config.GetConfigPath()) return nil } - -// authFormTheme builds the form theme for auth login. -func authFormTheme() huh.Theme { - return huh.ThemeFunc(authFormStyles) -} - -func authFormStyles(isDark bool) *huh.Styles { - t := *huh.ThemeBase(isDark) - - accent := lipgloss.Color("#38BDF8") - dimmed := lipgloss.Color("#64748B") - subtle := lipgloss.Color("#475569") - errColor := lipgloss.Color("#F87171") - greenColor := lipgloss.Color("#4ADE80") - - t.Focused.Base = t.Focused.Base. - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(accent). - PaddingLeft(1) - t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true) - t.Focused.Description = t.Focused.Description.Foreground(dimmed).Italic(true) - t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errColor) - t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(greenColor).SetString("✓ ").Bold(true) - t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") - t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) - t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - t.Focused.Next = t.Focused.FocusedButton - - t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) - t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent) - - t.Blurred.Base = t.Blurred.Base. - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(subtle). - PaddingLeft(1) - t.Blurred.Title = t.Blurred.Title.Foreground(dimmed) - t.Blurred.Description = t.Blurred.Description.Foreground(subtle).Italic(true) - t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(dimmed) - t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) - t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - t.Blurred.SelectedPrefix = lipgloss.NewStyle().Foreground(dimmed).SetString("✓ ") - t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") - - return &t -} - -// authFormWidth returns the terminal width for sizing huh forms. -func authFormWidth() int { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - width = 80 - } - return width -} diff --git a/zSDKs/sdk-cli/internal/cli/configure.go b/zSDKs/sdk-cli/internal/cli/configure.go index 1431add7a..248889533 100644 --- a/zSDKs/sdk-cli/internal/cli/configure.go +++ b/zSDKs/sdk-cli/internal/cli/configure.go @@ -3,20 +3,17 @@ package cli import ( - "charm.land/huh/v2" - "charm.land/lipgloss/v2" "cmp" "encoding/json" "fmt" "github.com/spf13/cobra" - "golang.org/x/term" "openapi/internal/client" "openapi/internal/config" "openapi/internal/flagutil" + "openapi/internal/forms" "openapi/internal/interactive" "openapi/internal/output" "openapi/internal/usage" - "os" "strings" ) @@ -184,125 +181,104 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { var cfgGlobalLoneQueryParam string accessible := formMode == interactive.FormAccessible - var groups []*huh.Group - securityFields := []huh.Field{ - huh.NewInput(). + var pages []*forms.Page + securityFields := []*forms.Field{ + forms.NewInput(&authUsername). Title("HTTP Basic username"). Description("--username"). - Placeholder(cfg.Security.Username). - Value(&authUsername), - huh.NewInput(). + Placeholder(cfg.Security.Username), + forms.NewInput(&authPassword). Title("HTTP Basic password"). Description("--password"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("password", cfg.Security.Password))). - Value(&authPassword), - huh.NewInput(). + Password(). + Placeholder(maskSecret(config.GetStoredSecret("password", cfg.Security.Password))), + forms.NewInput(&authBearerAuth). Title("HTTP Bearer"). Description("--bearer-auth"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("bearer-auth", cfg.Security.BearerAuth))). - Value(&authBearerAuth), - huh.NewInput(). + Password(). + Placeholder(maskSecret(config.GetStoredSecret("bearer-auth", cfg.Security.BearerAuth))), + forms.NewInput(&authMyApiKey). Title("API Key"). Description("--my-api-key"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))). - Value(&authMyApiKey), - huh.NewInput(). + Password(). + Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))), + forms.NewInput(&authOauth2). Title("OAuth2 Authorization"). Description("--oauth2"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("oauth2", cfg.Security.Oauth2))). - Value(&authOauth2), - huh.NewInput(). + Password(). + Placeholder(maskSecret(config.GetStoredSecret("oauth2", cfg.Security.Oauth2))), + forms.NewInput(&authAppId). Title("Custom authentication credential"). Description("--app-id"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("app-id", cfg.Security.AppId))). - Value(&authAppId), - huh.NewInput(). + Password(). + Placeholder(maskSecret(config.GetStoredSecret("app-id", cfg.Security.AppId))), + forms.NewInput(&authSecret). Title("Custom authentication credential"). Description("--secret"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("secret", cfg.Security.Secret))). - Value(&authSecret), - huh.NewInput(). + Password(). + Placeholder(maskSecret(config.GetStoredSecret("secret", cfg.Security.Secret))), + forms.NewInput(&authMobileAuth). Title("OAuth2 Password flow, a flow that is too\nlong to describe in a single line."). Description("--mobile-auth"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("mobile-auth", cfg.Security.MobileAuth))). - Value(&authMobileAuth), - huh.NewInput(). + Password(). + Placeholder(maskSecret(config.GetStoredSecret("mobile-auth", cfg.Security.MobileAuth))), + forms.NewInput(&authClientID). Title("Client Credentials flow. client identifier"). Description("--client-id"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("client-id", cfg.Security.ClientID))). - Value(&authClientID), - huh.NewInput(). + Password(). + Placeholder(maskSecret(config.GetStoredSecret("client-id", cfg.Security.ClientID))), + forms.NewInput(&authClientSecret). Title("Client Credentials flow. client secret"). Description("--client-secret"). - EchoMode(huh.EchoModePassword). - Placeholder(maskSecret(config.GetStoredSecret("client-secret", cfg.Security.ClientSecret))). - Value(&authClientSecret), - huh.NewInput(). + Password(). + Placeholder(maskSecret(config.GetStoredSecret("client-secret", cfg.Security.ClientSecret))), + forms.NewInput(&authTokenURL). Title("Client Credentials flow. token URL"). Description("--token-url"). - Placeholder(cmp.Or(cfg.Security.TokenURL, "/clientcredentials/token")). - Value(&authTokenURL), + Placeholder(cmp.Or(cfg.Security.TokenURL, "/clientcredentials/token")), } - groups = append(groups, huh.NewGroup(securityFields...).Title("Authentication")) - globalFields := []huh.Field{ - huh.NewInput(). + pages = append(pages, forms.NewPage("Authentication", securityFields...)) + globalFields := []*forms.Field{ + forms.NewInput(&cfgGlobalQueryParam1). Title("A long winded, multi-line description"). Description("--query-param1"). - Placeholder(cfg.Globals.QueryParam1). - Value(&cfgGlobalQueryParam1), - huh.NewInput(). + Placeholder(cfg.Globals.QueryParam1), + forms.NewInput(&cfgGlobalDeprecatedQueryParam1). Title("A deprecated description"). Description("--deprecated-query-param1"). - Placeholder(cfg.Globals.DeprecatedQueryParam1). - Value(&cfgGlobalDeprecatedQueryParam1), - huh.NewInput(). + Placeholder(cfg.Globals.DeprecatedQueryParam1), + forms.NewInput(&cfgGlobalDeprecatedQueryParam2). Title("Global deprecated-query-param2 parameter"). Description("--deprecated-query-param2"). - Placeholder(cfg.Globals.DeprecatedQueryParam2). - Value(&cfgGlobalDeprecatedQueryParam2), - huh.NewInput(). + Placeholder(cfg.Globals.DeprecatedQueryParam2), + forms.NewInput(&cfgGlobalLoneQueryParam). Title("Global lone-query-param parameter"). Description("--lone-query-param"). - Placeholder(cfg.Globals.LoneQueryParam). - Value(&cfgGlobalLoneQueryParam), + Placeholder(cfg.Globals.LoneQueryParam), } - groups = append(groups, huh.NewGroup(globalFields...).Title("Global Parameters")) + pages = append(pages, forms.NewPage("Global Parameters", globalFields...)) - // Preference fields use huh.Select which loops forever on EOF in - // accessible mode (non-TTY). Only show them when truly interactive. + // Preferences are only offered in the interactive form; line prompts + // cover credentials and global parameters. var cfgOutputFormat string if !accessible { - preferenceFields := []huh.Field{ - huh.NewSelect[string](). + preferenceFields := []*forms.Field{ + forms.NewSelect(&cfgOutputFormat, + forms.NewOption("Keep current", ""), + forms.NewOption("Clear (use built-in default: pretty)", "__CLEAR__"), + forms.NewOption("pretty", "pretty"), + forms.NewOption("json", "json"), + forms.NewOption("yaml", "yaml"), + forms.NewOption("table", "table"), + forms.NewOption("toon", "toon"), + ). Title("Default output format"). - Description("Choose the default response rendering format for this CLI"). - Options( - huh.NewOption("Keep current", ""), - huh.NewOption("Clear (use built-in default: pretty)", "__CLEAR__"), - huh.NewOption("pretty", "pretty"), - huh.NewOption("json", "json"), - huh.NewOption("yaml", "yaml"), - huh.NewOption("table", "table"), - huh.NewOption("toon", "toon"), - ). - Value(&cfgOutputFormat), + Description("Choose the default response rendering format for this CLI"), } - groups = append(groups, huh.NewGroup(preferenceFields...).Title("Preferences")) + pages = append(pages, forms.NewPage("Preferences", preferenceFields...)) } - form := huh.NewForm(groups...). - WithAccessible(accessible). - WithTheme(configureFormTheme()). - WithWidth(configureFormWidth()). - WithShowHelp(false) + form := forms.New(pages...).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("configure: %w", err) @@ -427,64 +403,3 @@ func dryRunLocalNoop(cmd *cobra.Command, message string) bool { } return true } - -// configureFormTheme builds the form theme for the configure command. -func configureFormTheme() huh.Theme { - return huh.ThemeFunc(configureFormStyles) -} - -func configureFormStyles(isDark bool) *huh.Styles { - t := *huh.ThemeBase(isDark) - - accent := lipgloss.Color("#38BDF8") - dimmed := lipgloss.Color("#64748B") - subtle := lipgloss.Color("#475569") - errColor := lipgloss.Color("#F87171") - greenColor := lipgloss.Color("#4ADE80") - - t.Focused.Base = t.Focused.Base. - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(accent). - PaddingLeft(1) - t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true) - t.Focused.Description = t.Focused.Description.Foreground(dimmed).Italic(true) - t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errColor) - t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(greenColor).SetString("✓ ").Bold(true) - t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") - t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) - t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - t.Focused.Next = t.Focused.FocusedButton - - t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) - t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent) - - t.Blurred.Base = t.Blurred.Base. - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(subtle). - PaddingLeft(1) - t.Blurred.Title = t.Blurred.Title.Foreground(dimmed) - t.Blurred.Description = t.Blurred.Description.Foreground(subtle).Italic(true) - t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(dimmed) - t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) - t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - t.Blurred.SelectedPrefix = lipgloss.NewStyle().Foreground(dimmed).SetString("✓ ") - t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") - - return &t -} - -// configureFormWidth returns the terminal width for sizing huh forms. -func configureFormWidth() int { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - width = 80 - } - return width -} diff --git a/zSDKs/sdk-cli/internal/forms/forms.go b/zSDKs/sdk-cli/internal/forms/forms.go new file mode 100644 index 000000000..41efde310 --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/forms.go @@ -0,0 +1,763 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +// Package forms asks the questions behind the CLI's guided setup: pages of +// text, secret, JSON, choice and yes/no fields. Terminals that can redraw get +// an interactive form; everything else gets plain line prompts. +package forms + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "golang.org/x/term" +) + +// ErrUserAborted is returned by Run when the user cancels with ctrl+c. +var ErrUserAborted = errors.New("user aborted") + +var ( + accent = lipgloss.Color("#38BDF8") + dimmed = lipgloss.Color("#64748B") + subtle = lipgloss.Color("#475569") + errColor = lipgloss.Color("#F87171") +) + +type fieldKind int + +const ( + kindInput fieldKind = iota + kindText + kindSelect + kindConfirm +) + +// Option is a choice in a select field: Label is shown, Value is stored. +type Option struct { + Label string + Value string +} + +// NewOption returns a select option. +func NewOption(label, value string) Option { + return Option{Label: label, Value: value} +} + +// Field is one question. Its answer is written to the variable passed to the +// constructor as the user edits it. +type Field struct { + kind fieldKind + title string + description string + validate func(string) error + err error + + text *string // input, text and select answers + yes *bool // confirm answer + secret bool + + options []Option + cursor int + halfPage int // rows moved by ctrl+d and ctrl+u, set from the visible window + + input textinput.Model + area textarea.Model +} + +// NewInput asks for a single line of text, starting from *value. +func NewInput(value *string) *Field { + in := textinput.New() + in.SetValue(*value) + return &Field{kind: kindInput, text: value, input: in} +} + +// textRows is the editor height when the terminal has room for it. +const textRows = 6 + +// NewText asks for multi-line text such as JSON, starting from *value. +// alt+enter or ctrl+j inserts a newline; enter moves on. +func NewText(value *string) *Field { + area := textarea.New() + area.ShowLineNumbers = false + area.Prompt = "" + area.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j") + area.SetHeight(textRows) + area.SetValue(*value) + return &Field{kind: kindText, text: value, area: area} +} + +// NewSelect asks for one of options, starting at the option matching *value. +func NewSelect(value *string, options ...Option) *Field { + f := &Field{kind: kindSelect, text: value, options: options} + for i, o := range options { + if o.Value == *value { + f.cursor = i + break + } + } + if len(options) > 0 { + *value = options[f.cursor].Value + } + return f +} + +// NewConfirm asks a yes/no question, starting from *value. +func NewConfirm(value *bool) *Field { + return &Field{kind: kindConfirm, yes: value} +} + +// Title sets the question. +func (f *Field) Title(title string) *Field { f.title = title; return f } + +// Description sets the hint shown under the question. +func (f *Field) Description(description string) *Field { f.description = description; return f } + +// Placeholder sets the text shown while an input is empty. +func (f *Field) Placeholder(placeholder string) *Field { + f.input.Placeholder = placeholder + return f +} + +// Password hides what is typed into an input. +func (f *Field) Password() *Field { + f.secret = true + f.input.EchoMode = textinput.EchoPassword + return f +} + +// Validate rejects answers before the form moves past the field. +func (f *Field) Validate(validate func(string) error) *Field { + f.validate = validate + return f +} + +func (f *Field) check() error { + if f.text == nil { + return nil + } + return f.checkValue(*f.text) +} + +func (f *Field) checkValue(value string) error { + if f.validate == nil { + return nil + } + return f.validate(value) +} + +func (f *Field) focus() tea.Cmd { + switch f.kind { + case kindInput: + return f.input.Focus() + case kindText: + return f.area.Focus() + } + return nil +} + +func (f *Field) blur() { + switch f.kind { + case kindInput: + f.input.Blur() + case kindText: + f.area.Blur() + } +} + +func (f *Field) setWidth(width int) { + inner := max(width-4, 1) + switch f.kind { + case kindInput: + f.input.SetWidth(max(inner-lipgloss.Width(f.input.Prompt)-1, 1)) + case kindText: + f.area.SetWidth(inner) + } +} + +// update applies a message to the focused field. advance reports that the +// key also answered the question (y or n on a confirm). +func (f *Field) update(msg tea.Msg) (cmd tea.Cmd, advance bool) { + key, isKey := msg.(tea.KeyPressMsg) + if isKey { + f.err = nil + } + switch f.kind { + case kindInput: + f.input, cmd = f.input.Update(msg) + *f.text = f.input.Value() + case kindText: + f.area, cmd = f.area.Update(msg) + *f.text = f.area.Value() + case kindSelect: + if !isKey || len(f.options) == 0 { + return nil, false + } + n := len(f.options) + switch key.String() { + case "up", "k", "ctrl+p": + f.cursor = (f.cursor - 1 + n) % n + case "down", "j", "ctrl+n": + f.cursor = (f.cursor + 1) % n + case "home", "g": + f.cursor = 0 + case "end", "G": + f.cursor = n - 1 + case "ctrl+d": + f.cursor = min(f.cursor+f.halfPage, n-1) + case "ctrl+u": + f.cursor = max(f.cursor-f.halfPage, 0) + } + *f.text = f.options[f.cursor].Value + case kindConfirm: + if !isKey { + return nil, false + } + switch key.String() { + case "left", "right", "h", "l": + *f.yes = !*f.yes + case "y", "Y": + *f.yes = true + return nil, true + case "n", "N": + *f.yes = false + return nil, true + } + } + return cmd, false +} + +// Page is a set of fields shown together under an optional heading. +type Page struct { + title string + fields []*Field +} + +// NewPage groups fields onto one screen. +func NewPage(title string, fields ...*Field) *Page { + return &Page{title: title, fields: fields} +} + +// Form steps through pages of fields. enter and tab move forward, shift+tab +// moves back, and enter on the last field submits. +type Form struct { + pages []*Page + page int + field int + accessible bool + in io.Reader + out io.Writer + width int + height int + scroll int // first rendered line when the page is taller than the terminal + finished bool + aborted bool +} + +// New returns a form over pages. +func New(pages ...*Page) *Form { + f := &Form{pages: pages, in: os.Stdin, out: os.Stdout} + f.resize(terminalWidth(), 0) + return f +} + +// Accessible switches between the interactive form and line prompts. Dumb +// terminals cannot redraw, so they always get line prompts. +func (f *Form) Accessible(on bool) *Form { + f.accessible = on + return f +} + +func (f *Form) linePrompts() bool { + return f.accessible || os.Getenv("TERM") == "dumb" +} + +// IO sets where line prompts read answers and write questions. The +// interactive form also reads keys from in unless it is standard input, and +// always draws on the terminal. +func (f *Form) IO(in io.Reader, out io.Writer) *Form { + f.in, f.out = in, out + return f +} + +func terminalWidth() int { + if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && width > 0 { + return width + } + return 80 +} + +// Run asks every question and returns once the form is submitted. Answers are +// written to the variables bound to each field. +func (f *Form) Run() error { + if f.empty() { + return nil + } + if f.linePrompts() { + return f.prompt(f.out, f.in) + } + opts := []tea.ProgramOption{tea.WithOutput(os.Stderr)} + if in := keyInput(f.in); in != os.Stdin { + opts = append(opts, tea.WithInput(in)) + } + if _, err := tea.NewProgram(f, opts...).Run(); err != nil { + if errors.Is(err, tea.ErrInterrupted) { + return ErrUserAborted + } + return fmt.Errorf("form: %w", err) + } + if f.aborted { + return ErrUserAborted + } + return nil +} + +// keyInput returns where the interactive form reads keys: the file behind a +// shared Reader so a terminal can be put in raw mode, otherwise in itself. +// Standard input is left to bubbletea, which falls back to the terminal when +// stdin is piped. +func keyInput(in io.Reader) io.Reader { + if shared, ok := in.(*Reader); ok && shared.file != nil { + return shared.file + } + return in +} + +func (f *Form) empty() bool { + for _, p := range f.pages { + if len(p.fields) > 0 { + return false + } + } + return true +} + +func (f *Form) resize(width, height int) { + f.width, f.height = width, height + for _, p := range f.pages { + for _, field := range p.fields { + field.setWidth(width) + } + } +} + +func (f *Form) current() *Field { + return f.pages[f.page].fields[f.field] +} + +func (f *Form) onLastField() bool { + _, _, ok := f.neighbour(1) + return !ok +} + +// neighbour finds the field one step away across page boundaries, skipping +// empty pages. ok is false when there is no field in that direction. +func (f *Form) neighbour(delta int) (page, field int, ok bool) { + page, field = f.page, f.field+delta + for { + switch { + case field < 0: + if page--; page < 0 { + return 0, 0, false + } + field = len(f.pages[page].fields) - 1 + case field >= len(f.pages[page].fields): + if page++; page >= len(f.pages) { + return 0, 0, false + } + field = 0 + default: + return page, field, true + } + } +} + +// Init implements [tea.Model]. +func (f *Form) Init() tea.Cmd { + if f.empty() { + return nil + } + for f.field >= len(f.pages[f.page].fields) { + f.page++ + } + return tea.Batch(f.current().focus(), tea.RequestWindowSize) +} + +// Update implements [tea.Model]. +func (f *Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + f.resize(msg.Width, msg.Height) + return f, nil + case tea.KeyPressMsg: + switch msg.String() { + case "ctrl+c": + f.aborted = true + return f, tea.Quit + case "shift+tab": + return f, f.move(-1) + case "tab": + if f.onLastField() { + return f, nil + } + return f, f.forward() + case "enter": + return f, f.forward() + } + } + cmd, advance := f.current().update(msg) + if advance { + return f, tea.Batch(cmd, f.forward()) + } + return f, cmd +} + +// forward validates the focused field, then moves on or submits. +func (f *Form) forward() tea.Cmd { + cur := f.current() + if cur.err = cur.check(); cur.err != nil { + return nil + } + if f.onLastField() { + f.finished = true + return tea.Quit + } + return f.move(1) +} + +func (f *Form) move(delta int) tea.Cmd { + page, field, ok := f.neighbour(delta) + if !ok { + return nil + } + f.current().blur() + f.page, f.field = page, field + return f.current().focus() +} + +// View implements [tea.Model]. +func (f *Form) View() tea.View { + if f.finished || f.aborted { + return tea.NewView("") + } + return tea.NewView(f.render()) +} + +func (f *Form) render() string { + page := f.pages[f.page] + var lines []string + if page.title != "" { + title := wrap(page.title, max(f.width, 1)) + lines = append(lines, strings.Split(lipgloss.NewStyle().Foreground(accent).Bold(true).Render(title), "\n")...) + lines = append(lines, "") + } + focusStart, focusEnd := 0, 0 + for i, field := range page.fields { + if i > 0 { + lines = append(lines, "") + } + if i == f.field { + focusStart = len(lines) + } + block, errRows := f.renderField(field, i == f.field) + lines = append(lines, strings.Split(block, "\n")...) + if i == f.field { + focusEnd = len(lines) + if f.height > 0 && focusEnd-focusStart > f.height && errRows > 1 { + // Too tall for the terminal: keep the input in view with the + // first line of its error under it. + focusEnd -= errRows - 1 + } + } + } + if f.field == 0 && (f.height <= 0 || focusEnd <= f.height) { + focusStart = 0 // keep the page title in view with the first field + } + return strings.Join(f.scrollTo(lines, focusStart, focusEnd), "\n") +} + +// scrollTo returns the lines that fit the terminal, moving the window as +// little as possible to show lines[start:end]. A block taller than the +// terminal shows its end, where the input is. +func (f *Form) scrollTo(lines []string, start, end int) []string { + if f.height <= 0 || len(lines) <= f.height { + f.scroll = 0 + return lines + } + if end-start > f.height { + f.scroll = end - f.height + } else if start < f.scroll { + f.scroll = start + } else if end > f.scroll+f.height { + f.scroll = end - f.height + } + f.scroll = min(max(f.scroll, 0), len(lines)-f.height) + return lines[f.scroll : f.scroll+f.height] +} + +// renderField draws a field inside its border and reports how many of the +// rows at its end are the validation error. +func (f *Form) renderField(field *Field, focused bool) (string, int) { + inner := max(f.width-4, 1) + bar, title := subtle, lipgloss.NewStyle().Foreground(dimmed) + if focused { + bar, title = accent, lipgloss.NewStyle().Foreground(accent).Bold(true) + } + lines := []string{} + if field.title != "" { + lines = append(lines, title.Render(wrap(field.title, inner))) + } + if field.description != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(subtle).Italic(true).Render(wrap(field.description, inner))) + } + var errLines []string + if field.err != nil { + errLines = append(errLines, lipgloss.NewStyle().Foreground(errColor).Render(wrap("! "+field.err.Error(), inner))) + } + // Rows left for the editor or options once the heading and error fit. + avail := f.height - rows(lines) - rows(errLines) + switch field.kind { + case kindInput: + lines = append(lines, field.input.View()) + case kindText: + if f.height > 0 { + field.area.SetHeight(min(max(avail, 1), textRows)) + } + lines = append(lines, field.area.View()) + case kindSelect: + lines = append(lines, f.renderOptions(field, focused, avail-1)) // minus the position line + case kindConfirm: + lines = append(lines, renderButtons(*field.yes, focused, inner)) + } + lines = append(lines, errLines...) + return lipgloss.NewStyle(). + BorderStyle(lipgloss.ThickBorder()).BorderLeft(true).BorderForeground(bar). + PaddingLeft(1). + Render(strings.Join(lines, "\n")), rows(errLines) +} + +func rows(blocks []string) int { + n := 0 + for _, b := range blocks { + n += lipgloss.Height(b) + } + return n +} + +// renderOptions shows the options around the cursor that fit the terminal +// height. Multi-line labels stay aligned under their first line. +func (f *Form) renderOptions(field *Field, focused bool, height int) string { + picked := lipgloss.NewStyle().Foreground(dimmed) + if focused { + picked = lipgloss.NewStyle().Foreground(accent).Bold(true) + } + labelWidth := max(f.width-6, 1) + blocks := make([]string, len(field.options)) + for i, o := range field.options { + label := strings.ReplaceAll(wrap(o.Label, labelWidth), "\n", "\n ") + if i == field.cursor { + blocks[i] = picked.Render("> " + label) + } else { + blocks[i] = " " + label + } + } + + budget := 0 + if f.height > 0 { + budget = max(height, 1) + } + start, end := 0, len(blocks) + if budget > 0 && len(blocks) > 0 { + start, end = field.cursor, field.cursor+1 + used := lipgloss.Height(blocks[field.cursor]) + for grew := true; grew; { + grew = false + if end < len(blocks) && used+lipgloss.Height(blocks[end]) <= budget { + used += lipgloss.Height(blocks[end]) + end++ + grew = true + } + if start > 0 && used+lipgloss.Height(blocks[start-1]) <= budget { + start-- + used += lipgloss.Height(blocks[start]) + grew = true + } + } + } + field.halfPage = max((end-start)/2, 1) + + out := strings.Join(blocks[start:end], "\n") + if start > 0 || end < len(blocks) { + out += "\n" + lipgloss.NewStyle().Foreground(subtle).Render(fmt.Sprintf(" %d/%d", field.cursor+1, len(blocks))) + } + return out +} + +func renderButtons(yes, focused bool, width int) string { + pad, yesLabel, noLabel := 2, "Yes", "No" + if width < 14 { + pad = 0 + } + if width < 6 { + yesLabel, noLabel = "Y", "N" + } + on := lipgloss.NewStyle().Padding(0, pad).Foreground(lipgloss.Color("#FFFFFF")).Background(subtle) + if focused { + on = on.Background(accent) + } + off := lipgloss.NewStyle().Padding(0, pad).Foreground(dimmed) + if yes { + return on.Render(yesLabel) + " " + off.Render(noLabel) + } + return off.Render(yesLabel) + " " + on.Render(noLabel) +} + +// Reader buffers answers for line prompts. Pass the same Reader to every form +// a command runs over one input so that answers read ahead by one form are +// left for the next. +type Reader struct { + buf *bufio.Reader + file *os.File // the terminal secrets are read from without echo +} + +// NewReader wraps r for sharing between forms. A Reader is returned as is. +func NewReader(r io.Reader) *Reader { + if shared, ok := r.(*Reader); ok { + return shared + } + file, _ := r.(*os.File) + return &Reader{buf: bufio.NewReader(r), file: file} +} + +// Read implements [io.Reader]. +func (r *Reader) Read(p []byte) (int, error) { return r.buf.Read(p) } + +// prompt asks every question as a plain line prompt. Blank answers keep the +// current value; end of input leaves the remaining answers unchanged. +func (f *Form) prompt(w io.Writer, r io.Reader) error { + in := NewReader(r) + for _, page := range f.pages { + for _, field := range page.fields { + err := field.ask(w, in) + fmt.Fprintln(w) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + } + } + return nil +} + +// ask prompts for one field until the answer is valid. It returns io.EOF at +// end of input. +func (f *Field) ask(w io.Writer, in *Reader) error { + if f.title != "" { + fmt.Fprintln(w, f.title) + } + if f.description != "" { + fmt.Fprintln(w, f.description) + } + switch f.kind { + case kindSelect: + for i, o := range f.options { + fmt.Fprintf(w, " %d. %s\n", i+1, o.Label) + } + } + for { + switch f.kind { + case kindSelect: + fmt.Fprintf(w, "Choose 1-%d [%d]: ", len(f.options), f.cursor+1) + case kindConfirm: + if *f.yes { + fmt.Fprint(w, "[Y/n]: ") + } else { + fmt.Fprint(w, "[y/N]: ") + } + default: + fmt.Fprint(w, "> ") + } + line, err := readAnswer(w, in, f.secret) + if err != nil { + return err + } + if msg := f.answer(line); msg != "" { + fmt.Fprintln(w, msg) + continue + } + return nil + } +} + +// answer applies a typed line, returning a message when it must be re-asked. +func (f *Field) answer(line string) string { + blank := strings.TrimSpace(line) == "" + if f.kind == kindConfirm { + switch strings.ToLower(strings.TrimSpace(line)) { + case "": + case "y", "yes": + *f.yes = true + case "n", "no": + *f.yes = false + default: + return "Answer y or n." + } + return "" + } + value, pick := *f.text, f.cursor + if f.kind == kindSelect { + if len(f.options) == 0 { + return "" + } + if !blank { + n, err := strconv.Atoi(strings.TrimSpace(line)) + if err != nil || n < 1 || n > len(f.options) { + return fmt.Sprintf("Enter a number from 1 to %d.", len(f.options)) + } + pick = n - 1 + } + value = f.options[pick].Value + } else if !blank { + value = line + } + if err := f.checkValue(value); err != nil { + return err.Error() + } + f.cursor = pick + *f.text = value + return "" +} + +// readAnswer reads one line, without echo for secrets typed at a terminal. +// It returns io.EOF only when no answer was read. +func readAnswer(w io.Writer, in *Reader, secret bool) (string, error) { + if in.file != nil && secret && term.IsTerminal(int(in.file.Fd())) { + b, err := term.ReadPassword(int(in.file.Fd())) + fmt.Fprintln(w) + return string(b), err + } + line, err := in.buf.ReadString('\n') + if err != nil && (line == "" || !errors.Is(err, io.EOF)) { + return "", err + } + return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil +} + +// wrap breaks s at spaces to fit width, without trailing spaces that some +// terminals scroll on in the last column. +func wrap(s string, width int) string { + lines := strings.Split(lipgloss.Wrap(s, width, " "), "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " ") + } + return strings.Join(lines, "\n") +} diff --git a/zSDKs/sdk-cli/internal/interactive/interactive.go b/zSDKs/sdk-cli/internal/interactive/interactive.go index 0a48a658e..ba20b5379 100644 --- a/zSDKs/sdk-cli/internal/interactive/interactive.go +++ b/zSDKs/sdk-cli/internal/interactive/interactive.go @@ -14,14 +14,13 @@ import ( "strings" "time" - "charm.land/huh/v2" "charm.land/lipgloss/v2" "github.com/spf13/cobra" "github.com/spf13/pflag" - "golang.org/x/term" "openapi/internal/config" "openapi/internal/flagutil" + "openapi/internal/forms" "openapi/internal/output" "openapi/internal/usage" ) @@ -205,7 +204,7 @@ func interceptCommand(cmd *cobra.Command) { if missing { prompter := injectedPrompter(runCmd) if prompter == nil { - prompter = huhPrompter{} + prompter = formPrompter{} } answers, err := prompter.Prompt(runCmd, fields) if err != nil { @@ -611,57 +610,6 @@ func applyAnswers(cmd *cobra.Command, args []string, fields []PromptField, answe return effective, nil } -// formWidth returns the terminal width for sizing huh forms, with a sensible default. -func formWidth() int { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - width = 80 - } - return width -} - -// formTheme builds the interactive prompt theme. -func formTheme() huh.Theme { - return huh.ThemeFunc(formStyles) -} - -func formStyles(isDark bool) *huh.Styles { - t := *huh.ThemeBase(isDark) - - accent := lipgloss.Color("#38BDF8") - dimmed := lipgloss.Color("#64748B") - subtle := lipgloss.Color("#475569") - errColor := lipgloss.Color("#F87171") - greenColor := lipgloss.Color("#4ADE80") - - t.Focused.Base = t.Focused.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(accent).PaddingLeft(1) - t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true) - t.Focused.Description = t.Focused.Description.Foreground(dimmed).Italic(true) - t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errColor) - t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(greenColor).SetString("✓ ").Bold(true) - t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") - t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) - t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - t.Focused.Next = t.Focused.FocusedButton - t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) - t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent) - - t.Blurred.Base = t.Blurred.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(subtle).PaddingLeft(1) - t.Blurred.Title = t.Blurred.Title.Foreground(dimmed) - t.Blurred.Description = t.Blurred.Description.Foreground(subtle).Italic(true) - t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(dimmed) - t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) - t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - t.Blurred.SelectedPrefix = lipgloss.NewStyle().Foreground(dimmed).SetString("✓ ") - t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") - return &t -} - func printCommandHeader(cmd *cobra.Command, totalRequired, totalOptional int) { accent := lipgloss.Color("#38BDF8") dimmed := lipgloss.Color("#64748B") @@ -689,9 +637,9 @@ func printCommandHeader(cmd *cobra.Command, totalRequired, totalOptional int) { fmt.Fprintln(os.Stderr) } -type huhPrompter struct{} +type formPrompter struct{} -func (huhPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAnswer, error) { +func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAnswer, error) { answers := make([]PromptAnswer, len(fields)) required := make([]int, 0, len(fields)) optionalArgs := make([]int, 0, len(fields)) @@ -709,9 +657,10 @@ func (huhPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAns } } printCommandHeader(cmd, len(required), len(optionalArgs)+len(direct)+len(optionalFlags)) - theme, width := formTheme(), formWidth() + // Every prompt reads from one reader so piped answers carry over. + in := forms.NewReader(cmd.InOrStdin()) for _, index := range required { - answer, err := runPromptField(fields[index], theme, width) + answer, err := runPromptField(cmd, in, fields[index]) if err != nil { return nil, err } @@ -720,14 +669,14 @@ func (huhPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAns for _, index := range optionalArgs { field := fields[index] field.Summary = optionalArgDescription(field.Summary) - answer, err := runPromptField(field, theme, width) + answer, err := runPromptField(cmd, in, field) if err != nil { return nil, err } answers[index] = answer } for _, index := range direct { - answer, err := runPromptField(fields[index], theme, width) + answer, err := runPromptField(cmd, in, fields[index]) if err != nil { return nil, err } @@ -741,16 +690,15 @@ func (huhPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAns for _, index := range optionalFlags { names = append(names, fields[index].Name) } - form := huh.NewForm(huh.NewGroup(huh.NewConfirm(). + form := forms.New(forms.NewPage("", forms.NewConfirm(&fillOptional). Title(fmt.Sprintf("Fill in %d optional field(s)?", len(optionalFlags))). - Description(strings.Join(names, ", ")).Value(&fillOptional))). - WithWidth(width).WithTheme(theme).WithShowHelp(false) + Description(strings.Join(names, ", ")))).IO(in, cmd.ErrOrStderr()) if err := form.Run(); err != nil { return nil, err } if fillOptional { for _, index := range optionalFlags { - answer, err := runPromptField(fields[index], theme, width) + answer, err := runPromptField(cmd, in, fields[index]) if err != nil { return nil, err } @@ -767,119 +715,65 @@ func optionalArgDescription(summary string) string { return summary + " · optional, leave empty to skip" } -func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer, error) { +func runPromptField(cmd *cobra.Command, in *forms.Reader, field PromptField) (PromptAnswer, error) { if field.Repeatable && field.target == promptTargetFlag { - return runRepeatableFlagPrompt(field, theme, width) + return runRepeatableFlagPrompt(cmd, in, field) + } + + ask := func(f *forms.Field) error { + return forms.New(forms.NewPage("", f.Title(field.Name).Description(field.Summary))).IO(in, cmd.ErrOrStderr()).Run() } - var answer PromptAnswer - var formField huh.Field switch field.Kind { case "bool": if field.Required { value := false - formField = huh.NewConfirm().Title(field.Name).Description(field.Summary).Value(&value) - answer.Set = true - answer.Values = []string{strconv.FormatBool(value)} - form := huh.NewForm(huh.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) - if err := form.Run(); err != nil { + if err := ask(forms.NewConfirm(&value)); err != nil { return PromptAnswer{}, err } - answer.Values[0] = strconv.FormatBool(value) - return answer, nil + return PromptAnswer{Set: true, Values: []string{strconv.FormatBool(value)}}, nil } value := "" - formField = huh.NewSelect[string]().Title(field.Name).Description(field.Summary).Options( - huh.NewOption("Yes", "true"), huh.NewOption("No", "false"), huh.NewOption("Skip (use default)", ""), - ).Value(&value) - form := huh.NewForm(huh.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) - if err := form.Run(); err != nil { + if err := ask(forms.NewSelect(&value, + forms.NewOption("Yes", "true"), forms.NewOption("No", "false"), forms.NewOption("Skip (use default)", ""), + )); err != nil { return PromptAnswer{}, err } return PromptAnswer{Set: value != "", Values: []string{value}}, nil case "enum": value := "" - options := make([]huh.Option[string], 0, len(field.Options)+1) + options := make([]forms.Option, 0, len(field.Options)+1) if !field.Required { - options = append(options, huh.NewOption[string]("Skip (use default)", "")) + options = append(options, forms.NewOption("Skip (use default)", "")) } for _, option := range field.Options { - options = append(options, huh.NewOption(option, option)) + options = append(options, forms.NewOption(option, option)) } - formField = huh.NewSelect[string]().Title(field.Name).Description(field.Summary).Options(options...).Value(&value) - form := huh.NewForm(huh.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) - if err := form.Run(); err != nil { + if err := ask(forms.NewSelect(&value, options...)); err != nil { return PromptAnswer{}, err } return PromptAnswer{Set: value != "", Values: []string{value}}, nil } value := "" - description := field.Summary - var validate func(string) error - if field.Required { - validate = func(value string) error { - if strings.TrimSpace(value) == "" { + var formField *forms.Field + if field.Kind == "json" { + formField = forms.NewText(&value) + field.Summary += " (JSON)" + } else { + formField = forms.NewInput(&value) + } + formField.Validate(func(value string) error { + if strings.TrimSpace(value) == "" { + if field.Required { return fmt.Errorf("required") } return nil } - } - switch field.Kind { - case "json": - formField = huh.NewText().Title(field.Name).Description(description + " (JSON)").Value(&value) - if validate != nil { - formField = formField.(*huh.Text).Validate(validate) - } - case "int64": - formField = huh.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { - if strings.TrimSpace(value) == "" { - if validate != nil { - return validate(value) - } - return nil - } - if _, err := strconv.ParseInt(value, 10, 64); err != nil { - return fmt.Errorf("must be an integer") - } - return nil - }) - case "float64": - formField = huh.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { - if strings.TrimSpace(value) == "" { - if validate != nil { - return validate(value) - } - return nil - } - if _, err := strconv.ParseFloat(value, 64); err != nil { - return fmt.Errorf("must be a number") - } - return nil - }) - case "duration": - formField = huh.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { - if strings.TrimSpace(value) == "" { - if validate != nil { - return validate(value) - } - return nil - } - if _, err := time.ParseDuration(value); err != nil { - return fmt.Errorf("must be a duration") - } - return nil - }) - default: - input := huh.NewInput().Title(field.Name).Description(description).Value(&value) - if validate != nil { - input = input.Validate(validate) - } - formField = input - } - form := huh.NewForm(huh.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) - if err := form.Run(); err != nil { + return checkPromptValue(field.Kind, value) + }) + if err := ask(formField); err != nil { return PromptAnswer{}, err } if strings.TrimSpace(value) == "" { @@ -888,7 +782,26 @@ func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer return PromptAnswer{Set: true, Values: []string{value}}, nil } -func runRepeatableFlagPrompt(field PromptField, theme huh.Theme, width int) (PromptAnswer, error) { +// checkPromptValue rejects a non-empty answer that does not parse as kind. +func checkPromptValue(kind, value string) error { + switch kind { + case "int64": + if _, err := strconv.ParseInt(value, 10, 64); err != nil { + return fmt.Errorf("must be an integer") + } + case "float64": + if _, err := strconv.ParseFloat(value, 64); err != nil { + return fmt.Errorf("must be a number") + } + case "duration": + if _, err := time.ParseDuration(value); err != nil { + return fmt.Errorf("must be a duration") + } + } + return nil +} + +func runRepeatableFlagPrompt(cmd *cobra.Command, in *forms.Reader, field PromptField) (PromptAnswer, error) { values := make([]string, 0, 1) for { value := "" @@ -898,7 +811,7 @@ func runRepeatableFlagPrompt(field PromptField, theme huh.Theme, width int) (Pro title = "Add another " + displayName(strings.TrimSuffix(field.Name, " (required)"), "", false) + "?" description = "Leave empty to finish." } - input := huh.NewInput().Title(title).Description(description).Value(&value) + input := forms.NewInput(&value).Title(title).Description(description) if field.Required && len(values) == 0 { input = input.Validate(func(value string) error { if strings.TrimSpace(value) == "" { @@ -907,8 +820,7 @@ func runRepeatableFlagPrompt(field PromptField, theme huh.Theme, width int) (Pro return nil }) } - form := huh.NewForm(huh.NewGroup(input)).WithWidth(width).WithTheme(theme).WithShowHelp(false) - if err := form.Run(); err != nil { + if err := forms.New(forms.NewPage("", input)).IO(in, cmd.ErrOrStderr()).Run(); err != nil { return PromptAnswer{}, err } if strings.TrimSpace(value) == "" {