feat(vars): add interactive prompting for required variables (#2579)

This commit is contained in:
Valentin Maerten
2026-01-22 21:20:45 +01:00
committed by GitHub
parent c84cfa41f7
commit 6dedcafd7d
19 changed files with 689 additions and 17 deletions

View File

@@ -83,6 +83,7 @@ var (
Timeout time.Duration
CacheExpiryDuration time.Duration
RemoteCacheDir string
Interactive bool
)
func init() {
@@ -132,6 +133,7 @@ func init() {
pflag.BoolVarP(&Silent, "silent", "s", false, "Disables echoing.")
pflag.BoolVar(&DisableFuzzy, "disable-fuzzy", getConfig(config, func() *bool { return config.DisableFuzzy }, false), "Disables fuzzy matching for task names.")
pflag.BoolVarP(&AssumeYes, "yes", "y", false, "Assume \"yes\" as answer to all prompts.")
pflag.BoolVar(&Interactive, "interactive", getConfig(config, func() *bool { return config.Interactive }, false), "Prompt for missing required variables.")
pflag.BoolVarP(&Parallel, "parallel", "p", false, "Executes tasks provided on command line in parallel.")
pflag.BoolVarP(&Dry, "dry", "n", false, "Compiles and prints tasks in the order that they would be run, without executing them.")
pflag.BoolVar(&Summary, "summary", false, "Show summary about a task.")
@@ -281,6 +283,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
task.WithSilent(Silent),
task.WithDisableFuzzy(DisableFuzzy),
task.WithAssumeYes(AssumeYes),
task.WithInteractive(Interactive),
task.WithDry(Dry || Status),
task.WithSummary(Summary),
task.WithParallel(Parallel),

211
internal/input/input.go Normal file
View File

@@ -0,0 +1,211 @@
package input
import (
"fmt"
"io"
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/go-task/task/v3/errors"
)
var ErrCancelled = errors.New("prompt cancelled")
var (
promptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) // cyan bold
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) // cyan bold
selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true) // green bold
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray
)
// Prompter handles interactive variable prompting
type Prompter struct {
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
}
// Text prompts the user for a text value
func (p *Prompter) Text(varName string) (string, error) {
m := newTextModel(varName)
prog := tea.NewProgram(m,
tea.WithInput(p.Stdin),
tea.WithOutput(p.Stderr),
)
result, err := prog.Run()
if err != nil {
return "", err
}
model := result.(textModel)
if model.cancelled {
return "", ErrCancelled
}
return model.value, nil
}
// Select prompts the user to select from a list of options
func (p *Prompter) Select(varName string, options []string) (string, error) {
if len(options) == 0 {
return "", errors.New("no options provided")
}
m := newSelectModel(varName, options)
prog := tea.NewProgram(m,
tea.WithInput(p.Stdin),
tea.WithOutput(p.Stderr),
)
result, err := prog.Run()
if err != nil {
return "", err
}
model := result.(selectModel)
if model.cancelled {
return "", ErrCancelled
}
return model.options[model.cursor], nil
}
// Prompt prompts for a variable value, using Select if enum is provided, Text otherwise
func (p *Prompter) Prompt(varName string, enum []string) (string, error) {
if len(enum) > 0 {
return p.Select(varName, enum)
}
return p.Text(varName)
}
// textModel is the Bubble Tea model for text input
type textModel struct {
varName string
textInput textinput.Model
value string
cancelled bool
done bool
}
func newTextModel(varName string) textModel {
ti := textinput.New()
ti.Placeholder = ""
ti.CharLimit = 256
ti.SetWidth(40)
ti.Focus()
return textModel{
varName: varName,
textInput: ti,
}
}
func (m textModel) Init() tea.Cmd {
return tea.Batch(m.textInput.Focus(), textinput.Blink)
}
func (m textModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.Keystroke() {
case "ctrl+c", "escape":
m.cancelled = true
m.done = true
return m, tea.Quit
case "enter":
m.value = m.textInput.Value()
m.done = true
return m, tea.Quit
}
}
var cmd tea.Cmd
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
func (m textModel) View() tea.View {
if m.done {
return tea.NewView("")
}
prompt := promptStyle.Render(fmt.Sprintf("? Enter value for %s: ", m.varName))
return tea.NewView(prompt + m.textInput.View() + "\n")
}
// selectModel is the Bubble Tea model for selection
type selectModel struct {
varName string
options []string
cursor int
cancelled bool
done bool
}
func newSelectModel(varName string, options []string) selectModel {
return selectModel{
varName: varName,
options: options,
cursor: 0,
}
}
func (m selectModel) Init() tea.Cmd {
return nil
}
func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.Keystroke() {
case "ctrl+c", "escape":
m.cancelled = true
m.done = true
return m, tea.Quit
case "up", "shift+tab", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "tab", "j":
if m.cursor < len(m.options)-1 {
m.cursor++
}
case "enter":
m.done = true
return m, tea.Quit
}
}
return m, nil
}
func (m selectModel) View() tea.View {
if m.done {
return tea.NewView("")
}
var b strings.Builder
b.WriteString(promptStyle.Render(fmt.Sprintf("? Select value for %s:", m.varName)))
b.WriteString("\n")
for i, opt := range m.options {
if i == m.cursor {
b.WriteString(cursorStyle.Render(" "))
b.WriteString(selectedStyle.Render(opt))
} else {
b.WriteString(" " + opt)
}
b.WriteString("\n")
}
b.WriteString(dimStyle.Render(" (↑/↓ to move, enter to select, esc to cancel)"))
return tea.NewView(b.String())
}