mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
71301a9035
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.
358 lines
10 KiB
Go
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")
|
|
}
|