mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
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:
+47
@@ -464,6 +464,53 @@ func runNormalMode(ctx context.Context) error {
|
||||
kitInstance.RemoveExtensionWidget(id)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
|
||||
a.mu.Lock()
|
||||
|
||||
@@ -137,3 +137,44 @@ type ExtensionPrintEvent struct {
|
||||
// Subtitle is optional muted text below the content for Level="block".
|
||||
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
|
||||
}
|
||||
|
||||
@@ -82,6 +82,52 @@ type Context struct {
|
||||
// RemoveWidget removes a previously placed widget by its ID. No-op if
|
||||
// the ID does not exist.
|
||||
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.
|
||||
@@ -265,6 +311,64 @@ type WidgetConfig struct {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -34,6 +34,14 @@ func Symbols() interp.Exports {
|
||||
"WidgetAbove": reflect.ValueOf(WidgetAbove),
|
||||
"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
|
||||
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
|
||||
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
|
||||
|
||||
+197
-16
@@ -26,6 +26,10 @@ const (
|
||||
|
||||
// stateTreeSelector means the /tree viewer is active.
|
||||
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
|
||||
@@ -59,6 +63,11 @@ type AppController interface {
|
||||
// GetTreeSession returns the tree session manager, or nil if tree sessions
|
||||
// are not enabled. Used by slash commands like /tree, /fork, /session.
|
||||
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
|
||||
@@ -231,6 +240,19 @@ type AppModel struct {
|
||||
// getWidgets returns extension widgets for a given placement. May be nil.
|
||||
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 int
|
||||
height int
|
||||
@@ -430,6 +452,11 @@ func tildeHome(path string) string {
|
||||
func (m *AppModel) Update(msg tea.Msg) (tea.Model, 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) {
|
||||
|
||||
// ── Tree selector events ─────────────────────────────────────────────────
|
||||
@@ -495,6 +522,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
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.
|
||||
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.
|
||||
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:
|
||||
// Extension output — route through styled renderers when a level is set.
|
||||
switch msg.Level {
|
||||
@@ -764,7 +841,15 @@ func (m *AppModel) View() tea.View {
|
||||
|
||||
streamView := m.renderStream()
|
||||
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()
|
||||
|
||||
// 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-
|
||||
// registered slash command, executes it, and returns a tea.Cmd that renders
|
||||
// the output. Returns nil if no extension command matches.
|
||||
// registered slash command and returns a tea.Cmd that runs it. Returns nil
|
||||
// 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
|
||||
// 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"
|
||||
name, args, _ := strings.Cut(text, " ")
|
||||
cmd := FindExtensionCommand(name, m.extensionCommands)
|
||||
if cmd == nil {
|
||||
ecmd := FindExtensionCommand(name, m.extensionCommands)
|
||||
if ecmd == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
output, err := cmd.Execute(args)
|
||||
if err != nil {
|
||||
return m.printSystemMessage(fmt.Sprintf("Command %s error: %v", cmd.Name, err))
|
||||
}
|
||||
if output != "" {
|
||||
return m.printSystemMessage(output)
|
||||
}
|
||||
return nil
|
||||
// Run the command in a dedicated goroutine — NOT as a tea.Cmd. Extension
|
||||
// commands may block on interactive prompts (ctx.PromptSelect etc.) which
|
||||
// wait for the TUI to respond via a channel. A blocking tea.Cmd can stall
|
||||
// BubbleTea's internal Cmd scheduler, causing intermittent freezes.
|
||||
// The goroutine delivers its result via SendEvent (prog.Send) instead.
|
||||
cmdName := ecmd.Name
|
||||
cmdExec := ecmd.Execute
|
||||
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.
|
||||
@@ -1306,10 +1405,14 @@ func (m *AppModel) distributeHeight() {
|
||||
const linesPerQueuedMsg = 5
|
||||
queuedLines := len(m.queuedMessages) * linesPerQueuedMsg
|
||||
|
||||
// Measure the actual rendered input height so we don't rely on a
|
||||
// fragile constant that drifts when styling changes.
|
||||
// Measure the actual rendered input (or prompt overlay) height so we
|
||||
// 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)
|
||||
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 != "" {
|
||||
inputLines = lipgloss.Height(rendered)
|
||||
}
|
||||
@@ -1455,3 +1558,81 @@ func cancelTimerCmd() tea.Cmd {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ func (s *stubAppController) GetTreeSession() *session.TreeManager {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAppController) SendEvent(_ tea.Msg) {
|
||||
// no-op in tests
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Stub child components
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user