feat: add interactive prompt system for extensions (select, confirm, input)

Extensions can now show modal prompts to the user via ctx.PromptSelect,
ctx.PromptConfirm, and ctx.PromptInput. Prompts render inline below the
separator (replacing the input area) and use channel-based sync so the
extension blocks until the user responds. Extension slash commands run in
dedicated goroutines to avoid stalling BubbleTea's Cmd scheduler.
This commit is contained in:
Ed Zynda
2026-02-28 13:54:00 +03:00
parent 3009b5530b
commit 584b215803
9 changed files with 826 additions and 16 deletions
+47
View File
@@ -464,6 +464,53 @@ func runNormalMode(ctx context.Context) error {
kitInstance.RemoveExtensionWidget(id) kitInstance.RemoveExtensionWidget(id)
appInstance.NotifyWidgetUpdate() appInstance.NotifyWidgetUpdate()
}, },
PromptSelect: func(config extensions.PromptSelectConfig) extensions.PromptSelectResult {
ch := make(chan app.PromptResponse, 1)
appInstance.SendPromptRequest(app.PromptRequestEvent{
PromptType: "select",
Message: config.Message,
Options: config.Options,
ResponseCh: ch,
})
resp := <-ch
if resp.Cancelled {
return extensions.PromptSelectResult{Cancelled: true}
}
return extensions.PromptSelectResult{Value: resp.Value, Index: resp.Index}
},
PromptConfirm: func(config extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
ch := make(chan app.PromptResponse, 1)
def := "false"
if config.DefaultValue {
def = "true"
}
appInstance.SendPromptRequest(app.PromptRequestEvent{
PromptType: "confirm",
Message: config.Message,
Default: def,
ResponseCh: ch,
})
resp := <-ch
if resp.Cancelled {
return extensions.PromptConfirmResult{Cancelled: true}
}
return extensions.PromptConfirmResult{Value: resp.Confirmed}
},
PromptInput: func(config extensions.PromptInputConfig) extensions.PromptInputResult {
ch := make(chan app.PromptResponse, 1)
appInstance.SendPromptRequest(app.PromptRequestEvent{
PromptType: "input",
Message: config.Message,
Placeholder: config.Placeholder,
Default: config.Default,
ResponseCh: ch,
})
resp := <-ch
if resp.Cancelled {
return extensions.PromptInputResult{Cancelled: true}
}
return extensions.PromptInputResult{Value: resp.Value}
},
}) })
kitInstance.EmitSessionStart() kitInstance.EmitSessionStart()
} }
+113
View File
@@ -0,0 +1,113 @@
//go:build ignore
package main
import (
"fmt"
"strings"
"kit/ext"
)
// Init demonstrates the interactive prompt system. It registers three slash
// commands that show each prompt type (select, confirm, input), plus a
// combined workflow command that chains prompts together.
func Init(api ext.API) {
// /demo-select — shows a selection list.
api.RegisterCommand(ext.CommandDef{
Name: "demo-select",
Description: "Demo: pick from a list",
Execute: func(args string, ctx ext.Context) (string, error) {
result := ctx.PromptSelect(ext.PromptSelectConfig{
Message: "Choose your deployment target:",
Options: []string{"local", "staging", "production"},
})
if result.Cancelled {
return "Selection cancelled.", nil
}
return fmt.Sprintf("Selected: %s (index %d)", result.Value, result.Index), nil
},
})
// /demo-confirm — shows a yes/no confirmation.
api.RegisterCommand(ext.CommandDef{
Name: "demo-confirm",
Description: "Demo: yes/no confirmation",
Execute: func(args string, ctx ext.Context) (string, error) {
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Are you sure you want to deploy?",
DefaultValue: false,
})
if result.Cancelled {
return "Confirmation cancelled.", nil
}
if result.Value {
return "Confirmed! Deploying...", nil
}
return "Declined. Deployment aborted.", nil
},
})
// /demo-input — shows a text input.
api.RegisterCommand(ext.CommandDef{
Name: "demo-input",
Description: "Demo: free-form text input",
Execute: func(args string, ctx ext.Context) (string, error) {
result := ctx.PromptInput(ext.PromptInputConfig{
Message: "Enter the release tag:",
Placeholder: "v1.0.0",
})
if result.Cancelled {
return "Input cancelled.", nil
}
return fmt.Sprintf("Release tag: %s", result.Value), nil
},
})
// /demo-workflow — chains multiple prompts into a workflow.
api.RegisterCommand(ext.CommandDef{
Name: "demo-workflow",
Description: "Demo: chained prompt workflow",
Execute: func(args string, ctx ext.Context) (string, error) {
// Step 1: select environment
env := ctx.PromptSelect(ext.PromptSelectConfig{
Message: "Step 1/3: Select environment:",
Options: []string{"development", "staging", "production"},
})
if env.Cancelled {
return "Workflow cancelled at step 1.", nil
}
// Step 2: enter version tag
tag := ctx.PromptInput(ext.PromptInputConfig{
Message: "Step 2/3: Enter the version tag:",
Placeholder: "v1.0.0",
})
if tag.Cancelled {
return "Workflow cancelled at step 2.", nil
}
// Step 3: confirm
confirm := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: fmt.Sprintf(
"Step 3/3: Deploy %s to %s?",
tag.Value, env.Value),
DefaultValue: false,
})
if confirm.Cancelled {
return "Workflow cancelled at step 3.", nil
}
if !confirm.Value {
return "Deployment declined.", nil
}
var summary strings.Builder
summary.WriteString("Deployment summary:\n")
fmt.Fprintf(&summary, " Environment: %s\n", env.Value)
fmt.Fprintf(&summary, " Version: %s\n", tag.Value)
summary.WriteString(" Status: initiated")
return summary.String(), nil
},
})
}
+26
View File
@@ -503,6 +503,32 @@ func (a *App) NotifyWidgetUpdate() {
} }
} }
// SendEvent sends a tea.Msg to the registered program. Safe to call from
// any goroutine. No-op when no program is registered.
//
// Satisfies ui.AppController.
func (a *App) SendEvent(msg tea.Msg) {
a.sendEvent(msg)
}
// SendPromptRequest sends a PromptRequestEvent to the TUI so the user can
// respond interactively. In non-interactive mode (no program registered) it
// immediately responds with a cancelled result via the channel, ensuring the
// calling extension goroutine never blocks indefinitely.
func (a *App) SendPromptRequest(evt PromptRequestEvent) {
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(evt)
return
}
// Non-interactive fallback: immediately cancel.
if evt.ResponseCh != nil {
evt.ResponseCh <- PromptResponse{Cancelled: true}
}
}
// PrintBlockFromExtension outputs a custom styled block from an extension. // PrintBlockFromExtension outputs a custom styled block from an extension.
func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) { func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
a.mu.Lock() a.mu.Lock()
+41
View File
@@ -137,3 +137,44 @@ type ExtensionPrintEvent struct {
// Subtitle is optional muted text below the content for Level="block". // Subtitle is optional muted text below the content for Level="block".
Subtitle string Subtitle string
} }
// PromptResponse carries the user's answer to an interactive prompt. The TUI
// sends exactly one PromptResponse through the channel embedded in
// PromptRequestEvent when the user completes or cancels the prompt.
type PromptResponse struct {
// Value is the response text — the selected option (select), or the
// entered text (input). Unused for confirm prompts.
Value string
// Index is the zero-based index of the selected option (select only).
Index int
// Confirmed is the boolean answer for confirm prompts.
Confirmed bool
// Cancelled is true if the user dismissed the prompt (ESC) or the
// prompt could not be shown (e.g. app shutting down).
Cancelled bool
}
// PromptRequestEvent is sent when an extension requests an interactive
// prompt from the user (select, confirm, or text input). The TUI enters a
// modal prompt state, renders the prompt, and sends a single PromptResponse
// through ResponseCh when the user completes or cancels.
//
// The extension goroutine blocks on the read side of ResponseCh until the
// TUI sends a response. The channel must have buffer size >= 1.
type PromptRequestEvent struct {
// PromptType is "select", "confirm", or "input".
PromptType string
// Message is the question displayed to the user.
Message string
// Options lists the choices for select prompts.
Options []string
// Default is the pre-filled value: "true"/"false" for confirm prompts,
// or the initial text for input prompts.
Default string
// Placeholder is the ghost text for input prompts.
Placeholder string
// ResponseCh receives the user's answer. The TUI must send exactly one
// value. The channel must be buffered (cap >= 1) so sending never
// blocks inside Update().
ResponseCh chan<- PromptResponse
}
+104
View File
@@ -82,6 +82,52 @@ type Context struct {
// RemoveWidget removes a previously placed widget by its ID. No-op if // RemoveWidget removes a previously placed widget by its ID. No-op if
// the ID does not exist. // the ID does not exist.
RemoveWidget func(id string) RemoveWidget func(id string)
// PromptSelect shows a selection list to the user and blocks until
// they pick an option or cancel (ESC). Returns a cancelled result in
// non-interactive mode. Safe to call from event handlers and slash
// command handlers.
//
// Example:
//
// result := ctx.PromptSelect(ext.PromptSelectConfig{
// Message: "Choose a deployment target:",
// Options: []string{"staging", "production", "local"},
// })
// if !result.Cancelled {
// fmt.Println("Selected:", result.Value)
// }
PromptSelect func(PromptSelectConfig) PromptSelectResult
// PromptConfirm shows a yes/no confirmation to the user and blocks
// until they respond or cancel. Returns a cancelled result in
// non-interactive mode.
//
// Example:
//
// result := ctx.PromptConfirm(ext.PromptConfirmConfig{
// Message: "Deploy to production?",
// DefaultValue: false,
// })
// if !result.Cancelled && result.Value {
// // proceed with deployment
// }
PromptConfirm func(PromptConfirmConfig) PromptConfirmResult
// PromptInput shows a text input field to the user and blocks until
// they submit text or cancel. Returns a cancelled result in
// non-interactive mode.
//
// Example:
//
// result := ctx.PromptInput(ext.PromptInputConfig{
// Message: "Enter the release tag:",
// Placeholder: "v1.0.0",
// })
// if !result.Cancelled {
// fmt.Println("Tag:", result.Value)
// }
PromptInput func(PromptInputConfig) PromptInputResult
} }
// PrintBlockOpts configures a custom styled block for PrintBlock. // PrintBlockOpts configures a custom styled block for PrintBlock.
@@ -265,6 +311,64 @@ type WidgetConfig struct {
Priority int Priority int
} }
// ---------------------------------------------------------------------------
// Interactive prompt types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// PromptSelectConfig configures a selection prompt that presents the user
// with a list of options to choose from.
type PromptSelectConfig struct {
// Message is the question or instruction displayed to the user.
Message string
// Options is the list of choices the user can select from.
Options []string
}
// PromptSelectResult is the response from a selection prompt.
type PromptSelectResult struct {
// Value is the text of the selected option.
Value string
// Index is the zero-based index of the selected option.
Index int
// Cancelled is true if the user dismissed the prompt (ESC) or
// the prompt was unavailable (non-interactive mode).
Cancelled bool
}
// PromptConfirmConfig configures a yes/no confirmation prompt.
type PromptConfirmConfig struct {
// Message is the question displayed to the user.
Message string
// DefaultValue is the pre-selected answer (true = Yes).
DefaultValue bool
}
// PromptConfirmResult is the response from a confirmation prompt.
type PromptConfirmResult struct {
// Value is true for "Yes", false for "No".
Value bool
// Cancelled is true if the user dismissed the prompt.
Cancelled bool
}
// PromptInputConfig configures a free-form text input prompt.
type PromptInputConfig struct {
// Message is the question displayed to the user.
Message string
// Placeholder is ghost text shown when the input is empty.
Placeholder string
// Default is the pre-filled value in the input field.
Default string
}
// PromptInputResult is the response from a text input prompt.
type PromptInputResult struct {
// Value is the text the user entered.
Value string
// Cancelled is true if the user dismissed the prompt.
Cancelled bool
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ToolDef / CommandDef // ToolDef / CommandDef
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+8
View File
@@ -34,6 +34,14 @@ func Symbols() interp.Exports {
"WidgetAbove": reflect.ValueOf(WidgetAbove), "WidgetAbove": reflect.ValueOf(WidgetAbove),
"WidgetBelow": reflect.ValueOf(WidgetBelow), "WidgetBelow": reflect.ValueOf(WidgetBelow),
// Prompt types
"PromptSelectConfig": reflect.ValueOf((*PromptSelectConfig)(nil)),
"PromptSelectResult": reflect.ValueOf((*PromptSelectResult)(nil)),
"PromptConfirmConfig": reflect.ValueOf((*PromptConfirmConfig)(nil)),
"PromptConfirmResult": reflect.ValueOf((*PromptConfirmResult)(nil)),
"PromptInputConfig": reflect.ValueOf((*PromptInputConfig)(nil)),
"PromptInputResult": reflect.ValueOf((*PromptInputResult)(nil)),
// Event structs // Event structs
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)), "ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)), "ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
+197 -16
View File
@@ -26,6 +26,10 @@ const (
// stateTreeSelector means the /tree viewer is active. // stateTreeSelector means the /tree viewer is active.
stateTreeSelector stateTreeSelector
// statePrompt means an extension-triggered interactive prompt is active.
// The prompt overlay takes full focus until the user completes or cancels.
statePrompt
) )
// AppController is the interface the parent TUI model uses to interact with the // AppController is the interface the parent TUI model uses to interact with the
@@ -59,6 +63,11 @@ type AppController interface {
// GetTreeSession returns the tree session manager, or nil if tree sessions // GetTreeSession returns the tree session manager, or nil if tree sessions
// are not enabled. Used by slash commands like /tree, /fork, /session. // are not enabled. Used by slash commands like /tree, /fork, /session.
GetTreeSession() *session.TreeManager GetTreeSession() *session.TreeManager
// SendEvent sends a tea.Msg to the program asynchronously. Safe to call
// from any goroutine. Used by extension command goroutines to deliver
// results back to the TUI without going through tea.Cmd (which can stall
// when the goroutine blocks on interactive prompts).
SendEvent(tea.Msg)
} }
// SkillItem holds display metadata about a loaded skill for the startup // SkillItem holds display metadata about a loaded skill for the startup
@@ -231,6 +240,19 @@ type AppModel struct {
// getWidgets returns extension widgets for a given placement. May be nil. // getWidgets returns extension widgets for a given placement. May be nil.
getWidgets func(placement string) []WidgetData getWidgets func(placement string) []WidgetData
// prompt holds the state of an active interactive prompt overlay. Nil
// when no prompt is active. Managed by updatePromptState().
prompt *promptOverlay
// promptResponseCh is the write-side of the channel used to deliver the
// user's prompt answer back to the blocking extension goroutine. Set
// alongside prompt; nil when no prompt is active.
promptResponseCh chan<- app.PromptResponse
// prePromptState remembers the state before the prompt overlay took
// over, so the model can return to it when the prompt completes.
prePromptState appState
// width and height track the terminal dimensions. // width and height track the terminal dimensions.
width int width int
height int height int
@@ -430,6 +452,11 @@ func tildeHome(path string) string {
func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
// Prompt overlay takes precedence when active — it is fully modal.
if m.state == statePrompt && m.prompt != nil {
return m.updatePromptState(msg)
}
switch msg := msg.(type) { switch msg := msg.(type) {
// ── Tree selector events ───────────────────────────────────────────────── // ── Tree selector events ─────────────────────────────────────────────────
@@ -495,6 +522,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg: case tea.KeyPressMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c": case "ctrl+c":
// Cancel any active prompt before quitting.
if m.promptResponseCh != nil {
m.promptResponseCh <- app.PromptResponse{Cancelled: true}
m.promptResponseCh = nil
m.prompt = nil
}
// Graceful quit: app.Close() is deferred in cmd/root.go. // Graceful quit: app.Close() is deferred in cmd/root.go.
return m, tea.Quit return m, tea.Quit
} }
@@ -722,6 +755,50 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// latest widget state on the next render. // latest widget state on the next render.
m.distributeHeight() m.distributeHeight()
case app.PromptRequestEvent:
// Extension wants to show an interactive prompt. Enter prompt state.
// If already in prompt state (concurrent prompt from another
// extension), immediately cancel the new request.
if m.state == statePrompt {
if msg.ResponseCh != nil {
msg.ResponseCh <- app.PromptResponse{Cancelled: true}
}
return m, tea.Batch(cmds...)
}
m.prePromptState = m.state
m.state = statePrompt
m.promptResponseCh = msg.ResponseCh
switch msg.PromptType {
case "select":
m.prompt = newSelectPrompt(msg.Message, msg.Options, m.width, m.height)
case "confirm":
defaultVal := msg.Default == "true"
m.prompt = newConfirmPrompt(msg.Message, defaultVal, m.width, m.height)
case "input":
m.prompt = newInputPrompt(msg.Message, msg.Placeholder, msg.Default, m.width, m.height)
default:
// Unknown prompt type — cancel immediately.
if msg.ResponseCh != nil {
msg.ResponseCh <- app.PromptResponse{Cancelled: true}
}
m.state = m.prePromptState
m.promptResponseCh = nil
return m, tea.Batch(cmds...)
}
if m.prompt != nil {
cmds = append(cmds, m.prompt.Init())
}
case extensionCmdResultMsg:
// Async extension slash command completed. Render output/error.
if msg.err != nil {
cmds = append(cmds, m.printSystemMessage(
fmt.Sprintf("Command %s error: %v", msg.name, msg.err)))
} else if msg.output != "" {
cmds = append(cmds, m.printSystemMessage(msg.output))
}
case app.ExtensionPrintEvent: case app.ExtensionPrintEvent:
// Extension output — route through styled renderers when a level is set. // Extension output — route through styled renderers when a level is set.
switch msg.Level { switch msg.Level {
@@ -764,7 +841,15 @@ func (m *AppModel) View() tea.View {
streamView := m.renderStream() streamView := m.renderStream()
separator := m.renderSeparator() separator := m.renderSeparator()
inputView := m.renderInput()
// When a prompt is active, it replaces the input area for consistency
// (appears below the separator, in the same position as the input).
var inputView string
if m.state == statePrompt && m.prompt != nil {
inputView = m.prompt.Render()
} else {
inputView = m.renderInput()
}
statusBar := m.renderStatusBar() statusBar := m.renderStatusBar()
// Only include the stream region when it has content. When idle the // Only include the stream region when it has content. When idle the
@@ -1074,8 +1159,13 @@ func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) tea.Cmd {
} }
// handleExtensionCommand checks if the submitted text matches an extension- // handleExtensionCommand checks if the submitted text matches an extension-
// registered slash command, executes it, and returns a tea.Cmd that renders // registered slash command and returns a tea.Cmd that runs it. Returns nil
// the output. Returns nil if no extension command matches. // if no extension command matches.
//
// Extension commands execute asynchronously (via tea.Cmd goroutine) so they
// can safely call blocking operations like ctx.PromptSelect() without
// deadlocking the TUI's Update loop. The result is delivered back as an
// extensionCmdResultMsg.
// //
// Extension commands support arguments: "/sub list files" is split into // Extension commands support arguments: "/sub list files" is split into
// command name "/sub" and args "list files". // command name "/sub" and args "list files".
@@ -1091,19 +1181,28 @@ func (m *AppModel) handleExtensionCommand(text string) tea.Cmd {
// Split: "/sub list files" → name="/sub", args="list files" // Split: "/sub list files" → name="/sub", args="list files"
name, args, _ := strings.Cut(text, " ") name, args, _ := strings.Cut(text, " ")
cmd := FindExtensionCommand(name, m.extensionCommands) ecmd := FindExtensionCommand(name, m.extensionCommands)
if cmd == nil { if ecmd == nil {
return nil return nil
} }
output, err := cmd.Execute(args) // Run the command in a dedicated goroutine — NOT as a tea.Cmd. Extension
if err != nil { // commands may block on interactive prompts (ctx.PromptSelect etc.) which
return m.printSystemMessage(fmt.Sprintf("Command %s error: %v", cmd.Name, err)) // wait for the TUI to respond via a channel. A blocking tea.Cmd can stall
} // BubbleTea's internal Cmd scheduler, causing intermittent freezes.
if output != "" { // The goroutine delivers its result via SendEvent (prog.Send) instead.
return m.printSystemMessage(output) cmdName := ecmd.Name
} cmdExec := ecmd.Execute
return nil cmdArgs := args
ctrl := m.appCtrl
go func() {
output, err := cmdExec(cmdArgs)
ctrl.SendEvent(extensionCmdResultMsg{name: cmdName, output: output, err: err})
}()
// Return a non-nil Cmd so the caller knows the command was handled
// and doesn't fall through to the regular prompt path. The Cmd itself
// is a no-op.
return func() tea.Msg { return nil }
} }
// printHelpMessage renders the help text listing all available slash commands. // printHelpMessage renders the help text listing all available slash commands.
@@ -1306,10 +1405,14 @@ func (m *AppModel) distributeHeight() {
const linesPerQueuedMsg = 5 const linesPerQueuedMsg = 5
queuedLines := len(m.queuedMessages) * linesPerQueuedMsg queuedLines := len(m.queuedMessages) * linesPerQueuedMsg
// Measure the actual rendered input height so we don't rely on a // Measure the actual rendered input (or prompt overlay) height so we
// fragile constant that drifts when styling changes. // don't rely on a fragile constant that drifts when styling changes.
inputLines := 9 // fallback: title(1)+margin(1)+nl(1)+textarea(3)+nl(1)+margin(1)+help(1) inputLines := 9 // fallback: title(1)+margin(1)+nl(1)+textarea(3)+nl(1)+margin(1)+help(1)
if m.input != nil { if m.state == statePrompt && m.prompt != nil {
if rendered := m.prompt.Render(); rendered != "" {
inputLines = lipgloss.Height(rendered)
}
} else if m.input != nil {
if rendered := m.input.View().Content; rendered != "" { if rendered := m.input.View().Content; rendered != "" {
inputLines = lipgloss.Height(rendered) inputLines = lipgloss.Height(rendered)
} }
@@ -1455,3 +1558,81 @@ func cancelTimerCmd() tea.Cmd {
return cancelTimerExpiredMsg{} return cancelTimerExpiredMsg{}
}) })
} }
// --------------------------------------------------------------------------
// Interactive prompt support
// --------------------------------------------------------------------------
// extensionCmdResultMsg carries the result of an asynchronously executed
// extension slash command. Extension commands run async (via tea.Cmd) so they
// can safely call blocking operations like ctx.PromptSelect().
type extensionCmdResultMsg struct {
name string
output string
err error
}
// updatePromptState handles all messages while the prompt overlay is active.
// It routes keys to the prompt overlay, detects completion/cancellation, and
// restores the previous state when done.
func (m *AppModel) updatePromptState(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
// Cancel prompt and quit the application.
m.resolvePrompt(app.PromptResponse{Cancelled: true})
return m, tea.Quit
}
result, cmd := m.prompt.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
if result != nil {
if result.cancelled {
m.resolvePrompt(app.PromptResponse{Cancelled: true})
} else {
m.resolvePrompt(app.PromptResponse{
Value: result.value,
Index: result.index,
Confirmed: result.confirmed,
})
}
}
case app.PromptRequestEvent:
// Already handling a prompt — reject concurrent requests.
if msg.ResponseCh != nil {
msg.ResponseCh <- app.PromptResponse{Cancelled: true}
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
_, cmd := m.prompt.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
default:
// Pass blink ticks and other messages to the prompt overlay.
_, cmd := m.prompt.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
}
// resolvePrompt sends the response through the channel, clears prompt state,
// and restores the previous app state.
func (m *AppModel) resolvePrompt(resp app.PromptResponse) {
if m.promptResponseCh != nil {
m.promptResponseCh <- resp
m.promptResponseCh = nil
}
m.prompt = nil
m.state = m.prePromptState
}
+4
View File
@@ -53,6 +53,10 @@ func (s *stubAppController) GetTreeSession() *session.TreeManager {
return nil return nil
} }
func (s *stubAppController) SendEvent(_ tea.Msg) {
// no-op in tests
}
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Stub child components // Stub child components
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
+286
View File
@@ -0,0 +1,286 @@
package ui
import (
"strings"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// ---------------------------------------------------------------------------
// 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"
)
// 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 = 1000
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", "alt+enter"),
)
if defaultValue != "" {
ta.SetValue(defaultValue)
ta.CursorEnd()
}
return &promptOverlay{
mode: promptModeInput,
message: message,
inputTA: ta,
width: width,
height: height,
}
}
// Init returns the initial command for the prompt overlay. For input mode
// this starts the cursor blink animation.
func (p *promptOverlay) Init() tea.Cmd {
if p.mode == promptModeInput {
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 mode 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.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)
}
}
// Pass non-key messages to textarea for blink animation.
if p.mode == promptModeInput {
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
}
}
// 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 := 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)
}
return renderContentBlock(content, p.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Accent),
WithPaddingTop(0),
WithPaddingBottom(0),
)
}
func (p *promptOverlay) viewSelect(theme 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 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 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")
}