package tui import ( "fmt" "strings" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) var ( focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) labelStyle = lipgloss.NewStyle().Width(32) blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) cursorStyle = focusedStyle ) // secretKey returns true for keys that likely contain sensitive values. func secretKey(key string) bool { upper := strings.ToUpper(key) for _, word := range []string{"KEY", "TOKEN", "PASSWORD", "SECRET", "PASS"} { if strings.Contains(upper, word) { return true } } return false } type formModel struct { title string keys []string inputs []textinput.Model cursor int done bool } func newFormModel(title string, keys []string) formModel { inputs := make([]textinput.Model, len(keys)) for i, key := range keys { t := textinput.New() t.Placeholder = "enter value" t.CursorStyle = cursorStyle if secretKey(key) { t.EchoMode = textinput.EchoPassword t.EchoCharacter = '•' } if i == 0 { t.Focus() t.PromptStyle = focusedStyle t.TextStyle = focusedStyle } inputs[i] = t } return formModel{title: title, keys: keys, inputs: inputs} } func (m formModel) Init() tea.Cmd { return textinput.Blink } func (m formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "esc": m.done = true m.keys = nil // signal cancelled return m, tea.Quit case "tab", "down": m.cursor = (m.cursor + 1) % len(m.inputs) return m, m.focusAt(m.cursor) case "shift+tab", "up": m.cursor = (m.cursor - 1 + len(m.inputs)) % len(m.inputs) return m, m.focusAt(m.cursor) case "enter": if m.cursor < len(m.inputs)-1 { m.cursor++ return m, m.focusAt(m.cursor) } // Last field — submit m.done = true return m, tea.Quit } } // Forward key events to the focused input var cmd tea.Cmd m.inputs[m.cursor], cmd = m.inputs[m.cursor].Update(msg) return m, cmd } func (m formModel) focusAt(idx int) tea.Cmd { var cmds []tea.Cmd for i := range m.inputs { if i == idx { m.inputs[i].PromptStyle = focusedStyle m.inputs[i].TextStyle = focusedStyle cmds = append(cmds, m.inputs[i].Focus()) } else { m.inputs[i].Blur() m.inputs[i].PromptStyle = blurredStyle m.inputs[i].TextStyle = blurredStyle } } return tea.Batch(cmds...) } func (m formModel) View() string { var b strings.Builder b.WriteString(titleStyle.Render(m.title) + "\n\n") for i, key := range m.keys { label := key if secretKey(key) { label += mutedStyle.Render(" (hidden)") } if i == m.cursor { b.WriteString(focusedStyle.Render("› ") + labelStyle.Render(label) + m.inputs[i].View() + "\n") } else { b.WriteString(blurredStyle.Render(" ") + labelStyle.Render(label) + m.inputs[i].View() + "\n") } } b.WriteString("\n" + mutedStyle.Render("tab/↑↓ navigate enter confirm esc cancel")) return b.String() } // PromptMissingEnv runs a TUI form for the given keys and returns a map of // key → value. Returns nil if the user cancelled. func PromptMissingEnv(stackName string, keys []string) (map[string]string, error) { if len(keys) == 0 { return map[string]string{}, nil } title := fmt.Sprintf("Missing env vars for %q", stackName) m := newFormModel(title, keys) p := tea.NewProgram(m) result, err := p.Run() if err != nil { return nil, err } final := result.(formModel) if final.keys == nil { return nil, nil // cancelled } values := make(map[string]string, len(keys)) for i, key := range keys { values[key] = final.inputs[i].Value() } return values, nil }