Files
Ed Zynda 71301a9035 feat: add interactive sudo password prompt for bash tool
Add core TUI support for handling sudo password prompts when executing
bash commands that require elevated privileges.

- Detect sudo commands and check if credentials are cached (sudo -n)
- Show modal password prompt with masked input (• characters) when needed
- Pipe password via stdin using sudo -S -p '' (no password in command string)
- Password flows through context callbacks, never stored in session history
- Add PasswordPromptHandler to agent and SDK event system
- Add password prompt overlay to TUI with 🔐 icon and hidden input
- Include tests for sudo command detection and rewriting

The password is never persisted to disk - it only exists in memory
during execution and is piped directly to sudo via stdin.
2026-04-15 17:33:03 +03:00

358 lines
10 KiB
Go

package ui
import (
"strings"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/ui/style"
)
// ---------------------------------------------------------------------------
// Prompt overlay — modal prompt rendered by AppModel when active
// ---------------------------------------------------------------------------
// promptMode indicates the type of interactive prompt being displayed.
type promptMode string
const (
promptModeSelect promptMode = "select"
promptModeConfirm promptMode = "confirm"
promptModeInput promptMode = "input"
promptModePassword promptMode = "password"
)
// promptResult carries the synchronous outcome of a prompt overlay update.
// A non-nil value means the prompt is done (completed or cancelled); nil
// means the overlay is still active.
type promptResult struct {
completed bool
cancelled bool
value string
index int
confirmed bool
}
// promptOverlay holds the state of an active interactive prompt. It is
// created when a PromptRequestEvent arrives and destroyed when the user
// completes or cancels. The AppModel owns the overlay and routes messages
// to it while in statePrompt.
type promptOverlay struct {
mode promptMode
message string
options []string // select: available choices
selected int // select: currently highlighted index
confirmed bool // confirm: current yes/no value
inputTA textarea.Model // input: text editor
width int
height int
}
// newSelectPrompt creates a prompt overlay for a selection list.
func newSelectPrompt(message string, options []string, width, height int) *promptOverlay {
return &promptOverlay{
mode: promptModeSelect,
message: message,
options: options,
width: width,
height: height,
}
}
// newConfirmPrompt creates a prompt overlay for a yes/no confirmation.
func newConfirmPrompt(message string, defaultValue bool, width, height int) *promptOverlay {
return &promptOverlay{
mode: promptModeConfirm,
message: message,
confirmed: defaultValue,
width: width,
height: height,
}
}
// newInputPrompt creates a prompt overlay for free-form text input.
func newInputPrompt(message, placeholder, defaultValue string, width, height int) *promptOverlay {
ta := textarea.New()
ta.Placeholder = placeholder
ta.ShowLineNumbers = false
ta.Prompt = ""
ta.CharLimit = 0
ta.SetWidth(width - 12) // account for border + padding
ta.SetHeight(1)
ta.Focus()
// Prevent Enter from inserting a newline — we intercept it for submit.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("ctrl+j", "shift+enter"),
)
if defaultValue != "" {
ta.SetValue(defaultValue)
ta.CursorEnd()
}
return &promptOverlay{
mode: promptModeInput,
message: message,
inputTA: ta,
width: width,
height: height,
}
}
// newPasswordPrompt creates a prompt overlay for password input (masked).
func newPasswordPrompt(message string, width, height int) *promptOverlay {
ta := textarea.New()
ta.Placeholder = "Enter password"
ta.ShowLineNumbers = false
ta.Prompt = ""
ta.CharLimit = 0
ta.SetWidth(width - 12) // account for border + padding
ta.SetHeight(1)
ta.Focus()
// Prevent Enter from inserting a newline — we intercept it for submit.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("ctrl+j", "shift+enter"),
)
// Enable password masking - the textarea will show dots instead of characters
// Note: textarea doesn't have built-in password masking, so we handle it in View()
return &promptOverlay{
mode: promptModePassword,
message: message,
inputTA: ta,
width: width,
height: height,
}
}
// Init returns the initial command for the prompt overlay. For input/password
// modes this starts the cursor blink animation.
func (p *promptOverlay) Init() tea.Cmd {
if p.mode == promptModeInput || p.mode == promptModePassword {
return textarea.Blink
}
return nil
}
// Update handles messages for the prompt overlay. It returns a non-nil
// *promptResult when the user completes or cancels the prompt. The returned
// tea.Cmd is for textarea blink ticks (input/password modes only).
func (p *promptOverlay) Update(msg tea.Msg) (*promptResult, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.width = msg.Width
p.height = msg.Height
if p.mode == promptModeInput || p.mode == promptModePassword {
p.inputTA.SetWidth(p.width - 12)
}
return nil, nil
case tea.KeyPressMsg:
switch p.mode {
case promptModeSelect:
return p.updateSelect(msg)
case promptModeConfirm:
return p.updateConfirm(msg)
case promptModeInput:
return p.updateInput(msg)
case promptModePassword:
return p.updatePassword(msg)
}
}
// Pass non-key messages to textarea for blink animation.
if p.mode == promptModeInput || p.mode == promptModePassword {
var cmd tea.Cmd
p.inputTA, cmd = p.inputTA.Update(msg)
return nil, cmd
}
return nil, nil
}
func (p *promptOverlay) updateSelect(msg tea.KeyPressMsg) (*promptResult, tea.Cmd) {
switch msg.String() {
case "up", "k":
if p.selected > 0 {
p.selected--
}
case "down", "j":
if p.selected < len(p.options)-1 {
p.selected++
}
case "home":
p.selected = 0
case "end":
if len(p.options) > 0 {
p.selected = len(p.options) - 1
}
case "enter":
value := ""
if p.selected < len(p.options) {
value = p.options[p.selected]
}
return &promptResult{completed: true, value: value, index: p.selected}, nil
case "esc":
return &promptResult{cancelled: true}, nil
}
return nil, nil
}
func (p *promptOverlay) updateConfirm(msg tea.KeyPressMsg) (*promptResult, tea.Cmd) {
switch msg.String() {
case "left", "h", "y", "Y":
p.confirmed = true
case "right", "l", "n", "N":
p.confirmed = false
case "tab":
p.confirmed = !p.confirmed
case "enter":
return &promptResult{completed: true, confirmed: p.confirmed}, nil
case "esc":
return &promptResult{cancelled: true}, nil
}
return nil, nil
}
func (p *promptOverlay) updateInput(msg tea.KeyPressMsg) (*promptResult, tea.Cmd) {
switch msg.String() {
case "enter":
return &promptResult{completed: true, value: p.inputTA.Value()}, nil
case "esc":
return &promptResult{cancelled: true}, nil
default:
// Delegate character input, backspace, cursor movement, etc.
var cmd tea.Cmd
p.inputTA, cmd = p.inputTA.Update(msg)
return nil, cmd
}
}
func (p *promptOverlay) updatePassword(msg tea.KeyPressMsg) (*promptResult, tea.Cmd) {
switch msg.String() {
case "enter":
return &promptResult{completed: true, value: p.inputTA.Value()}, nil
case "esc":
return &promptResult{cancelled: true}, nil
default:
// Delegate character input, backspace, cursor movement, etc.
var cmd tea.Cmd
p.inputTA, cmd = p.inputTA.Update(msg)
return nil, cmd
}
}
// Render returns the prompt as a styled string for inline composition in the
// AppModel layout. The prompt replaces the normal input area (below the
// separator and above the status bar) rather than taking over the full screen.
func (p *promptOverlay) Render() string {
theme := style.GetTheme()
var content string
switch p.mode {
case promptModeSelect:
content = p.viewSelect(theme)
case promptModeConfirm:
content = p.viewConfirm(theme)
case promptModeInput:
content = p.viewInput(theme)
case promptModePassword:
content = p.viewPassword(theme)
}
return renderContentBlock(content, p.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Accent),
WithPaddingTop(0),
WithPaddingBottom(0),
)
}
func (p *promptOverlay) viewSelect(theme style.Theme) string {
var lines []string
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
lines = append(lines, "")
for i, opt := range p.options {
if i == p.selected {
cursor := lipgloss.NewStyle().Foreground(theme.Accent).Bold(true).Render("> ")
label := lipgloss.NewStyle().Foreground(theme.Accent).Bold(true).Render(opt)
lines = append(lines, " "+cursor+label)
} else {
lines = append(lines, " "+lipgloss.NewStyle().Foreground(theme.Text).Render(opt))
}
}
lines = append(lines, "")
lines = append(lines, lipgloss.NewStyle().
Foreground(theme.Muted).
Render(" up/down navigate Enter select Esc cancel"))
return strings.Join(lines, "\n")
}
func (p *promptOverlay) viewConfirm(theme style.Theme) string {
var lines []string
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
lines = append(lines, "")
yesStyle := lipgloss.NewStyle().Foreground(theme.Text)
noStyle := lipgloss.NewStyle().Foreground(theme.Text)
if p.confirmed {
yesStyle = yesStyle.Bold(true).Foreground(theme.Accent)
} else {
noStyle = noStyle.Bold(true).Foreground(theme.Accent)
}
yes := yesStyle.Render("[Yes]")
no := noStyle.Render("[No]")
lines = append(lines, " "+yes+" "+no)
lines = append(lines, "")
lines = append(lines, lipgloss.NewStyle().
Foreground(theme.Muted).
Render(" left/right switch y/n Enter confirm Esc cancel"))
return strings.Join(lines, "\n")
}
func (p *promptOverlay) viewInput(theme style.Theme) string {
var lines []string
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
lines = append(lines, "")
lines = append(lines, p.inputTA.View())
lines = append(lines, "")
lines = append(lines, lipgloss.NewStyle().
Foreground(theme.Muted).
Render(" Enter submit Esc cancel"))
return strings.Join(lines, "\n")
}
func (p *promptOverlay) viewPassword(theme style.Theme) string {
var lines []string
// Add 🔐 icon to message for password prompt
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render("🔐 "+p.message))
lines = append(lines, "")
// Mask the password input with dots
passwordValue := p.inputTA.Value()
masked := strings.Repeat("•", len([]rune(passwordValue)))
// Render the masked password in a style that looks like input
maskedStyle := lipgloss.NewStyle().Foreground(theme.Text)
cursor := lipgloss.NewStyle().Foreground(theme.Accent).Render("█")
lines = append(lines, maskedStyle.Render(masked)+cursor)
lines = append(lines, "")
lines = append(lines, lipgloss.NewStyle().
Foreground(theme.Muted).
Render(" Enter submit Esc cancel (input is hidden)"))
return strings.Join(lines, "\n")
}