diff --git a/cli/azd/CHANGELOG.md b/cli/azd/CHANGELOG.md index 12e527fa89c..8ad6c39765a 100644 --- a/cli/azd/CHANGELOG.md +++ b/cli/azd/CHANGELOG.md @@ -8,6 +8,8 @@ ### Bugs Fixed +- [[#8649]](https://github.com/Azure/azure-dev/pull/8649) Fix interactive prompts (for example the `azd init` environment-name prompt) rendering twice on Windows terminals by rendering prompts with azd's own UX components instead of the archived survey library. + ### Other Changes ## 1.25.6 (2026-06-12) diff --git a/cli/azd/pkg/input/console.go b/cli/azd/pkg/input/console.go index 69e3738e670..6164135a019 100644 --- a/cli/azd/pkg/input/console.go +++ b/cli/azd/pkg/input/console.go @@ -719,6 +719,10 @@ func (c *AskerConsole) Prompt(ctx context.Context, options ConsoleOptions) (stri return response, nil } + if c.isTerminal && !c.noPrompt { + return c.promptUx(ctx, options) + } + err := c.doInteraction(func(c *AskerConsole) error { return c.asker(promptFromOptions(options), &response) }) @@ -781,6 +785,10 @@ func (c *AskerConsole) Select(ctx context.Context, options ConsoleOptions) (int, return res, nil } + if c.isTerminal && !c.noPrompt { + return c.selectUx(ctx, options) + } + surveyOptions := make([]string, len(options.Options)) surveyDefault := options.DefaultValue surveyDefaultAsString, surveyDefaultIsString := surveyDefault.(string) @@ -854,6 +862,10 @@ func (c *AskerConsole) MultiSelect(ctx context.Context, options ConsoleOptions) return response, nil } + if c.isTerminal && !c.noPrompt { + return c.multiSelectUx(ctx, options) + } + surveyOptions := make([]string, len(options.Options)) surveyDefault := options.DefaultValue surveyDefaultAsArr, surveyDefaultIsArr := surveyDefault.([]string) @@ -931,6 +943,10 @@ func (c *AskerConsole) Confirm(ctx context.Context, options ConsoleOptions) (boo } } + if c.isTerminal && !c.noPrompt { + return c.confirmUx(ctx, options) + } + var defaultValue bool if value, ok := options.DefaultValue.(bool); ok { defaultValue = value diff --git a/cli/azd/pkg/input/console_ux.go b/cli/azd/pkg/input/console_ux.go new file mode 100644 index 00000000000..917e4dc9d60 --- /dev/null +++ b/cli/azd/pkg/input/console_ux.go @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package input + +import ( + "context" + "errors" + "fmt" + "io" + "slices" + + surveyterm "github.com/AlecAivazis/survey/v2/terminal" + "github.com/azure/azure-dev/cli/azd/pkg/output" + uxlib "github.com/azure/azure-dev/cli/azd/pkg/ux" +) + +// The interactive prompts below are rendered with the in-repo ux package instead +// of AlecAivazis/survey. survey has a Windows-specific double-render bug (the +// prompt and its answer are redrawn above the next prompt) that was never fixed +// upstream and the library is archived. See Azure/azure-dev#435. +// +// Only the terminal path is routed through ux. The non-terminal / no-prompt paths +// continue to use the lightweight asker implementation so machine-friendly input +// behavior (used by tests, CI, and piped stdin) is unchanged. + +// mapUxCancel converts a ux cancellation error into the survey interrupt error +// that the rest of azd already recognizes, preserving existing error handling. +func mapUxCancel(err error) error { + if err != nil && errors.Is(err, uxlib.ErrCancelled) { + return surveyterm.InterruptErr + } + + return err +} + +// optionLabel returns the display label for an option, appending the gray detail +// text (when present) the same way the previous survey-based rendering did. +func optionLabel(options ConsoleOptions, index int) string { + option := options.Options[index] + if index < len(options.OptionDetails) && options.OptionDetails[index] != "" { + return fmt.Sprintf("%s %s", option, output.WithGrayFormat("(%s)", options.OptionDetails[index])) + } + + return option +} + +// selectChoices builds the ux select choices and the initially selected index +// from the console options. The selected index defaults to 0 and is set to the +// position of the string default value when present. +func selectChoices(options ConsoleOptions) ([]*uxlib.SelectChoice, int) { + choices := make([]*uxlib.SelectChoice, len(options.Options)) + for i, option := range options.Options { + choices[i] = &uxlib.SelectChoice{ + Value: option, + Label: optionLabel(options, i), + } + } + + selectedIndex := 0 + if value, ok := options.DefaultValue.(string); ok { + if idx := slices.Index(options.Options, value); idx >= 0 { + selectedIndex = idx + } + } + + return choices, selectedIndex +} + +// multiSelectChoices builds the ux multi-select choices from the console options, +// marking choices that appear in the []string default value as pre-selected. +func multiSelectChoices(options ConsoleOptions) []*uxlib.MultiSelectChoice { + defaultValues, _ := options.DefaultValue.([]string) + + choices := make([]*uxlib.MultiSelectChoice, len(options.Options)) + for i, option := range options.Options { + choices[i] = &uxlib.MultiSelectChoice{ + Value: option, + Label: optionLabel(options, i), + Selected: slices.Contains(defaultValues, option), + } + } + + return choices +} + +// multiSelectValues maps the ux multi-select result back to the option values. +func multiSelectValues(selected []*uxlib.MultiSelectChoice) []string { + response := make([]string, len(selected)) + for i, choice := range selected { + response[i] = choice.Value + } + + return response +} + +// newPromptOptions builds the ux prompt options from the console options. +func newPromptOptions(writer io.Writer, options ConsoleOptions) *uxlib.PromptOptions { + var defaultValue string + if value, ok := options.DefaultValue.(string); ok { + defaultValue = value + } + + return &uxlib.PromptOptions{ + Writer: writer, + Message: options.Message, + HelpMessage: options.Help, + DefaultValue: defaultValue, + Secret: options.IsPassword, + } +} + +// newSelectOptions builds the ux select options from the console options. +func newSelectOptions(writer io.Writer, options ConsoleOptions) *uxlib.SelectOptions { + choices, selectedIndex := selectChoices(options) + + return &uxlib.SelectOptions{ + Writer: writer, + Message: options.Message, + HelpMessage: options.Help, + Choices: choices, + SelectedIndex: new(selectedIndex), + } +} + +// newConfirmOptions builds the ux confirm options from the console options. +func newConfirmOptions(writer io.Writer, options ConsoleOptions) *uxlib.ConfirmOptions { + defaultValue := false + if value, ok := options.DefaultValue.(bool); ok { + defaultValue = value + } + + return &uxlib.ConfirmOptions{ + Writer: writer, + Message: options.Message, + HelpMessage: options.Help, + DefaultValue: new(defaultValue), + } +} + +// newMultiSelectOptions builds the ux multi-select options from the console options. +// Empty selection is allowed to preserve the behavior of the survey-based +// implementation that callers depend on. +func newMultiSelectOptions(writer io.Writer, options ConsoleOptions) *uxlib.MultiSelectOptions { + return &uxlib.MultiSelectOptions{ + Writer: writer, + Message: options.Message, + HelpMessage: options.Help, + Choices: multiSelectChoices(options), + AllowEmptySelection: new(true), + } +} + +// selectResult converts the ux select result into the (index, error) pair the +// console contract expects. A nil result indicates an interrupted prompt. +func selectResult(result *int, err error) (int, error) { + if err != nil { + return -1, err + } + if result == nil { + return -1, surveyterm.InterruptErr + } + + return *result, nil +} + +// confirmResult converts the ux confirm result into the (bool, error) pair the +// console contract expects. A nil result indicates an interrupted prompt. +func confirmResult(result *bool, err error) (bool, error) { + if err != nil { + return false, err + } + if result == nil { + return false, surveyterm.InterruptErr + } + + return *result, nil +} + +// multiSelectResult converts the ux multi-select result into the ([]string, error) +// pair the console contract expects. +func multiSelectResult(selected []*uxlib.MultiSelectChoice, err error) ([]string, error) { + if err != nil { + return nil, err + } + + return multiSelectValues(selected), nil +} + +// uxComponent is the subset of the ux prompt components used by runComponent. +type uxComponent[T any] interface { + Ask(ctx context.Context) (T, error) +} + +// runComponent executes an interactive ux component, pausing any active spinner, +// mapping cancellation to the survey interrupt error, and recording the +// post-interaction console state on success. +func runComponent[T any](ctx context.Context, c *AskerConsole, component uxComponent[T]) (T, error) { + var result T + err := c.doInteraction(func(c *AskerConsole) error { + var askErr error + result, askErr = component.Ask(ctx) + return askErr + }) + if err != nil { + var zero T + return zero, mapUxCancel(err) + } + + c.updateLastBytes(afterIoSentinel) + return result, nil +} + +func (c *AskerConsole) promptUx(ctx context.Context, options ConsoleOptions) (string, error) { + return runComponent(ctx, c, uxlib.NewPrompt(newPromptOptions(c.writer, options))) +} + +func (c *AskerConsole) selectUx(ctx context.Context, options ConsoleOptions) (int, error) { + return selectResult(runComponent(ctx, c, uxlib.NewSelect(newSelectOptions(c.writer, options)))) +} + +func (c *AskerConsole) confirmUx(ctx context.Context, options ConsoleOptions) (bool, error) { + return confirmResult(runComponent(ctx, c, uxlib.NewConfirm(newConfirmOptions(c.writer, options)))) +} + +func (c *AskerConsole) multiSelectUx(ctx context.Context, options ConsoleOptions) ([]string, error) { + return multiSelectResult(runComponent(ctx, c, uxlib.NewMultiSelect(newMultiSelectOptions(c.writer, options)))) +} diff --git a/cli/azd/pkg/input/console_ux_test.go b/cli/azd/pkg/input/console_ux_test.go new file mode 100644 index 00000000000..48b9f3b7b3a --- /dev/null +++ b/cli/azd/pkg/input/console_ux_test.go @@ -0,0 +1,368 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package input + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "strings" + "testing" + + surveyterm "github.com/AlecAivazis/survey/v2/terminal" + "github.com/azure/azure-dev/cli/azd/pkg/output" + uxlib "github.com/azure/azure-dev/cli/azd/pkg/ux" + "github.com/stretchr/testify/require" +) + +// newTestAskerConsole builds a non-terminal AskerConsole suitable for exercising +// the ux helper plumbing (spinner pausing, result bookkeeping) without a TTY. +func newTestAskerConsole(t *testing.T) *AskerConsole { + t.Helper() + + formatter, err := output.NewFormatter(string(output.NoneFormat)) + require.NoError(t, err) + + buf := &bytes.Buffer{} + c := NewConsole( + false, + false, + Writers{Output: buf}, + ConsoleHandles{Stderr: io.Discard, Stdin: strings.NewReader(""), Stdout: buf}, + formatter, + nil, + ) + + asker, ok := c.(*AskerConsole) + require.True(t, ok) + return asker +} + +// fakeUxComponent is a test double for the ux prompt components used by runComponent. +type fakeUxComponent[T any] struct { + result T + err error +} + +func (f fakeUxComponent[T]) Ask(ctx context.Context) (T, error) { + return f.result, f.err +} + +func TestMapUxCancel(t *testing.T) { + t.Run("nil passes through", func(t *testing.T) { + require.NoError(t, mapUxCancel(nil)) + }) + + t.Run("ErrCancelled maps to InterruptErr", func(t *testing.T) { + require.ErrorIs(t, mapUxCancel(uxlib.ErrCancelled), surveyterm.InterruptErr) + }) + + t.Run("wrapped ErrCancelled maps to InterruptErr", func(t *testing.T) { + wrapped := fmt.Errorf("reading input: %w", uxlib.ErrCancelled) + require.ErrorIs(t, mapUxCancel(wrapped), surveyterm.InterruptErr) + }) + + t.Run("other errors pass through unchanged", func(t *testing.T) { + other := errors.New("boom") + require.Equal(t, other, mapUxCancel(other)) + }) +} + +func TestOptionLabel(t *testing.T) { + t.Run("no details returns plain option", func(t *testing.T) { + options := ConsoleOptions{Options: []string{"alpha", "beta"}} + require.Equal(t, "alpha", optionLabel(options, 0)) + require.Equal(t, "beta", optionLabel(options, 1)) + }) + + t.Run("detail is appended", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha"}, + OptionDetails: []string{"the first"}, + } + require.Contains(t, optionLabel(options, 0), "alpha") + require.Contains(t, optionLabel(options, 0), "the first") + }) + + t.Run("empty detail is ignored", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha", "beta"}, + OptionDetails: []string{"", "second"}, + } + require.Equal(t, "alpha", optionLabel(options, 0)) + require.Contains(t, optionLabel(options, 1), "second") + }) + + t.Run("details shorter than options does not panic", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha", "beta"}, + OptionDetails: []string{"only first"}, + } + require.Contains(t, optionLabel(options, 0), "only first") + require.Equal(t, "beta", optionLabel(options, 1)) + }) +} + +func TestSelectChoices(t *testing.T) { + t.Run("maps options to choices with default index 0", func(t *testing.T) { + options := ConsoleOptions{Options: []string{"alpha", "beta", "gamma"}} + choices, selectedIndex := selectChoices(options) + + require.Len(t, choices, 3) + require.Equal(t, "alpha", choices[0].Value) + require.Equal(t, "alpha", choices[0].Label) + require.Equal(t, "gamma", choices[2].Value) + require.Equal(t, 0, selectedIndex) + }) + + t.Run("default value selects matching index", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha", "beta", "gamma"}, + DefaultValue: "gamma", + } + _, selectedIndex := selectChoices(options) + require.Equal(t, 2, selectedIndex) + }) + + t.Run("default value not in options falls back to 0", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha", "beta"}, + DefaultValue: "missing", + } + _, selectedIndex := selectChoices(options) + require.Equal(t, 0, selectedIndex) + }) + + t.Run("non-string default falls back to 0", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha", "beta"}, + DefaultValue: 42, + } + _, selectedIndex := selectChoices(options) + require.Equal(t, 0, selectedIndex) + }) + + t.Run("labels include option details", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha"}, + OptionDetails: []string{"detail"}, + } + choices, _ := selectChoices(options) + require.Equal(t, "alpha", choices[0].Value) + require.Contains(t, choices[0].Label, "detail") + }) +} + +func TestMultiSelectChoices(t *testing.T) { + t.Run("maps options with none selected by default", func(t *testing.T) { + options := ConsoleOptions{Options: []string{"alpha", "beta"}} + choices := multiSelectChoices(options) + + require.Len(t, choices, 2) + require.Equal(t, "alpha", choices[0].Value) + require.False(t, choices[0].Selected) + require.False(t, choices[1].Selected) + }) + + t.Run("default values mark choices selected", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha", "beta", "gamma"}, + DefaultValue: []string{"alpha", "gamma"}, + } + choices := multiSelectChoices(options) + + require.True(t, choices[0].Selected) + require.False(t, choices[1].Selected) + require.True(t, choices[2].Selected) + }) + + t.Run("non-slice default selects nothing", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha", "beta"}, + DefaultValue: "alpha", + } + choices := multiSelectChoices(options) + require.False(t, choices[0].Selected) + require.False(t, choices[1].Selected) + }) + + t.Run("labels include option details", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha"}, + OptionDetails: []string{"detail"}, + } + choices := multiSelectChoices(options) + require.Contains(t, choices[0].Label, "detail") + }) +} + +func TestMultiSelectValues(t *testing.T) { + t.Run("maps choices back to values", func(t *testing.T) { + selected := []*uxlib.MultiSelectChoice{ + {Value: "alpha"}, + {Value: "gamma"}, + } + require.Equal(t, []string{"alpha", "gamma"}, multiSelectValues(selected)) + }) + + t.Run("empty selection returns empty slice", func(t *testing.T) { + require.Empty(t, multiSelectValues(nil)) + require.Empty(t, multiSelectValues([]*uxlib.MultiSelectChoice{})) + }) +} + +func TestNewPromptOptions(t *testing.T) { + buf := &bytes.Buffer{} + opts := newPromptOptions(buf, ConsoleOptions{ + Message: "message", + Help: "help", + DefaultValue: "default", + IsPassword: true, + }) + + require.Equal(t, buf, opts.Writer) + require.Equal(t, "message", opts.Message) + require.Equal(t, "help", opts.HelpMessage) + require.Equal(t, "default", opts.DefaultValue) + require.True(t, opts.Secret) + + t.Run("non-string default is ignored", func(t *testing.T) { + opts := newPromptOptions(buf, ConsoleOptions{DefaultValue: 42}) + require.Empty(t, opts.DefaultValue) + }) +} + +func TestNewSelectOptions(t *testing.T) { + buf := &bytes.Buffer{} + opts := newSelectOptions(buf, ConsoleOptions{ + Message: "message", + Options: []string{"alpha", "beta", "gamma"}, + DefaultValue: "gamma", + }) + + require.Equal(t, buf, opts.Writer) + require.Equal(t, "message", opts.Message) + require.Len(t, opts.Choices, 3) + require.NotNil(t, opts.SelectedIndex) + require.Equal(t, 2, *opts.SelectedIndex) +} + +func TestNewConfirmOptions(t *testing.T) { + buf := &bytes.Buffer{} + + t.Run("uses bool default", func(t *testing.T) { + opts := newConfirmOptions(buf, ConsoleOptions{Message: "message", DefaultValue: true}) + require.Equal(t, buf, opts.Writer) + require.Equal(t, "message", opts.Message) + require.NotNil(t, opts.DefaultValue) + require.True(t, *opts.DefaultValue) + }) + + t.Run("defaults to false", func(t *testing.T) { + opts := newConfirmOptions(buf, ConsoleOptions{}) + require.NotNil(t, opts.DefaultValue) + require.False(t, *opts.DefaultValue) + }) +} + +func TestNewMultiSelectOptions(t *testing.T) { + buf := &bytes.Buffer{} + opts := newMultiSelectOptions(buf, ConsoleOptions{ + Message: "message", + Options: []string{"alpha", "beta"}, + DefaultValue: []string{"beta"}, + }) + + require.Equal(t, buf, opts.Writer) + require.Equal(t, "message", opts.Message) + require.Len(t, opts.Choices, 2) + require.False(t, opts.Choices[0].Selected) + require.True(t, opts.Choices[1].Selected) + require.NotNil(t, opts.AllowEmptySelection) + require.True(t, *opts.AllowEmptySelection) +} + +func TestSelectResult(t *testing.T) { + t.Run("error passes through", func(t *testing.T) { + boom := errors.New("boom") + _, err := selectResult(nil, boom) + require.ErrorIs(t, err, boom) + }) + + t.Run("nil result is an interrupt", func(t *testing.T) { + got, err := selectResult(nil, nil) + require.ErrorIs(t, err, surveyterm.InterruptErr) + require.Equal(t, -1, got) + }) + + t.Run("returns dereferenced index", func(t *testing.T) { + got, err := selectResult(new(2), nil) + require.NoError(t, err) + require.Equal(t, 2, got) + }) +} + +func TestConfirmResult(t *testing.T) { + t.Run("error passes through", func(t *testing.T) { + boom := errors.New("boom") + _, err := confirmResult(nil, boom) + require.ErrorIs(t, err, boom) + }) + + t.Run("nil result is an interrupt", func(t *testing.T) { + got, err := confirmResult(nil, nil) + require.ErrorIs(t, err, surveyterm.InterruptErr) + require.False(t, got) + }) + + t.Run("returns dereferenced value", func(t *testing.T) { + got, err := confirmResult(new(true), nil) + require.NoError(t, err) + require.True(t, got) + }) +} + +func TestMultiSelectResult(t *testing.T) { + t.Run("error passes through", func(t *testing.T) { + boom := errors.New("boom") + _, err := multiSelectResult(nil, boom) + require.ErrorIs(t, err, boom) + }) + + t.Run("maps selected values", func(t *testing.T) { + got, err := multiSelectResult([]*uxlib.MultiSelectChoice{{Value: "alpha"}, {Value: "beta"}}, nil) + require.NoError(t, err) + require.Equal(t, []string{"alpha", "beta"}, got) + }) +} + +func TestRunComponent(t *testing.T) { + c := newTestAskerConsole(t) + + t.Run("returns result on success", func(t *testing.T) { + got, err := runComponent[string](t.Context(), c, fakeUxComponent[string]{result: "hello"}) + require.NoError(t, err) + require.Equal(t, "hello", got) + }) + + t.Run("maps cancellation to interrupt", func(t *testing.T) { + _, err := runComponent[string](t.Context(), c, fakeUxComponent[string]{err: uxlib.ErrCancelled}) + require.ErrorIs(t, err, surveyterm.InterruptErr) + }) + + t.Run("passes through other errors", func(t *testing.T) { + boom := errors.New("boom") + _, err := runComponent[string](t.Context(), c, fakeUxComponent[string]{err: boom}) + require.ErrorIs(t, err, boom) + }) + + t.Run("supports pointer result types", func(t *testing.T) { + got, err := runComponent[*int](t.Context(), c, fakeUxComponent[*int]{result: new(7)}) + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, 7, *got) + }) +}