diff --git a/internal/agent/agenttest/coordinator.go b/internal/agent/agenttest/coordinator.go
index fdacb7e129..618e8dfec5 100644
--- a/internal/agent/agenttest/coordinator.go
+++ b/internal/agent/agenttest/coordinator.go
@@ -64,17 +64,10 @@ func NewCoordinator(
coderCfg.AllowedTools = nil
cfg.Config().Agents[config.AgentCoder] = coderCfg
- return agent.NewCoordinator(
- ctx,
- cfg,
- sessions,
- messages,
- permission.NewPermissionService(workingDir, true, nil),
- nil,
- nil,
- nil,
- nil,
- nil,
- nil,
- )
+ return agent.NewCoordinator(ctx, agent.CoordinatorOptions{
+ Config: cfg,
+ Sessions: sessions,
+ Messages: messages,
+ Permissions: permission.NewPermissionService(workingDir, true, nil),
+ })
}
diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go
index 86ca09e3bf..a6330747dd 100644
--- a/internal/agent/coordinator.go
+++ b/internal/agent/coordinator.go
@@ -33,6 +33,7 @@ import (
"github.com/charmbracelet/crush/internal/oauth/copilot"
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/pubsub"
+ "github.com/charmbracelet/crush/internal/question"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/skills"
"golang.org/x/sync/errgroup"
@@ -52,6 +53,8 @@ import (
// Coordinator errors.
var (
errCoderAgentNotConfigured = errors.New("coder agent not configured")
+ errPlanAgentNotConfigured = errors.New("plan agent not configured")
+ errMainAgentNotFound = errors.New("main agent not found")
errModelProviderNotConfigured = errors.New("model provider not configured")
errLargeModelNotSelected = errors.New("large model not selected")
errSmallModelNotSelected = errors.New("small model not selected")
@@ -78,8 +81,7 @@ var opencodeMessagesModels = map[string]bool{
}
type Coordinator interface {
- // INFO: (kujtim) this is not used yet we will use this when we have multiple agents
- // SetMainAgent(string)
+ SetMainAgent(agentName string) error
Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
// RunAccepted runs a call that was already accepted via
// BeginAccepted on the fire-and-forget dispatch path. The handle is
@@ -107,14 +109,17 @@ type coordinator struct {
sessions session.Service
messages message.Service
permissions permission.Service
+ questions question.Service
history history.Service
filetracker filetracker.Service
lspManager *lsp.Manager
notify pubsub.Publisher[notify.Notification]
runComplete pubsub.Publisher[notify.RunComplete]
+ interactive bool
- currentAgent SessionAgent
- agents map[string]SessionAgent
+ currentAgent SessionAgent
+ currentAgentName string
+ agents map[string]SessionAgent
// Skills discovery results (session-start snapshot).
allSkills []*skills.Skill // Pre-filter: all discovered after dedup.
@@ -124,68 +129,103 @@ type coordinator struct {
readyWg errgroup.Group
}
-func NewCoordinator(
- ctx context.Context,
- cfg *config.ConfigStore,
- sessions session.Service,
- messages message.Service,
- permissions permission.Service,
- history history.Service,
- filetracker filetracker.Service,
- lspManager *lsp.Manager,
- notify pubsub.Publisher[notify.Notification],
- runComplete pubsub.Publisher[notify.RunComplete],
- skillsMgr *skills.Manager,
-) (Coordinator, error) {
+// CoordinatorOptions holds the dependencies for NewCoordinator. Using a
+// struct keeps the constructor self-documenting and avoids a long
+// positional parameter list.
+type CoordinatorOptions struct {
+ Config *config.ConfigStore
+ Sessions session.Service
+ Messages message.Service
+ Permissions permission.Service
+ Questions question.Service
+ History history.Service
+ FileTracker filetracker.Service
+ LSPManager *lsp.Manager
+ Notify pubsub.Publisher[notify.Notification]
+ RunComplete pubsub.Publisher[notify.RunComplete]
+ Skills *skills.Manager
+ Interactive bool
+}
+
+func NewCoordinator(ctx context.Context, opts CoordinatorOptions) (Coordinator, error) {
// Skills are pre-discovered by the caller (see app.New /
// backend.CreateWorkspace) and passed in via the manager. If no
// manager was provided (legacy callers), fall back to an in-line
// discovery so the coordinator still works.
var allSkills, activeSkills []*skills.Skill
- if skillsMgr != nil {
- allSkills = skillsMgr.AllSkills()
- activeSkills = skillsMgr.ActiveSkills()
+ if opts.Skills != nil {
+ allSkills = opts.Skills.AllSkills()
+ activeSkills = opts.Skills.ActiveSkills()
} else {
- allSkills, activeSkills = discoverSkills(cfg)
+ allSkills, activeSkills = discoverSkills(opts.Config)
}
skillTracker := skills.NewTracker(activeSkills)
c := &coordinator{
- cfg: cfg,
- sessions: sessions,
- messages: messages,
- permissions: permissions,
- history: history,
- filetracker: filetracker,
- lspManager: lspManager,
- notify: notify,
- runComplete: runComplete,
+ cfg: opts.Config,
+ sessions: opts.Sessions,
+ messages: opts.Messages,
+ permissions: opts.Permissions,
+ questions: opts.Questions,
+ history: opts.History,
+ filetracker: opts.FileTracker,
+ lspManager: opts.LSPManager,
+ notify: opts.Notify,
+ runComplete: opts.RunComplete,
agents: make(map[string]SessionAgent),
allSkills: allSkills,
activeSkills: activeSkills,
skillTracker: skillTracker,
+ interactive: opts.Interactive,
}
- agentCfg, ok := cfg.Config().Agents[config.AgentCoder]
+ agentCfg, ok := opts.Config.Config().Agents[config.AgentCoder]
if !ok {
return nil, errCoderAgentNotConfigured
}
- // TODO: make this dynamic when we support multiple agents
- prompt, err := coderPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
+ coderPrompt, err := coderPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
if err != nil {
return nil, err
}
- agent, err := c.buildAgent(ctx, prompt, agentCfg, false)
+ agent, err := c.buildAgent(ctx, coderPrompt, agentCfg, false)
if err != nil {
return nil, err
}
- c.currentAgent = agent
c.agents[config.AgentCoder] = agent
+
+ planCfg, ok := c.cfg.Config().Agents[config.AgentPlan]
+ if !ok {
+ return nil, errPlanAgentNotConfigured
+ }
+
+ planSystemPrompt, err := planPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
+ if err != nil {
+ return nil, err
+ }
+
+ planAgent, err := c.buildAgent(ctx, planSystemPrompt, planCfg, false)
+ if err != nil {
+ return nil, err
+ }
+ c.agents[config.AgentPlan] = planAgent
+
+ c.currentAgent = agent
+ c.currentAgentName = config.AgentCoder
return c, nil
}
+func (c *coordinator) SetMainAgent(agentName string) error {
+ agent, ok := c.agents[agentName]
+ if !ok {
+ return fmt.Errorf("%w: %s", errMainAgentNotFound, agentName)
+ }
+ c.currentAgent = agent
+ c.currentAgentName = agentName
+ return nil
+}
+
// Run implements Coordinator.
func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
return c.run(ctx, nil, sessionID, prompt, attachments...)
@@ -623,6 +663,11 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent, isSubA
tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
)
+ // Question tool is interactive-only and not available to sub-agents.
+ if !isSubAgent && c.interactive {
+ allTools = append(allTools, tools.NewQuestionTool(c.questions))
+ }
+
// Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
if len(c.cfg.Config().LSP) > 0 || c.cfg.Config().Options.AutoLSP == nil || *c.cfg.Config().Options.AutoLSP {
allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
@@ -1086,9 +1131,9 @@ func (c *coordinator) UpdateModels(ctx context.Context) error {
}
c.currentAgent.SetModels(large, small)
- agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder]
+ agentCfg, ok := c.cfg.Config().Agents[c.currentAgentName]
if !ok {
- return errCoderAgentNotConfigured
+ return fmt.Errorf("%w: %s", errMainAgentNotFound, c.currentAgentName)
}
tools, err := c.buildTools(ctx, agentCfg, false)
diff --git a/internal/agent/coordinator_test.go b/internal/agent/coordinator_test.go
index c522ef5de1..eb34c888d1 100644
--- a/internal/agent/coordinator_test.go
+++ b/internal/agent/coordinator_test.go
@@ -426,3 +426,35 @@ func TestGetProviderOptionsReasoningEffort(t *testing.T) {
})
}
}
+
+func TestCoordinatorSetMainAgent(t *testing.T) {
+ t.Run("switches current agent", func(t *testing.T) {
+ coder := &mockSessionAgent{}
+ plan := &mockSessionAgent{}
+ coord := &coordinator{
+ currentAgent: coder,
+ currentAgentName: config.AgentCoder,
+ agents: map[string]SessionAgent{
+ config.AgentCoder: coder,
+ config.AgentPlan: plan,
+ },
+ }
+
+ err := coord.SetMainAgent(config.AgentPlan)
+ require.NoError(t, err)
+ assert.Equal(t, config.AgentPlan, coord.currentAgentName)
+ assert.Same(t, plan, coord.currentAgent)
+ })
+
+ t.Run("returns error for unknown agent", func(t *testing.T) {
+ coord := &coordinator{
+ agents: map[string]SessionAgent{
+ config.AgentCoder: &mockSessionAgent{},
+ },
+ }
+
+ err := coord.SetMainAgent("unknown")
+ require.Error(t, err)
+ assert.ErrorIs(t, err, errMainAgentNotFound)
+ })
+}
diff --git a/internal/agent/prompts.go b/internal/agent/prompts.go
index 448fe0425c..afa23a2bac 100644
--- a/internal/agent/prompts.go
+++ b/internal/agent/prompts.go
@@ -14,6 +14,9 @@ var coderPromptTmpl []byte
//go:embed templates/task.md.tpl
var taskPromptTmpl []byte
+//go:embed templates/plan.md.tpl
+var planPromptTmpl []byte
+
//go:embed templates/initialize.md.tpl
var initializePromptTmpl []byte
@@ -33,6 +36,14 @@ func taskPrompt(opts ...prompt.Option) (*prompt.Prompt, error) {
return systemPrompt, nil
}
+func planPrompt(opts ...prompt.Option) (*prompt.Prompt, error) {
+ systemPrompt, err := prompt.NewPrompt("plan", string(planPromptTmpl), opts...)
+ if err != nil {
+ return nil, err
+ }
+ return systemPrompt, nil
+}
+
func InitializePrompt(cfg *config.ConfigStore) (string, error) {
systemPrompt, err := prompt.NewPrompt("initialize", string(initializePromptTmpl))
if err != nil {
diff --git a/internal/agent/templates/plan.md.tpl b/internal/agent/templates/plan.md.tpl
new file mode 100644
index 0000000000..28e6248d44
--- /dev/null
+++ b/internal/agent/templates/plan.md.tpl
@@ -0,0 +1,54 @@
+You are Crush in plan mode — an expert architect, senior UX designer, and planning specialist with meticulous attention to detail.
+
+Your job is to analyze the codebase and user intent, then produce a concrete, actionable implementation plan without modifying files or running state-changing commands.
+
+
+You do NOT have access to file-modification tools. The following tools are physically absent from your environment — calling them will fail immediately:
+- edit, multiedit, write (file editing/creation)
+- bash (shell execution)
+
+Your available tools are: agent, glob, grep, ls, question, sourcegraph, view.
+
+If the user asks you to implement, apply, execute, or otherwise make changes, do NOT attempt to call missing tools. Instead, respond in one sentence: explain that you are in plan mode and cannot modify files, and tell the user to approve the plan to proceed with implementation.
+
+
+
+These rules override everything else. Follow them strictly:
+
+1. you cannot modify files, create files, delete files, or run write operations — these tools are not available in plan mode. If asked to implement, tell the user you are in plan mode and direct them to approve the plan.
+2. do not execute commands that can change system state.
+3. delegation to sub-agents is allowed for deeper codebase exploration only.
+4. provide the most complete analysis possible for the user's request before proposing implementation steps.
+5. ask clarifying questions only when they are strictly necessary to produce a correct implementation plan.
+6. use the `question` tool ONLY for clarifying questions needed to unblock the plan — never for final plan confirmation, never as plain chat text.
+7. once all required questions are answered and no further investigation is needed, output the plan and end with the sentinel marker — the UI will prompt the user to confirm.
+
+
+
+1. decompose the request into independent exploration threads (e.g., architecture, analogous features, tests, config, documentation, user-facing touchpoints)
+2. launch multiple `agent` tool calls in parallel for independent searches; use direct search tools (like `glob`, `grep`, `ls`, `view`) only for simple, targeted lookups you can resolve in one or two calls
+3. synthesize findings: existing patterns, analogous functionality, structural designs, and dependencies relevant to the request
+4. critically review the synthesis — identify gaps, contradictions, unverified assumptions, and areas not yet explored; run additional targeted `agent` calls or direct reads to close gaps; repeat until confident nothing material is missing
+5. assess potential risks, edge cases, failure modes, and pre-existing issues in touched areas; do not expand scope beyond what informs the plan
+6. produce a concrete, actionable implementation plan
+7. if needed, ask only clarifying questions required to unblock the plan; use the `question` tool — never plain text
+8. when the plan is ready and complete, your final response MUST:
+ - include a "Critical Files" section listing the 3-5 files most critical for implementing the plan
+ - end with the exact marker on its own line:
+ - do NOT ask for confirmation via the question tool or plain text — the UI will prompt the user
+ - keep all intermediate/exploratory responses marker-free
+
+
+
\ No newline at end of file
diff --git a/internal/agent/tools/question.go b/internal/agent/tools/question.go
new file mode 100644
index 0000000000..9d6eb8f327
--- /dev/null
+++ b/internal/agent/tools/question.go
@@ -0,0 +1,184 @@
+package tools
+
+import (
+ "context"
+ _ "embed"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "charm.land/fantasy"
+ "github.com/charmbracelet/crush/internal/question"
+)
+
+const QuestionToolName = "question"
+
+//go:embed question.md
+var questionDescription string
+
+// QuestionParams defines the parameters for the question tool.
+type QuestionParams struct {
+ Questions []QuestionItem `json:"questions" description:"List of questions to present. Single item = no tabs, multiple = tabbed form."`
+ ConfirmTitle string `json:"confirm_title,omitempty" description:"Title for the confirmation tab. Required for multi-question batches."`
+ ConfirmDescription string `json:"confirm_description,omitempty" description:"Description for the confirmation tab. Required for multi-question batches."`
+}
+
+// UnmarshalJSON handles models that double-serialize the questions field as a
+// JSON string instead of a native array.
+func (p *QuestionParams) UnmarshalJSON(data []byte) error {
+ type Alias QuestionParams
+ aux := &struct {
+ Questions json.RawMessage `json:"questions"`
+ *Alias
+ }{
+ Alias: (*Alias)(p),
+ }
+ if err := json.Unmarshal(data, aux); err != nil {
+ return err
+ }
+ if len(aux.Questions) == 0 {
+ return nil
+ }
+ // Try array first.
+ if err := json.Unmarshal(aux.Questions, &p.Questions); err != nil {
+ // Fall back to string-encoded JSON array.
+ var s string
+ if err2 := json.Unmarshal(aux.Questions, &s); err2 != nil {
+ return err
+ }
+ if err2 := json.Unmarshal([]byte(strings.TrimSpace(s)), &p.Questions); err2 != nil {
+ return fmt.Errorf("questions must be an array: %w", err2)
+ }
+ }
+ return nil
+}
+
+// QuestionItem is a single question from the tool input.
+type QuestionItem struct {
+ Label string `json:"label,omitempty" description:"Short tab header label (3 words max)."`
+ Type string `json:"type" description:"The type of question: yes_no, single_choice, multi_choice, or free_text"`
+ Question string `json:"question" description:"The question text"`
+ Description string `json:"description" description:"Required markdown description shown below the question"`
+ Choices []QuestionChoice `json:"choices,omitempty" description:"List of choices"`
+ Options []QuestionChoice `json:"options,omitempty"` // alias for Choices
+}
+
+// GetChoices returns choices, preferring the Choices field over Options.
+func (q QuestionItem) GetChoices() []QuestionChoice {
+ if len(q.Choices) > 0 {
+ return q.Choices
+ }
+ return q.Options
+}
+
+// QuestionChoice represents a selectable option.
+type QuestionChoice struct {
+ ID string `json:"id" description:"Unique identifier for this choice"`
+ Label string `json:"label" description:"Display text for this choice"`
+ Description string `json:"description,omitempty" description:"Optional description for this choice"`
+}
+
+// NewQuestionTool creates a new question tool.
+func NewQuestionTool(svc question.Service) fantasy.AgentTool {
+ return fantasy.NewAgentTool(
+ QuestionToolName,
+ questionDescription,
+ func(ctx context.Context, params QuestionParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+ sessionID := GetSessionFromContext(ctx)
+
+ if len(params.Questions) == 0 {
+ return fantasy.NewTextErrorResponse("at least one question is required"), nil
+ }
+ if len(params.Questions) > question.MaxQuestions {
+ return fantasy.NewTextErrorResponse(fmt.Sprintf("exceeds maximum of %d questions (got %d)", question.MaxQuestions, len(params.Questions))), nil
+ }
+
+ questions := make([]question.Question, len(params.Questions))
+ for i, item := range params.Questions {
+ qType := question.Type(item.Type)
+ if qType != question.TypeYesNo && qType != question.TypeSingleChoice && qType != question.TypeMultiChoice && qType != question.TypeFreeText {
+ return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid type for question %d: %q", i, item.Type)), nil
+ }
+ questions[i] = question.Question{
+ Type: qType,
+ Label: item.Label,
+ Text: item.Question,
+ Description: item.Description,
+ Choices: convertChoices(item.GetChoices()),
+ }
+ }
+
+ req := question.Request{
+ SessionID: sessionID,
+ ToolCallID: call.ID,
+ Questions: questions,
+ ConfirmTitle: params.ConfirmTitle,
+ ConfirmDescription: params.ConfirmDescription,
+ }
+
+ answers, err := svc.Ask(ctx, req)
+ if err != nil {
+ return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to get user response: %v", err)), nil
+ }
+
+ return formatAnswers(answers, questions)
+ },
+ )
+}
+
+func convertChoices(in []QuestionChoice) []question.Choice {
+ out := make([]question.Choice, len(in))
+ for i, c := range in {
+ out[i] = question.Choice{ID: c.ID, Label: c.Label, Description: c.Description}
+ }
+ return out
+}
+
+// formatAnswers converts answers into a tool response string for the LLM.
+func formatAnswers(answers []question.Answer, questions []question.Question) (fantasy.ToolResponse, error) {
+ var b strings.Builder
+ for i, answer := range answers {
+ if i > 0 {
+ b.WriteString("\n\n")
+ }
+ if i < len(questions) {
+ fmt.Fprintf(&b, "Q%d: %s\n", i+1, questions[i].Text)
+ }
+ formatted, _ := formatAnswer(&answer, question.Type(""))
+ b.WriteString(formatted.Content)
+ }
+ return fantasy.NewTextResponse(b.String()), nil
+}
+
+// formatAnswer formats a single answer for the LLM.
+func formatAnswer(answer *question.Answer, _ question.Type) (fantasy.ToolResponse, error) {
+ var b strings.Builder
+
+ if answer.Yes != nil {
+ if *answer.Yes {
+ b.WriteString("User answered: yes")
+ } else {
+ b.WriteString("User answered: no")
+ }
+ } else if answer.FillInText != "" {
+ fmt.Fprintf(&b, "User provided: %s", answer.FillInText)
+ } else if len(answer.SelectedIDs) > 0 {
+ data, _ := json.Marshal(answer.SelectedIDs)
+ fmt.Fprintf(&b, "User selected: %s", string(data))
+ } else {
+ b.WriteString("User skipped this question")
+ }
+
+ if len(answer.Notes) > 0 {
+ b.WriteString("\n\nNotes:")
+ for key, note := range answer.Notes {
+ if key == "_question" {
+ fmt.Fprintf(&b, "\n- %s", note)
+ } else {
+ fmt.Fprintf(&b, "\n- [%s]: %s", key, note)
+ }
+ }
+ }
+
+ return fantasy.NewTextResponse(b.String()), nil
+}
diff --git a/internal/agent/tools/question.md b/internal/agent/tools/question.md
new file mode 100644
index 0000000000..7a719c1898
--- /dev/null
+++ b/internal/agent/tools/question.md
@@ -0,0 +1,102 @@
+Ask the user a structured question and wait for their response. Use this
+when you need clarification, confirmation, or a choice before proceeding.
+
+## How it works
+
+Always provide a `questions` array with at least one item. A single item
+renders as a plain question; multiple items render as a tabbed form with
+a confirmation screen at the end.
+
+Every question MUST include:
+- `type` — `yes_no`, `single_choice`, `multi_choice`, or `free_text`
+- `question` — a short, direct question (one line)
+- `description` — markdown context shown below the question with details,
+ tradeoffs, or examples. **Always required.** Omitting it causes an error.
+
+## Hard limits
+
+These are enforced. Violations return an error and waste a round trip.
+
+- **`questions` must be a JSON array**, not a string. Pass `[{...}]`, not
+ `"[{...}]"`. Double-serializing the array as a string is a common mistake.
+- **Max 5 choices** per question. If you have more, group or prioritize.
+- **Choices required** for `single_choice` and `multi_choice`. A
+ single_choice without choices is an error.
+- **Description required** on every question. Keep it under 300 chars.
+- **Choice descriptions** must be under 100 chars each.
+- **Max 5 questions** per batch.
+
+## Question types
+
+- `yes_no` — confirmation only. The question must be a proposition the user
+ affirms or rejects (e.g. "Proceed with deletion?", "Enable caching?").
+ Never use yes_no for A-vs-B choices, preference questions, or anything
+ where both answers are valid options rather than accept/reject. If the
+ question has two meaningful alternatives, use `single_choice` with two
+ choices instead — even when there are exactly two options.
+- `single_choice` — pick one from `choices`. Use this for any selection
+ between named alternatives, including binary ones like "TypeScript or
+ Go?" or "Automatic or manual?". Always provide at least 2 choices.
+- `multi_choice` — pick one or more from `choices`
+- `free_text` — open-ended text input. Use for questions that need a
+ narrative answer (e.g. "What keeps you up at night?", "Describe your
+ setup"). No choices needed. Do NOT use yes_no for open-ended questions.
+
+Single and multi choice questions automatically include a free-text
+fill-in option so the user can type a custom answer. Do not add an
+"Other", "Something else", or "Custom" choice manually.
+
+## Confirmation screen (batches only)
+
+When asking multiple questions, a confirmation tab is **always shown**
+after all questions are answered. The user sees a summary of their answers
+and must confirm before submitting. If they say no, they go back to editing.
+
+**`confirm_title` and `confirm_description` are required for batches.**
+Omitting either causes an error.
+
+- `confirm_title`: a short question like "Ready to go?" or "Sound good?"
+- `confirm_description`: summarize what will happen based on the expected
+ answers. Write it as if you already know what they'll pick. This gives
+ the user context for their confirmation decision.
+
+## Multiple questions
+
+When providing multiple questions, each item can include an optional
+`label` (3 words max) used as the tab header. If omitted, the first 3
+words of `question` are used.
+
+Example — single question:
+```json
+{
+ "questions": [
+ {"type": "yes_no", "question": "Enable caching?", "description": "Reduces latency for repeated queries but adds invalidation complexity."}
+ ]
+}
+```
+
+Example — multiple questions with confirmation:
+```json
+{
+ "questions": [
+ {"label": "Database", "type": "single_choice", "question": "Which database?", "description": "PostgreSQL for relational data, MongoDB for documents.", "choices": [{"id": "pg", "label": "PostgreSQL"}, {"id": "mongo", "label": "MongoDB"}]},
+ {"label": "Caching", "type": "yes_no", "question": "Enable caching?", "description": "Reduces latency for repeated queries but adds invalidation complexity."},
+ {"label": "Concerns", "type": "free_text", "question": "Any concerns about this approach?", "description": "Share any reservations or edge cases we should consider."}
+ ],
+ "confirm_title": "Ready to configure?",
+ "confirm_description": "We'll set up PostgreSQL with query caching enabled."
+}
+```
+
+## When to use
+
+- Confirm destructive or ambiguous actions
+- User's request has multiple valid interpretations
+- Need the user to pick from options
+- Gather multiple related answers at once
+
+## When NOT to use
+
+- Questions answerable by reading code or docs
+- Information obtainable via other tools
+- Asking permission (use the permission system)
diff --git a/internal/agent/tools/question_test.go b/internal/agent/tools/question_test.go
new file mode 100644
index 0000000000..55e7f0e13c
--- /dev/null
+++ b/internal/agent/tools/question_test.go
@@ -0,0 +1,49 @@
+package tools
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestQuestionParamsUnmarshalJSON_NativeArray(t *testing.T) {
+ t.Parallel()
+ input := `{"questions": [{"type": "yes_no", "question": "OK?", "description": "test"}]}`
+ var p QuestionParams
+ require.NoError(t, json.Unmarshal([]byte(input), &p))
+ require.Len(t, p.Questions, 1)
+ require.Equal(t, "OK?", p.Questions[0].Question)
+}
+
+func TestQuestionParamsUnmarshalJSON_StringEncodedArray(t *testing.T) {
+ t.Parallel()
+ // Simulates a model that double-serializes the questions field.
+ inner := `[{"type":"yes_no","question":"OK?","description":"test"}]`
+ encoded, _ := json.Marshal(inner)
+ input := `{"questions": ` + string(encoded) + `}`
+ var p QuestionParams
+ require.NoError(t, json.Unmarshal([]byte(input), &p))
+ require.Len(t, p.Questions, 1)
+ require.Equal(t, "OK?", p.Questions[0].Question)
+}
+
+func TestQuestionParamsUnmarshalJSON_StringEncodedWithWhitespace(t *testing.T) {
+ t.Parallel()
+ inner := ` [{"type":"single_choice","question":"Pick","description":"d","choices":[{"id":"a","label":"A"}]}] `
+ encoded, _ := json.Marshal(inner)
+ input := `{"questions": ` + string(encoded) + `, "confirm_title": "Go?"}`
+ var p QuestionParams
+ require.NoError(t, json.Unmarshal([]byte(input), &p))
+ require.Len(t, p.Questions, 1)
+ require.Equal(t, "Pick", p.Questions[0].Question)
+ require.Equal(t, "Go?", p.ConfirmTitle)
+}
+
+func TestQuestionParamsUnmarshalJSON_InvalidString(t *testing.T) {
+ t.Parallel()
+ encoded, _ := json.Marshal("not valid json")
+ input := `{"questions": ` + string(encoded) + `}`
+ var p QuestionParams
+ require.Error(t, json.Unmarshal([]byte(input), &p))
+}
diff --git a/internal/app/app.go b/internal/app/app.go
index d8a3abc63b..b75523663e 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -32,6 +32,7 @@ import (
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/pubsub"
+ "github.com/charmbracelet/crush/internal/question"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/shell"
"github.com/charmbracelet/crush/internal/skills"
@@ -56,6 +57,7 @@ type App struct {
Messages message.Service
History history.Service
Permissions permission.Service
+ Questions question.Service
FileTracker filetracker.Service
AgentCoordinator agent.Coordinator
@@ -105,6 +107,7 @@ func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore, skillsMgr
Messages: messages,
History: files,
Permissions: permission.NewPermissionService(store.WorkingDir(), skipPermissionsRequests, allowedTools),
+ Questions: question.NewService(),
FileTracker: filetracker.NewService(q),
LSPManager: lsp.NewManager(store),
Skills: skillsMgr,
@@ -229,6 +232,11 @@ func (app *App) resolveSession(ctx context.Context, continueSessionID string, us
func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool, continueSessionID string, useLast bool) error {
slog.Info("Running in non-interactive mode")
+ // Re-initialize the coder agent without interactive-only tools.
+ if err := app.InitCoderAgentNonInteractive(ctx); err != nil {
+ return fmt.Errorf("failed to reinitialize agent for non-interactive mode: %w", err)
+ }
+
ctx, cancel := context.WithCancel(ctx)
defer cancel()
@@ -499,6 +507,8 @@ func (app *App) setupEvents() {
setupSubscriber(ctx, app.serviceEventsWG, "messages", app.Messages.Subscribe, app.events)
setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
+ setupSubscriber(ctx, app.serviceEventsWG, "question-batches", app.Questions.Subscribe, app.events)
+ setupSubscriber(ctx, app.serviceEventsWG, "question-notifications", app.Questions.SubscribeNotifications, app.events)
setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
setupSubscriber(ctx, app.serviceEventsWG, "agent-notifications", app.agentNotifications.Subscribe, app.events)
setupSubscriberMustDeliver(ctx, app.serviceEventsWG, "run-completions", app.runCompletions.Subscribe, app.events)
@@ -574,24 +584,35 @@ func setupSubscriberMustDeliver[T any](
}
func (app *App) InitCoderAgent(ctx context.Context) error {
+ return app.initCoderAgent(ctx, true)
+}
+
+// InitCoderAgentNonInteractive initializes the coder agent without
+// interactive-only tools (e.g. question).
+func (app *App) InitCoderAgentNonInteractive(ctx context.Context) error {
+ return app.initCoderAgent(ctx, false)
+}
+
+func (app *App) initCoderAgent(ctx context.Context, interactive bool) error {
coderAgentCfg := app.config.Config().Agents[config.AgentCoder]
if coderAgentCfg.ID == "" {
return fmt.Errorf("coder agent configuration is missing")
}
var err error
- app.AgentCoordinator, err = agent.NewCoordinator(
- ctx,
- app.config,
- app.Sessions,
- app.Messages,
- app.Permissions,
- app.History,
- app.FileTracker,
- app.LSPManager,
- app.agentNotifications,
- app.runCompletions,
- app.Skills,
- )
+ app.AgentCoordinator, err = agent.NewCoordinator(ctx, agent.CoordinatorOptions{
+ Config: app.config,
+ Sessions: app.Sessions,
+ Messages: app.Messages,
+ Permissions: app.Permissions,
+ Questions: app.Questions,
+ History: app.History,
+ FileTracker: app.FileTracker,
+ LSPManager: app.LSPManager,
+ Notify: app.agentNotifications,
+ RunComplete: app.runCompletions,
+ Skills: app.Skills,
+ Interactive: interactive,
+ })
if err != nil {
slog.Error("Failed to create coder agent", "err", err)
return err
diff --git a/internal/app/testing.go b/internal/app/testing.go
index 1722e2b154..a90c36e073 100644
--- a/internal/app/testing.go
+++ b/internal/app/testing.go
@@ -8,6 +8,7 @@ import (
"github.com/charmbracelet/crush/internal/agent/notify"
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/pubsub"
+ "github.com/charmbracelet/crush/internal/question"
)
// NewForTest constructs a minimal [App] suitable for in-process tests
@@ -29,6 +30,7 @@ import (
func NewForTest(ctx context.Context) *App {
app := &App{
Permissions: permission.NewPermissionService("", false, nil),
+ Questions: question.NewService(),
globalCtx: ctx,
events: pubsub.NewBroker[tea.Msg](),
serviceEventsWG: &sync.WaitGroup{},
@@ -43,6 +45,10 @@ func NewForTest(ctx context.Context) *App {
app.Permissions.Subscribe, app.events)
setupSubscriber(eventsCtx, app.serviceEventsWG, "permissions-notifications",
app.Permissions.SubscribeNotifications, app.events)
+ setupSubscriber(eventsCtx, app.serviceEventsWG, "question-batches",
+ app.Questions.Subscribe, app.events)
+ setupSubscriber(eventsCtx, app.serviceEventsWG, "question-notifications",
+ app.Questions.SubscribeNotifications, app.events)
setupSubscriber(eventsCtx, app.serviceEventsWG, "agent-notifications",
app.agentNotifications.Subscribe, app.events)
setupSubscriber(eventsCtx, app.serviceEventsWG, "run-completions",
diff --git a/internal/backend/agent.go b/internal/backend/agent.go
index 3d08746ed3..e9e97de6fa 100644
--- a/internal/backend/agent.go
+++ b/internal/backend/agent.go
@@ -140,13 +140,16 @@ func (b *Backend) GetAgentInfo(workspaceID string) (proto.AgentInfo, error) {
}
// InitAgent initializes the coder agent for the workspace.
-func (b *Backend) InitAgent(ctx context.Context, workspaceID string) error {
+func (b *Backend) InitAgent(ctx context.Context, workspaceID string, interactive bool) error {
ws, err := b.GetWorkspace(workspaceID)
if err != nil {
return err
}
- return ws.InitCoderAgent(ctx)
+ if interactive {
+ return ws.InitCoderAgent(ctx)
+ }
+ return ws.InitCoderAgentNonInteractive(ctx)
}
// UpdateAgent reloads the agent model configuration.
diff --git a/internal/backend/agent_runcomplete_test.go b/internal/backend/agent_runcomplete_test.go
index be3df103e6..b0a87b544c 100644
--- a/internal/backend/agent_runcomplete_test.go
+++ b/internal/backend/agent_runcomplete_test.go
@@ -47,6 +47,7 @@ func (c *errorCoordinator) ClearQueue(string) {}
func (c *errorCoordinator) Summarize(context.Context, string) error { return nil }
func (c *errorCoordinator) Model() agent.Model { return agent.Model{} }
func (c *errorCoordinator) UpdateModels(context.Context) error { return nil }
+func (c *errorCoordinator) SetMainAgent(string) error { return nil }
// insertRunCompleteWorkspace installs a workspace backed by a real
// app.App (so the runCompletions broker exists) with the given
diff --git a/internal/backend/agent_test.go b/internal/backend/agent_test.go
index 5d9365ecc8..a74b177cf6 100644
--- a/internal/backend/agent_test.go
+++ b/internal/backend/agent_test.go
@@ -57,6 +57,7 @@ func (c *blockingCoordinator) ClearQueue(string)
func (c *blockingCoordinator) Summarize(context.Context, string) error { return nil }
func (c *blockingCoordinator) Model() agent.Model { return agent.Model{} }
func (c *blockingCoordinator) UpdateModels(context.Context) error { return nil }
+func (c *blockingCoordinator) SetMainAgent(string) error { return nil }
// insertAgentWorkspace installs a synthetic workspace with the given
// coordinator (or none) and a workspace run context, mirroring the
diff --git a/internal/backend/question.go b/internal/backend/question.go
new file mode 100644
index 0000000000..55b2efe7bd
--- /dev/null
+++ b/internal/backend/question.go
@@ -0,0 +1,29 @@
+package backend
+
+import (
+ "github.com/charmbracelet/crush/internal/proto"
+ "github.com/charmbracelet/crush/internal/question"
+)
+
+// AnswerQuestion submits answers for a question. The returned bool
+// reports whether this call resolved the pending request (true) or
+// found it already resolved by a previous caller (false).
+func (b *Backend) AnswerQuestion(workspaceID string, req proto.QuestionAnswer) (bool, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return false, err
+ }
+
+ responses := make([]question.Answer, len(req.Responses))
+ for i, r := range req.Responses {
+ responses[i] = question.Answer{
+ QuestionID: r.QuestionID,
+ SelectedIDs: r.SelectedIDs,
+ FillInText: r.FillInText,
+ Yes: r.Yes,
+ Notes: r.Notes,
+ }
+ }
+
+ return ws.Questions.Answer(responses), nil
+}
diff --git a/internal/client/proto.go b/internal/client/proto.go
index d07e46dc84..3e85ada813 100644
--- a/internal/client/proto.go
+++ b/internal/client/proto.go
@@ -195,6 +195,18 @@ func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, er
if !sendEvent(ctx, events, e) {
return
}
+ case pubsub.PayloadTypeQuestionRequest:
+ var e pubsub.Event[proto.QuestionRequest]
+ _ = json.Unmarshal(p.Payload, &e)
+ if !sendEvent(ctx, events, e) {
+ return
+ }
+ case pubsub.PayloadTypeQuestionNotification:
+ var e pubsub.Event[proto.QuestionNotification]
+ _ = json.Unmarshal(p.Payload, &e)
+ if !sendEvent(ctx, events, e) {
+ return
+ }
case pubsub.PayloadTypeMessage:
var e pubsub.Event[proto.Message]
_ = json.Unmarshal(p.Payload, &e)
@@ -476,8 +488,9 @@ func (c *Client) AgentSummarizeSession(ctx context.Context, id string, sessionID
}
// InitiateAgentProcessing triggers agent initialization on the server.
-func (c *Client) InitiateAgentProcessing(ctx context.Context, id string) error {
- rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/init", id), nil, nil, nil)
+func (c *Client) InitiateAgentProcessing(ctx context.Context, id string, interactive bool) error {
+ body := jsonBody(proto.AgentInitRequest{Interactive: interactive})
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/init", id), nil, body, http.Header{"Content-Type": []string{"application/json"}})
if err != nil {
return fmt.Errorf("failed to initiate session agent processing: %w", err)
}
@@ -594,6 +607,25 @@ func (c *Client) GrantPermission(ctx context.Context, id string, req proto.Permi
return resp.Resolved, nil
}
+// AnswerQuestionBatch submits answers for a batch question on a
+// workspace. Returns true if this call resolved the pending
+// request, false if already resolved by another caller.
+func (c *Client) AnswerQuestionBatch(ctx context.Context, id string, req proto.QuestionAnswer) (bool, error) {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/questions/answer", id), nil, jsonBody(req), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return false, fmt.Errorf("failed to answer question batch: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return false, fmt.Errorf("failed to answer question batch: status code %d", rsp.StatusCode)
+ }
+ var resp proto.QuestionAnswerResponse
+ if err := json.NewDecoder(rsp.Body).Decode(&resp); err != nil {
+ return false, fmt.Errorf("failed to decode answer question batch response: %w", err)
+ }
+ return resp.Resolved, nil
+}
+
// SetPermissionsSkipRequests sets the skip-requests flag for a workspace.
func (c *Client) SetPermissionsSkipRequests(ctx context.Context, id string, skip bool) error {
rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/permissions/skip", id), nil, jsonBody(proto.PermissionSkipRequest{Skip: skip}), http.Header{"Content-Type": []string{"application/json"}})
diff --git a/internal/cmd/run.go b/internal/cmd/run.go
index 2feeba78e6..5d2742764c 100644
--- a/internal/cmd/run.go
+++ b/internal/cmd/run.go
@@ -105,6 +105,15 @@ crush run --continue "Follow up on your last response"
event.AppInitialized()
+ if !ws.Config.IsConfigured() {
+ return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
+ }
+
+ clientWs := workspace.NewClientWorkspace(c, *ws)
+ if err := clientWs.InitCoderAgentNonInteractive(ctx); err != nil {
+ return fmt.Errorf("failed to initialize agent: %w", err)
+ }
+
if sessionID != "" {
sess, err := resolveSessionByID(ctx, c, ws.ID, sessionID)
if err != nil {
@@ -113,10 +122,6 @@ crush run --continue "Follow up on your last response"
sessionID = sess.ID
}
- if !ws.Config.IsConfigured() {
- return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
- }
-
if verbose {
slog.SetDefault(slog.New(log.New(os.Stderr)))
}
diff --git a/internal/config/agent_id_test.go b/internal/config/agent_id_test.go
index 74bad7f563..ef503c6334 100644
--- a/internal/config/agent_id_test.go
+++ b/internal/config/agent_id_test.go
@@ -26,4 +26,10 @@ func TestConfig_AgentIDs(t *testing.T) {
require.True(t, ok)
assert.Equal(t, AgentTask, taskAgent.ID, "Task agent ID should be '%s'", AgentTask)
})
+
+ t.Run("Plan agent should have correct ID", func(t *testing.T) {
+ planAgent, ok := cfg.Agents[AgentPlan]
+ require.True(t, ok)
+ assert.Equal(t, AgentPlan, planAgent.ID, "Plan agent ID should be '%s'", AgentPlan)
+ })
}
diff --git a/internal/config/config.go b/internal/config/config.go
index abd36f3e71..8bf6214eed 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -58,6 +58,7 @@ const (
const (
AgentCoder string = "coder"
+ AgentPlan string = "plan"
AgentTask string = "task"
)
@@ -684,6 +685,7 @@ func allToolNames() []string {
"glob",
"grep",
"ls",
+ "question",
"sourcegraph",
"todos",
"view",
@@ -707,6 +709,11 @@ func resolveReadOnlyTools(tools []string) []string {
return filterSlice(tools, readOnlyTools, true)
}
+func resolvePlanTools(tools []string) []string {
+ planTools := []string{"agent", "glob", "grep", "ls", "question", "sourcegraph", "view"}
+ return filterSlice(tools, planTools, true)
+}
+
func filterSlice(data []string, mask []string, include bool) []string {
var filtered []string
for _, s := range data {
@@ -742,6 +749,17 @@ func (c *Config) SetupAgents() {
// NO MCPs or LSPs by default
AllowedMCP: map[string][]string{},
},
+
+ AgentPlan: {
+ ID: AgentPlan,
+ Name: "Plan",
+ Description: "An agent that performs deep analysis and prepares implementation plans without modifying files.",
+ Model: SelectedModelTypeLarge,
+ ContextPaths: c.Options.ContextPaths,
+ AllowedTools: resolvePlanTools(allowedTools),
+ // NO MCPs or LSPs by default
+ AllowedMCP: map[string][]string{},
+ },
}
c.Agents = agents
}
diff --git a/internal/config/load_test.go b/internal/config/load_test.go
index 9d67acaeec..f8dd4c5deb 100644
--- a/internal/config/load_test.go
+++ b/internal/config/load_test.go
@@ -689,6 +689,10 @@ func TestConfig_setupAgentsWithNoDisabledTools(t *testing.T) {
taskAgent, ok := cfg.Agents[AgentTask]
require.True(t, ok)
assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
+
+ planAgent, ok := cfg.Agents[AgentPlan]
+ require.True(t, ok)
+ assert.Equal(t, []string{"agent", "glob", "grep", "ls", "question", "sourcegraph", "view"}, planAgent.AllowedTools)
}
func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
@@ -706,17 +710,22 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
coderAgent, ok := cfg.Agents[AgentCoder]
require.True(t, ok)
- assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
+ assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "question", "sourcegraph", "todos", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
taskAgent, ok := cfg.Agents[AgentTask]
require.True(t, ok)
assert.Equal(t, []string{"glob", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
+
+ planAgent, ok := cfg.Agents[AgentPlan]
+ require.True(t, ok)
+ assert.Equal(t, []string{"agent", "glob", "ls", "question", "sourcegraph", "view"}, planAgent.AllowedTools)
}
func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
cfg := &Config{
Options: &Options{
DisabledTools: []string{
+ "agent",
"glob",
"grep",
"ls",
@@ -729,11 +738,15 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
cfg.SetupAgents()
coderAgent, ok := cfg.Agents[AgentCoder]
require.True(t, ok)
- assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
+ assert.Equal(t, []string{"bash", "crush_info", "crush_logs", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "question", "todos", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
taskAgent, ok := cfg.Agents[AgentTask]
require.True(t, ok)
assert.Len(t, taskAgent.AllowedTools, 0)
+
+ planAgent, ok := cfg.Agents[AgentPlan]
+ require.True(t, ok)
+ assert.Equal(t, []string{"question"}, planAgent.AllowedTools)
}
func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
diff --git a/internal/proto/proto.go b/internal/proto/proto.go
index 2e2cebc6ca..6d436167d7 100644
--- a/internal/proto/proto.go
+++ b/internal/proto/proto.go
@@ -181,6 +181,62 @@ type PermissionGrantResponse struct {
Resolved bool `json:"resolved"`
}
+// QuestionRequest is the wire format for a batch question
+// sent from server to client over SSE.
+type QuestionRequest struct {
+ ID string `json:"id"`
+ SessionID string `json:"session_id"`
+ ToolCallID string `json:"tool_call_id"`
+ Questions []QuestionItem `json:"questions"`
+ ConfirmTitle string `json:"confirm_title,omitempty"`
+ ConfirmDescription string `json:"confirm_description,omitempty"`
+}
+
+// QuestionItem is a single question within a batch.
+type QuestionItem struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Label string `json:"label,omitempty"`
+ Question string `json:"question"`
+ Description string `json:"description,omitempty"`
+ Choices []QuestionChoice `json:"choices,omitempty"`
+}
+
+// QuestionChoice is a selectable option.
+type QuestionChoice struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ Description string `json:"description,omitempty"`
+}
+
+// QuestionAnswer is the wire format for answering a batch
+// question, sent from client to server via REST.
+type QuestionAnswer struct {
+ BatchRequestID string `json:"batch_request_id"`
+ Responses []QuestionResponse `json:"responses"`
+}
+
+// QuestionResponse is a single answer within a batch response.
+type QuestionResponse struct {
+ QuestionID string `json:"request_id"`
+ SelectedIDs []string `json:"selected_ids,omitempty"`
+ FillInText string `json:"fill_in_text,omitempty"`
+ Yes *bool `json:"yes,omitempty"`
+ Notes map[string]string `json:"notes,omitempty"`
+}
+
+// QuestionAnswerResponse is the server's response to a
+// question batch answer call.
+type QuestionAnswerResponse struct {
+ Resolved bool `json:"resolved"`
+}
+
+// QuestionNotification is published when a question batch is
+// resolved so non-answering clients can dismiss their forms.
+type QuestionNotification struct {
+ BatchID string `json:"batch_id"`
+}
+
// PermissionSkipRequest represents a request to skip permission prompts.
type PermissionSkipRequest struct {
Skip bool `json:"skip"`
diff --git a/internal/proto/requests.go b/internal/proto/requests.go
index e66807def9..e2a772ad6f 100644
--- a/internal/proto/requests.go
+++ b/internal/proto/requests.go
@@ -102,6 +102,11 @@ type ProjectInitPromptResponse struct {
Prompt string `json:"prompt"`
}
+// AgentInitRequest represents a request to initialize the agent.
+type AgentInitRequest struct {
+ Interactive bool `json:"interactive"`
+}
+
// LSPStartRequest represents a request to start an LSP for a path.
type LSPStartRequest struct {
Path string `json:"path"`
diff --git a/internal/pubsub/events.go b/internal/pubsub/events.go
index 682672dfb2..dfe8b4c3a2 100644
--- a/internal/pubsub/events.go
+++ b/internal/pubsub/events.go
@@ -27,6 +27,8 @@ const (
PayloadTypeConfigChanged PayloadType = "config_changed"
PayloadTypeSkillsEvent PayloadType = "skills_event"
PayloadTypeRunComplete PayloadType = "run_complete"
+ PayloadTypeQuestionRequest PayloadType = "question_batch_request"
+ PayloadTypeQuestionNotification PayloadType = "question_batch_notification"
)
// Payload wraps a discriminated JSON payload with a type tag.
diff --git a/internal/question/question.go b/internal/question/question.go
new file mode 100644
index 0000000000..923bc8050e
--- /dev/null
+++ b/internal/question/question.go
@@ -0,0 +1,262 @@
+// Package question provides services for asking the user questions
+// via the TUI and blocking until an answer is received. It mirrors
+// the permission service pattern: publish a request over pubsub,
+// block on a channel, and resolve when the UI sends back answers.
+//
+// Only one question can be pending at a time (the tool blocks until
+// answered), so no correlation IDs are needed in the domain model.
+package question
+
+import (
+ "context"
+ "fmt"
+ "sync"
+
+ "github.com/charmbracelet/crush/internal/pubsub"
+ "github.com/google/uuid"
+)
+
+// Type identifies the kind of question to present.
+type Type string
+
+const (
+ TypeYesNo Type = "yes_no"
+ TypeSingleChoice Type = "single_choice"
+ TypeMultiChoice Type = "multi_choice"
+ TypeFreeText Type = "free_text"
+)
+
+// Choice represents a single selectable option.
+type Choice struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ Description string `json:"description,omitempty"`
+}
+
+// Question is a single question definition within a Request.
+type Question struct {
+ ID string `json:"id"`
+ Type Type `json:"type"`
+ Label string `json:"label,omitempty"`
+ Text string `json:"question"`
+ Description string `json:"description,omitempty"`
+ Choices []Choice `json:"choices,omitempty"`
+}
+
+// Answer carries the user's response to a single Question.
+type Answer struct {
+ QuestionID string `json:"question_id"`
+ SelectedIDs []string `json:"selected_ids,omitempty"`
+ FillInText string `json:"fill_in_text,omitempty"`
+ Yes *bool `json:"yes,omitempty"`
+ Notes map[string]string `json:"notes,omitempty"`
+}
+
+// HasNotes reports whether any notes were attached.
+func (a Answer) HasNotes() bool { return len(a.Notes) > 0 }
+
+// Request is the service envelope published to the UI. It contains
+// one or more Questions. A single question renders without tabs;
+// multiple questions render as a tabbed form with confirmation.
+type Request struct {
+ ID string `json:"id"`
+ SessionID string `json:"session_id"`
+ ToolCallID string `json:"tool_call_id"`
+ Questions []Question `json:"questions"`
+ ConfirmTitle string `json:"confirm_title,omitempty"`
+ ConfirmDescription string `json:"confirm_description,omitempty"`
+}
+
+// Validate checks that a Request has valid fields. For multiple
+// questions, ConfirmTitle and ConfirmDescription are required.
+func (r Request) Validate() error {
+ if len(r.Questions) == 0 {
+ return fmt.Errorf("at least one question is required")
+ }
+ if len(r.Questions) > MaxQuestions {
+ return fmt.Errorf("questions exceed maximum of %d (got %d)", MaxQuestions, len(r.Questions))
+ }
+ if len(r.Questions) >= 2 {
+ if r.ConfirmTitle == "" {
+ return fmt.Errorf("confirm_title is required for multi-question requests")
+ }
+ if r.ConfirmDescription == "" {
+ return fmt.Errorf("confirm_description is required for multi-question requests")
+ }
+ }
+ for i, q := range r.Questions {
+ if err := q.Validate(); err != nil {
+ return fmt.Errorf("question %d: %w", i, err)
+ }
+ }
+ return nil
+}
+
+// Validate checks that a Question has valid fields. Error messages
+// are written for LLM consumption: specific and actionable.
+func (q Question) Validate() error {
+ if q.Text == "" {
+ return fmt.Errorf("question text is required")
+ }
+ if len(q.Text) > MaxQuestionLength {
+ return fmt.Errorf("text exceeds %d characters (got %d)", MaxQuestionLength, len(q.Text))
+ }
+ if q.Description == "" {
+ return fmt.Errorf("description is required")
+ }
+ if len(q.Description) > MaxDescriptionLength {
+ return fmt.Errorf("description exceeds %d characters (got %d)", MaxDescriptionLength, len(q.Description))
+ }
+ switch q.Type {
+ case TypeYesNo, TypeFreeText:
+ // No choices needed.
+ case TypeSingleChoice, TypeMultiChoice:
+ if len(q.Choices) < 2 {
+ return fmt.Errorf("%s requires at least 2 choices (got %d)", q.Type, len(q.Choices))
+ }
+ if len(q.Choices) > MaxChoices {
+ return fmt.Errorf("choices exceed maximum of %d (got %d)", MaxChoices, len(q.Choices))
+ }
+ seen := make(map[string]bool, len(q.Choices))
+ for i, c := range q.Choices {
+ if c.ID == "" {
+ return fmt.Errorf("choice %d must have an id", i)
+ }
+ if seen[c.ID] {
+ return fmt.Errorf("choice %d has duplicate id %q", i, c.ID)
+ }
+ seen[c.ID] = true
+ if c.Label == "" {
+ return fmt.Errorf("choice %d must have a label", i)
+ }
+ if len(c.Label) > MaxChoiceLabelLength {
+ return fmt.Errorf("choice %d label exceeds %d characters (got %d)", i, MaxChoiceLabelLength, len(c.Label))
+ }
+ if len(c.Description) > MaxChoiceDescriptionLength {
+ return fmt.Errorf("choice %d description exceeds %d characters (got %d)", i, MaxChoiceDescriptionLength, len(c.Description))
+ }
+ }
+ default:
+ return fmt.Errorf("unknown type %q (must be yes_no, single_choice, multi_choice, or free_text)", q.Type)
+ }
+ return nil
+}
+
+const (
+ MaxQuestionLength = 120
+ MaxDescriptionLength = 300
+ MaxChoiceLabelLength = 120
+ MaxChoiceDescriptionLength = 100
+ MaxChoices = 5
+ MaxQuestions = 5
+)
+
+// Notification is published when a question batch is resolved so
+// that non-answering clients can dismiss their open forms.
+type Notification struct {
+ BatchID string `json:"batch_id"`
+}
+
+// Service manages the lifecycle of question requests. Only one
+// question can be pending at a time.
+type Service interface {
+ pubsub.Subscriber[Request]
+
+ // SubscribeNotifications returns a channel for question
+ // resolution notifications.
+ SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[Notification]
+
+ // Ask publishes questions and blocks until the user answers
+ // or the context is cancelled.
+ Ask(ctx context.Context, req Request) ([]Answer, error)
+
+ // Answer resolves the pending question with the given answers.
+ Answer(answers []Answer) bool
+}
+
+type questionService struct {
+ broker *pubsub.Broker[Request]
+ notificationBroker *pubsub.Broker[Notification]
+ mu sync.Mutex
+ pending chan []Answer
+ pendingID string
+}
+
+// NewService creates a new question service.
+func NewService() *questionService {
+ return &questionService{
+ broker: pubsub.NewBroker[Request](),
+ notificationBroker: pubsub.NewBroker[Notification](),
+ }
+}
+
+// Subscribe returns a channel for question events.
+func (s *questionService) Subscribe(ctx context.Context) <-chan pubsub.Event[Request] {
+ return s.broker.Subscribe(ctx)
+}
+
+// SubscribeNotifications returns a channel for question resolution
+// notifications.
+func (s *questionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[Notification] {
+ return s.notificationBroker.Subscribe(ctx)
+}
+
+// Ask publishes a request and blocks until the user answers.
+func (s *questionService) Ask(ctx context.Context, req Request) ([]Answer, error) {
+ if req.ID == "" {
+ req.ID = uuid.New().String()
+ }
+ for i := range req.Questions {
+ if req.Questions[i].ID == "" {
+ req.Questions[i].ID = uuid.New().String()
+ }
+ }
+
+ if err := req.Validate(); err != nil {
+ return nil, err
+ }
+
+ s.mu.Lock()
+ s.pending = make(chan []Answer, 1)
+ s.pendingID = req.ID
+ s.mu.Unlock()
+
+ defer func() {
+ s.mu.Lock()
+ s.pending = nil
+ s.pendingID = ""
+ s.mu.Unlock()
+ }()
+
+ s.broker.Publish(pubsub.CreatedEvent, req)
+
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case answers := <-s.pending:
+ return answers, nil
+ }
+}
+
+// Answer resolves the pending question. Returns false if no
+// question is pending (already answered or cancelled).
+func (s *questionService) Answer(answers []Answer) bool {
+ s.mu.Lock()
+ batchID := s.pendingID
+ ch := s.pending
+ s.mu.Unlock()
+
+ if ch == nil {
+ return false
+ }
+ ch <- answers
+
+ // Publish a notification so non-answering clients can dismiss
+ // their open question forms.
+ if batchID != "" {
+ s.notificationBroker.Publish(pubsub.CreatedEvent, Notification{
+ BatchID: batchID,
+ })
+ }
+ return true
+}
diff --git a/internal/server/agent_cancel_test.go b/internal/server/agent_cancel_test.go
index 68d1f10132..720d24c68e 100644
--- a/internal/server/agent_cancel_test.go
+++ b/internal/server/agent_cancel_test.go
@@ -81,6 +81,7 @@ func (s *runCoordinator) Summarize(context.Context, string) error {
}
func (s *runCoordinator) Model() agent.Model { return agent.Model{} }
func (s *runCoordinator) UpdateModels(context.Context) error { return nil }
+func (s *runCoordinator) SetMainAgent(string) error { return nil }
func (s *runCoordinator) capturedCtx() context.Context {
s.mu.Lock()
diff --git a/internal/server/e2e_agent_test.go b/internal/server/e2e_agent_test.go
index 012068a967..10c923ba92 100644
--- a/internal/server/e2e_agent_test.go
+++ b/internal/server/e2e_agent_test.go
@@ -220,6 +220,7 @@ func (c *scriptedCoordinator) ClearQueue(string) {}
func (c *scriptedCoordinator) Summarize(context.Context, string) error { return nil }
func (c *scriptedCoordinator) Model() agent.Model { return agent.Model{} }
func (c *scriptedCoordinator) UpdateModels(context.Context) error { return nil }
+func (c *scriptedCoordinator) SetMainAgent(string) error { return nil }
// agentE2EHarness extends the SSE harness with a scripted coordinator
// wired into the workspace's embedded app.App, so POST /agent drives a
diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go
index 565a989136..887d33c695 100644
--- a/internal/server/e2e_test.go
+++ b/internal/server/e2e_test.go
@@ -234,6 +234,12 @@ func decodeSSEEnvelope(p pubsub.Payload) (any, bool) {
return nil, false
}
return e, true
+ case pubsub.PayloadTypeQuestionNotification:
+ var e pubsub.Event[proto.QuestionNotification]
+ if err := json.Unmarshal(p.Payload, &e); err != nil {
+ return nil, false
+ }
+ return e, true
case pubsub.PayloadTypeMessage:
var e pubsub.Event[proto.Message]
if err := json.Unmarshal(p.Payload, &e); err != nil {
diff --git a/internal/server/events.go b/internal/server/events.go
index 526f9e1950..c6f09c13cf 100644
--- a/internal/server/events.go
+++ b/internal/server/events.go
@@ -15,6 +15,7 @@ import (
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/proto"
"github.com/charmbracelet/crush/internal/pubsub"
+ "github.com/charmbracelet/crush/internal/question"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/skills"
)
@@ -70,6 +71,26 @@ func wrapEvent(ev any) *pubsub.Payload {
Denied: e.Payload.Denied,
},
})
+ case pubsub.Event[question.Request]:
+ slog.Info("Wrapping question batch event for SSE", "id", e.Payload.ID, "questions", len(e.Payload.Questions))
+ return envelope(pubsub.PayloadTypeQuestionRequest, pubsub.Event[proto.QuestionRequest]{
+ Type: e.Type,
+ Payload: proto.QuestionRequest{
+ ID: e.Payload.ID,
+ SessionID: e.Payload.SessionID,
+ ToolCallID: e.Payload.ToolCallID,
+ Questions: questionsToProto(e.Payload.Questions),
+ ConfirmTitle: e.Payload.ConfirmTitle,
+ ConfirmDescription: e.Payload.ConfirmDescription,
+ },
+ })
+ case pubsub.Event[question.Notification]:
+ return envelope(pubsub.PayloadTypeQuestionNotification, pubsub.Event[proto.QuestionNotification]{
+ Type: e.Type,
+ Payload: proto.QuestionNotification{
+ BatchID: e.Payload.BatchID,
+ },
+ })
case pubsub.Event[message.Message]:
return envelope(pubsub.PayloadTypeMessage, pubsub.Event[proto.Message]{
Type: e.Type,
@@ -303,3 +324,29 @@ func messagesToProto(msgs []message.Message) []proto.Message {
}
return out
}
+
+func questionsToProto(qs []question.Question) []proto.QuestionItem {
+ if len(qs) == 0 {
+ return nil
+ }
+ out := make([]proto.QuestionItem, len(qs))
+ for i, q := range qs {
+ choices := make([]proto.QuestionChoice, len(q.Choices))
+ for j, c := range q.Choices {
+ choices[j] = proto.QuestionChoice{
+ ID: c.ID,
+ Label: c.Label,
+ Description: c.Description,
+ }
+ }
+ out[i] = proto.QuestionItem{
+ ID: q.ID,
+ Type: string(q.Type),
+ Label: q.Label,
+ Question: q.Text,
+ Description: q.Description,
+ Choices: choices,
+ }
+ }
+ return out
+}
diff --git a/internal/server/proto.go b/internal/server/proto.go
index 1d0dddece0..767fb58b45 100644
--- a/internal/server/proto.go
+++ b/internal/server/proto.go
@@ -786,7 +786,17 @@ func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.R
// @Router /workspaces/{id}/agent/init [post]
func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
- if err := c.backend.InitAgent(r.Context(), id); err != nil {
+
+ var req proto.AgentInitRequest
+ if r.Body != nil && r.ContentLength > 0 {
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode agent init request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+ }
+
+ if err := c.backend.InitAgent(r.Context(), id, req.Interactive); err != nil {
c.handleError(w, r, err)
return
}
@@ -989,6 +999,36 @@ func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter
jsonEncode(w, proto.PermissionGrantResponse{Resolved: resolved})
}
+// handlePostWorkspaceQuestionsAnswer submits answers for a batch question.
+//
+// @Summary Answer question batch
+// @Tags questions
+// @Accept json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.QuestionAnswer true "Question batch answer"
+// @Success 200 {object} proto.QuestionAnswerResponse
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/questions/answer [post]
+func (c *controllerV1) handlePostWorkspaceQuestionsAnswer(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.QuestionAnswer
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ resolved, err := c.backend.AnswerQuestion(id, req)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, proto.QuestionAnswerResponse{Resolved: resolved})
+}
+
// handlePostWorkspacePermissionsSkip sets whether to skip permission prompts.
//
// @Summary Set skip permissions
diff --git a/internal/server/recover_test.go b/internal/server/recover_test.go
index 2bbd61efd5..45b616677b 100644
--- a/internal/server/recover_test.go
+++ b/internal/server/recover_test.go
@@ -23,7 +23,7 @@ func TestRecoverHandler_PanicReturns500(t *testing.T) {
}))
rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil)
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusInternalServerError, rec.Code)
@@ -48,7 +48,7 @@ func TestRecoverHandler_NoPanicPassthrough(t *testing.T) {
}))
rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil)
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusTeapot, rec.Code)
@@ -71,7 +71,7 @@ func TestRecoverHandler_PanicAfterWriteHeader(t *testing.T) {
}))
rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil)
require.NotPanics(t, func() { h.ServeHTTP(rec, req) })
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "partial", rec.Body.String())
@@ -89,6 +89,6 @@ func TestRecoverHandler_AbortHandlerPropagates(t *testing.T) {
}))
rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil)
require.PanicsWithValue(t, http.ErrAbortHandler, func() { h.ServeHTTP(rec, req) })
}
diff --git a/internal/server/server.go b/internal/server/server.go
index 8a5b3c98f3..c3b1cd6b2e 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -179,6 +179,7 @@ func (s *Server) installHandler() {
mux.HandleFunc("GET /v1/workspaces/{id}/permissions/skip", c.handleGetWorkspacePermissionsSkip)
mux.HandleFunc("POST /v1/workspaces/{id}/permissions/skip", c.handlePostWorkspacePermissionsSkip)
mux.HandleFunc("POST /v1/workspaces/{id}/permissions/grant", c.handlePostWorkspacePermissionsGrant)
+ mux.HandleFunc("POST /v1/workspaces/{id}/questions/answer", c.handlePostWorkspaceQuestionsAnswer)
mux.HandleFunc("GET /v1/workspaces/{id}/agent", c.handleGetWorkspaceAgent)
mux.HandleFunc("POST /v1/workspaces/{id}/agent", c.handlePostWorkspaceAgent)
mux.HandleFunc("POST /v1/workspaces/{id}/agent/init", c.handlePostWorkspaceAgentInit)
diff --git a/internal/server/sessions_isbusy_test.go b/internal/server/sessions_isbusy_test.go
index 615f4a5b58..1b84192fa2 100644
--- a/internal/server/sessions_isbusy_test.go
+++ b/internal/server/sessions_isbusy_test.go
@@ -52,6 +52,7 @@ func (s *stubCoordinator) Summarize(context.Context, string) error {
}
func (s *stubCoordinator) Model() agent.Model { return agent.Model{} }
func (s *stubCoordinator) UpdateModels(context.Context) error { return nil }
+func (s *stubCoordinator) SetMainAgent(string) error { return nil }
// stubSessions is a minimal session.Service that returns a fixed list
// (and supports Get by ID). All other methods return zero values; the
diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go
index 412d85238e..64cdc0c76f 100644
--- a/internal/ui/chat/assistant.go
+++ b/internal/ui/chat/assistant.go
@@ -422,11 +422,42 @@ func (a *AssistantMessageItem) cachedContent(width int) string {
if a.contentSec.hit(width, srcHash, extra) {
return a.contentSec.out
}
- out := a.renderMarkdown(a.message.Content().Text, width)
+ text := a.message.Content().Text
+ // In plan mode the agent ends its final plan with a sentinel marker.
+ // Hide the end plan marker and wrap the message in a background "card" so
+ // the plan stands out from regular assistant replies. Mirrors the
+ // ThinkingBox treatment.
+ var out string
+ if common.PlanReadyMarkerPresent(text) {
+ out = a.renderPlanCard(common.StripPlanReadyMarker(text), width)
+ } else {
+ out = a.renderMarkdown(text, width)
+ }
a.contentSec.store(width, srcHash, extra, out, 0)
return out
}
+// renderPlanCard renders the final plan message as a full-width card. The
+// markdown is rendered at the card's inner width (accounting for PlanBox's
+// horizontal padding) with the PlanMarkdown style, whose per-primitive
+// background keeps the card fill uninterrupted by glamour's SGR resets;
+// PlanBox then paints the padding and pads each line out to full width. The
+// plan is final by the time the marker appears, so this bypasses the
+// streaming-markdown cache and renders directly, like renderThinking.
+func (a *AssistantMessageItem) renderPlanCard(text string, width int) string {
+ box := a.sty.Messages.PlanBox
+ innerWidth := max(1, width-box.GetHorizontalPadding())
+ renderer := common.PlanMarkdownRenderer(a.sty, innerWidth)
+ mu := common.LockMarkdownRenderer(renderer)
+ mu.Lock()
+ rendered, err := renderer.Render(text)
+ mu.Unlock()
+ if err != nil {
+ rendered = text
+ }
+ return box.Width(width).Render(strings.TrimSpace(rendered))
+}
+
// cachedError returns the rendered error section.
func (a *AssistantMessageItem) cachedError(width int) string {
srcHash, extra := a.errorKey()
diff --git a/internal/ui/chat/question.go b/internal/ui/chat/question.go
new file mode 100644
index 0000000000..9d930b0484
--- /dev/null
+++ b/internal/ui/chat/question.go
@@ -0,0 +1,220 @@
+package chat
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/crush/internal/agent/tools"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// QuestionToolMessageItem renders question tool calls in the chat.
+type QuestionToolMessageItem struct {
+ *baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*QuestionToolMessageItem)(nil)
+
+// NewQuestionToolMessageItem creates a new [QuestionToolMessageItem].
+func NewQuestionToolMessageItem(
+ sty *styles.Styles,
+ toolCall message.ToolCall,
+ result *message.ToolResult,
+ canceled bool,
+) ToolMessageItem {
+ return newBaseToolMessageItem(sty, toolCall, result, &QuestionToolRenderContext{}, canceled)
+}
+
+// QuestionToolRenderContext renders question tool messages.
+type QuestionToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (q *QuestionToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+ cappedWidth := cappedMessageWidth(width)
+ if opts.IsPending() {
+ return pendingTool(sty, "Question", opts.Anim, opts.Compact)
+ }
+
+ var params tools.QuestionParams
+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil {
+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+ }
+
+ headerText := questionSummary(params)
+ header := toolHeader(sty, opts.Status, "Question", cappedWidth, opts.Compact, headerText)
+ if opts.Compact {
+ return header
+ }
+
+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+ return joinToolParts(header, earlyState)
+ }
+
+ if opts.HasEmptyResult() {
+ return header
+ }
+
+ body := formatQuestionAnswers(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal)
+ if body == "" {
+ return header
+ }
+
+ return joinToolParts(header, sty.Tool.Body.Render(body))
+}
+
+// questionSummary builds a short header summary from the question params.
+func questionSummary(params tools.QuestionParams) string {
+ n := len(params.Questions)
+ if n == 0 {
+ return ""
+ }
+ if n == 1 {
+ text := params.Questions[0].Question
+ if len(text) > 60 {
+ text = text[:59] + "…"
+ }
+ return text
+ }
+ first := params.Questions[0].Question
+ if len(first) > 40 {
+ first = first[:39] + "…"
+ }
+ return fmt.Sprintf("%s (+%d more)", first, n-1)
+}
+
+// questionBlock holds a parsed Q&A block from the tool result.
+type questionBlock struct {
+ question string
+ answer string
+ notes []string
+}
+
+// parseQuestionBlocks splits the tool result into per-question blocks,
+// correctly handling the Notes subsection within each block.
+func parseQuestionBlocks(content string) []questionBlock {
+ // Split on "QN: " boundaries rather than \n\n since notes
+ // introduce extra \n\n within a single question block.
+ var rawBlocks []string
+ lines := strings.Split(content, "\n")
+ var current []string
+ for _, line := range lines {
+ if strings.HasPrefix(line, "Q") && len(current) > 0 {
+ rest := strings.TrimPrefix(line, "Q")
+ if idx := strings.IndexByte(rest, ':'); idx > 0 {
+ allDigits := true
+ for _, c := range rest[:idx] {
+ if c < '0' || c > '9' {
+ allDigits = false
+ break
+ }
+ }
+ if allDigits {
+ rawBlocks = append(rawBlocks, strings.Join(current, "\n"))
+ current = nil
+ }
+ }
+ }
+ current = append(current, line)
+ }
+ if len(current) > 0 {
+ rawBlocks = append(rawBlocks, strings.Join(current, "\n"))
+ }
+
+ blocks := make([]questionBlock, 0, len(rawBlocks))
+ for _, raw := range rawBlocks {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ continue
+ }
+
+ var b questionBlock
+
+ // Strip the "QN: \n" prefix.
+ if nlIdx := strings.IndexByte(raw, '\n'); nlIdx >= 0 {
+ b.question = strings.TrimSpace(raw[:nlIdx])
+ // Remove the "QN: " prefix from the question text.
+ if colonIdx := strings.Index(b.question, ": "); colonIdx >= 0 {
+ b.question = b.question[colonIdx+2:]
+ }
+ raw = raw[nlIdx+1:]
+ }
+
+ // Split off notes section if present.
+ if notesIdx := strings.Index(raw, "\n\nNotes:"); notesIdx >= 0 {
+ b.answer = strings.TrimSpace(raw[:notesIdx])
+ notesRaw := raw[notesIdx+len("\n\nNotes:"):]
+ for _, noteLine := range strings.Split(notesRaw, "\n") {
+ noteLine = strings.TrimSpace(noteLine)
+ if strings.HasPrefix(noteLine, "- ") {
+ b.notes = append(b.notes, strings.TrimPrefix(noteLine, "- "))
+ }
+ }
+ } else {
+ b.answer = strings.TrimSpace(raw)
+ }
+
+ blocks = append(blocks, b)
+ }
+
+ return blocks
+}
+
+// formatQuestionAnswers parses the tool result and formats answers with
+// styling for display in the chat body.
+func formatQuestionAnswers(sty *styles.Styles, content string, width int) string {
+ if content == "" {
+ return ""
+ }
+
+ blocks := parseQuestionBlocks(content)
+ if len(blocks) == 0 {
+ return ""
+ }
+
+ var lines []string
+ for _, b := range blocks {
+ icon := sty.Tool.IconSuccess.Render()
+ answer := styleAnswer(sty, b.answer)
+
+ // Show question text in subtle style, answer on same line.
+ qText := sty.Tool.TodoStatusNote.Render(b.question)
+ line := fmt.Sprintf("%s %s %s", icon, qText, answer)
+ line = ansi.Truncate(line, width, "…")
+ lines = append(lines, line)
+
+ for _, note := range b.notes {
+ noteLine := sty.Tool.TodoStatusNote.Render(" ╰ " + note)
+ noteLine = ansi.Truncate(noteLine, width, "…")
+ lines = append(lines, noteLine)
+ }
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+// styleAnswer extracts the meaningful part of an answer string and styles it.
+func styleAnswer(sty *styles.Styles, answer string) string {
+ answer = strings.TrimSpace(answer)
+
+ switch {
+ case answer == "User answered: yes":
+ return sty.Tool.TodoCompletedIcon.Render("Yes")
+ case answer == "User answered: no":
+ return sty.Tool.StateCancelled.Render("No")
+ case strings.HasPrefix(answer, "User selected:"):
+ selected := strings.TrimPrefix(answer, "User selected: ")
+ selected = strings.Trim(selected, "[]\"")
+ selected = strings.ReplaceAll(selected, "\",\"", ", ")
+ return sty.Tool.ParamMain.Render(selected)
+ case strings.HasPrefix(answer, "User provided:"):
+ text := strings.TrimPrefix(answer, "User provided: ")
+ return sty.Tool.ParamMain.Render(text)
+ case answer == "User skipped this question":
+ return sty.Tool.StateCancelled.Render("Skipped")
+ default:
+ return answer
+ }
+}
diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go
index 961173f30b..6b0a6dfaa2 100644
--- a/internal/ui/chat/tools.go
+++ b/internal/ui/chat/tools.go
@@ -255,6 +255,8 @@ func NewToolMessageItem(
item = NewWebSearchToolMessageItem(sty, toolCall, result, canceled)
case tools.TodosToolName:
item = NewTodosToolMessageItem(sty, toolCall, result, canceled)
+ case tools.QuestionToolName:
+ item = NewQuestionToolMessageItem(sty, toolCall, result, canceled)
case tools.ReferencesToolName:
item = NewReferencesToolMessageItem(sty, toolCall, result, canceled)
case tools.LSPRestartToolName:
diff --git a/internal/ui/common/button.go b/internal/ui/common/button.go
index 73524c2b3f..c245fa063b 100644
--- a/internal/ui/common/button.go
+++ b/internal/ui/common/button.go
@@ -1,6 +1,7 @@
package common
import (
+ "fmt"
"strings"
"charm.land/lipgloss/v2"
@@ -15,15 +16,21 @@ type ButtonOpts struct {
UnderlineIndex int
// Selected indicates whether this button is currently selected
Selected bool
+ // Hovered indicates whether the mouse is hovering over the button
+ Hovered bool
// Padding inner horizontal padding defaults to 2 if this is 0
Padding int
}
// Button creates a button with an underlined character and selection state
func Button(t *styles.Styles, opts ButtonOpts) string {
- // Select style based on selection state
+ // Select style based on selection/hover state.
style := t.Button.Blurred
- if opts.Selected {
+ if opts.Selected && opts.Hovered {
+ style = t.Button.Focused.Bold(true)
+ } else if opts.Hovered {
+ style = t.Button.Hovered.Bold(true)
+ } else if opts.Selected {
style = t.Button.Focused
}
@@ -67,3 +74,44 @@ func ButtonGroup(t *styles.Styles, buttons []ButtonOpts, spacing string) string
return strings.Join(parts, spacing)
}
+
+// ButtonHitCompositor builds a lipgloss Compositor with one hit
+// layer per button, positioned horizontally at (x, y). Layer IDs
+// are "btn_0", "btn_1", etc. The spacing parameter must match
+// what was passed to ButtonGroup when rendering.
+func ButtonHitCompositor(sty *styles.Styles, opts []ButtonOpts, spacing string, x, y int) *lipgloss.Compositor {
+ if len(opts) == 0 {
+ return nil
+ }
+ if spacing == "" {
+ spacing = " "
+ }
+ spacingWidth := lipgloss.Width(spacing)
+ var layers []*lipgloss.Layer
+ bx := x
+ for i, o := range opts {
+ b := Button(sty, o)
+ w := lipgloss.Width(b)
+ hitStr := strings.Repeat(" ", w)
+ layers = append(layers, lipgloss.NewLayer(hitStr).X(bx).Y(y).ID(fmt.Sprintf("btn_%d", i)))
+ bx += w + spacingWidth
+ }
+ return lipgloss.NewCompositor(layers...)
+}
+
+// HitButtonIndex checks a compositor for a button hit and returns
+// the button index, or -1 if no button was hit.
+func HitButtonIndex(c *lipgloss.Compositor, x, y int) int {
+ if c == nil {
+ return -1
+ }
+ hit := c.Hit(x, y)
+ if hit.Empty() {
+ return -1
+ }
+ var idx int
+ if _, err := fmt.Sscanf(hit.ID(), "btn_%d", &idx); err != nil {
+ return -1
+ }
+ return idx
+}
diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go
index 88d6f1f643..90ba3cc684 100644
--- a/internal/ui/common/common.go
+++ b/internal/ui/common/common.go
@@ -4,6 +4,7 @@ import (
"fmt"
"image"
"os"
+ "strings"
tea "charm.land/bubbletea/v2"
"github.com/atotto/clipboard"
@@ -17,6 +18,39 @@ import (
// MaxAttachmentSize defines the maximum allowed size for file attachments (5 MB).
const MaxAttachmentSize = int64(5 * 1024 * 1024)
+// PlanReadyMarker is the sentinel the plan agent emits on its own line at the
+// end of its final response to signal that the plan is ready for execution.
+const PlanReadyMarker = ""
+
+// PlanReadyMarkerPresent reports whether text contains the plan-ready sentinel
+// on a line by itself. An own-line check (rather than a substring match) avoids
+// a false positive when the agent merely mentions the marker inside explanatory
+// prose, while still tolerating trailing whitespace or notes after it.
+func PlanReadyMarkerPresent(text string) bool {
+ for line := range strings.SplitSeq(text, "\n") {
+ if strings.TrimSpace(line) == PlanReadyMarker {
+ return true
+ }
+ }
+ return false
+}
+
+// StripPlanReadyMarker removes lines that consist solely of the plan-ready
+// sentinel (matching the detection rule of [PlanReadyMarkerPresent]) so the
+// marker never shows up in rendered chat output. Mentions of the marker
+// inside prose are left untouched.
+func StripPlanReadyMarker(text string) string {
+ lines := strings.Split(text, "\n")
+ kept := lines[:0]
+ for _, line := range lines {
+ if strings.TrimSpace(line) == PlanReadyMarker {
+ continue
+ }
+ kept = append(kept, line)
+ }
+ return strings.Join(kept, "\n")
+}
+
// AllowedImageTypes defines the permitted image file types.
var AllowedImageTypes = []string{".jpg", ".jpeg", ".png"}
diff --git a/internal/ui/common/common_test.go b/internal/ui/common/common_test.go
new file mode 100644
index 0000000000..7e6ff466ed
--- /dev/null
+++ b/internal/ui/common/common_test.go
@@ -0,0 +1,27 @@
+package common
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestPlanReadyMarkerPresent(t *testing.T) {
+ t.Parallel()
+
+ require.True(t, PlanReadyMarkerPresent("plan\n"+PlanReadyMarker))
+ require.True(t, PlanReadyMarkerPresent("plan\n "+PlanReadyMarker+" \ntrailing note"))
+ require.False(t, PlanReadyMarkerPresent("plan without marker"))
+ require.False(t, PlanReadyMarkerPresent("I will end with "+PlanReadyMarker+" when done."))
+}
+
+func TestStripPlanReadyMarker(t *testing.T) {
+ t.Parallel()
+
+ require.Equal(t, "plan", StripPlanReadyMarker("plan\n"+PlanReadyMarker))
+ require.Equal(t, "plan\nnote", StripPlanReadyMarker("plan\n "+PlanReadyMarker+" \nnote"))
+ // Mentions inside prose are left untouched.
+ prose := "I will end with " + PlanReadyMarker + " when done."
+ require.Equal(t, prose, StripPlanReadyMarker(prose))
+ require.Equal(t, "no marker here", StripPlanReadyMarker("no marker here"))
+}
diff --git a/internal/ui/common/markdown.go b/internal/ui/common/markdown.go
index a2a2d3d22a..9b9dce23a0 100644
--- a/internal/ui/common/markdown.go
+++ b/internal/ui/common/markdown.go
@@ -19,7 +19,7 @@ func init() {
formatters.Register(formatterName, xchroma.Formatter(zero, nil))
}
-// mdCacheMu guards mdCache and quietMDCache.
+// mdCacheMu guards mdCache, quietMDCache, and planMDCache.
//
// Lock ordering: when both mdCacheMu and rendererLocksMu are
// needed (only in InvalidateMarkdownRendererCache), acquire
@@ -29,6 +29,7 @@ var (
mdCacheMu sync.Mutex
mdCache = map[int]*glamour.TermRenderer{}
quietMDCache = map[int]*glamour.TermRenderer{}
+ planMDCache = map[int]*glamour.TermRenderer{}
)
// MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with
@@ -76,6 +77,26 @@ func QuietMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer
return r
}
+// PlanMarkdownRenderer returns a glamour [glamour.TermRenderer] that keeps the
+// rich markdown colors but paints the plan-card background under every
+// primitive, for the final plan message card. Renderers are memoized per width
+// and shared across callers. Same concurrency contract as [MarkdownRenderer]:
+// serialize via [LockMarkdownRenderer].
+func PlanMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
+ mdCacheMu.Lock()
+ defer mdCacheMu.Unlock()
+ if r, ok := planMDCache[width]; ok {
+ return r
+ }
+ r, _ := glamour.NewTermRenderer(
+ glamour.WithStyles(sty.PlanMarkdown),
+ glamour.WithWordWrap(width),
+ glamour.WithChromaFormatter(formatterName),
+ )
+ planMDCache[width] = r
+ return r
+}
+
// InvalidateMarkdownRendererCache drops every cached renderer
// AND every per-renderer mutex in a single atomic critical
// section so the two maps cannot disagree mid-toggle. Call this
@@ -96,6 +117,7 @@ func InvalidateMarkdownRendererCache() {
mdCache = map[int]*glamour.TermRenderer{}
quietMDCache = map[int]*glamour.TermRenderer{}
+ planMDCache = map[int]*glamour.TermRenderer{}
rendererLocks = map[*glamour.TermRenderer]*sync.Mutex{}
}
diff --git a/internal/ui/dialog/inline_editor.go b/internal/ui/dialog/inline_editor.go
new file mode 100644
index 0000000000..c45da2ca3e
--- /dev/null
+++ b/internal/ui/dialog/inline_editor.go
@@ -0,0 +1,69 @@
+package dialog
+
+import (
+ "charm.land/bubbles/v2/key"
+ tea "charm.land/bubbletea/v2"
+ uv "github.com/charmbracelet/ultraviolet"
+)
+
+// InlineEditor is the interface for components that replace the
+// textarea in the editor area. The UI model holds a single
+// InlineEditor field and routes keys, rendering, layout, and help
+// through it without knowing the concrete type.
+type InlineEditor interface {
+ // HandleKey processes a key event. Returns true when the user
+ // has finished interacting (answer submitted or dismissed),
+ // plus an optional tea.Cmd.
+ HandleKey(msg tea.KeyPressMsg) (done bool, cmd tea.Cmd)
+
+ // ShortHelp returns key bindings for the status bar.
+ ShortHelp() []key.Binding
+
+ // Height returns the number of content lines for layout.
+ Height() int
+
+ // Draw renders the component onto the screen within the given
+ // area. Returns the cursor position relative to the area's
+ // top-left, or nil if no cursor should be shown.
+ Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor
+
+ // HeightChanged reports whether the height changed since the
+ // last call, indicating the UI should recalculate layout.
+ HeightChanged() bool
+
+ // SetFocused tells the component whether the editor area is
+ // focused.
+ SetFocused(focused bool)
+}
+
+// CmdOnDone is an optional interface for inline editors that need to
+// run a tea.Cmd when dismissed via mouse. The UI checks for this after
+// HandleMouseClick returns done=true and queues the returned cmd.
+type CmdOnDone interface {
+ PendingCmd() tea.Cmd
+}
+
+// MouseClickableEditor is an optional interface for inline editors
+// that handle mouse clicks and hover highlighting. The UI
+// type-asserts for this before routing click and motion events.
+type MouseClickableEditor interface {
+ InlineEditor
+ // HandleMouseClick processes a mouse click at the given screen
+ // coordinates. Returns done=true when the editor has completed
+ // (answer submitted or dismissed), and handled=true if the click
+ // was consumed (even if not done).
+ HandleMouseClick(x, y int) (done bool, handled bool)
+ // SetHover updates the current mouse position for hover
+ // highlighting. Called on every MouseMotionMsg while the
+ // editor is active.
+ SetHover(x, y int)
+}
+
+// PasteableEditor is an optional interface for inline editors
+// that contain text areas and can receive paste events. The UI
+// type-asserts for this before routing tea.PasteMsg.
+type PasteableEditor interface {
+ // HandlePaste processes a paste message. Returns an optional
+ // tea.Cmd for side effects (e.g., focus commands).
+ HandlePaste(msg tea.PasteMsg) tea.Cmd
+}
diff --git a/internal/ui/dialog/plan_handoff_inline.go b/internal/ui/dialog/plan_handoff_inline.go
new file mode 100644
index 0000000000..d1b8684525
--- /dev/null
+++ b/internal/ui/dialog/plan_handoff_inline.go
@@ -0,0 +1,154 @@
+package dialog
+
+import (
+ "image"
+
+ "charm.land/bubbles/v2/key"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ uv "github.com/charmbracelet/ultraviolet"
+)
+
+// PlanHandoffInline is a small inline prompt rendered at the bottom of the
+// editor area when the plan agent signals it is ready for execution.
+// It replaces the textarea temporarily, asking the user to switch to code
+// mode or keep editing the plan.
+type PlanHandoffInline struct {
+ com *common.Common
+ selectedNo bool // false = "Switch to code" selected (the default)
+ focused bool
+ compositor *lipgloss.Compositor
+ hoverX int
+ hoverY int
+
+ // OnConfirm is called when the user confirms switching to code mode.
+ // The returned tea.Cmd is queued by the UI to perform the switch and
+ // start the coder agent.
+ OnConfirm func() tea.Cmd
+
+ pendingCmd tea.Cmd // set during mouse confirmation; retrieved via PendingCmd
+
+ keyLeftRight key.Binding
+ keyEnter key.Binding
+ keyYes key.Binding
+ keyNo key.Binding
+ keyClose key.Binding
+}
+
+var _ InlineEditor = (*PlanHandoffInline)(nil)
+
+// NewPlanHandoffInline creates an inline plan handoff prompt. Wire
+// OnConfirm before setting it as the active inline editor.
+func NewPlanHandoffInline(com *common.Common) *PlanHandoffInline {
+ return &PlanHandoffInline{
+ com: com,
+ selectedNo: false, // default: "Switch to code" is highlighted
+ keyLeftRight: key.NewBinding(
+ key.WithKeys("left", "right"),
+ key.WithHelp("←/→", "switch"),
+ ),
+ keyEnter: key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "confirm"),
+ ),
+ keyYes: key.NewBinding(
+ key.WithKeys("y", "Y"),
+ key.WithHelp("y", "switch to code"),
+ ),
+ keyNo: key.NewBinding(
+ key.WithKeys("n", "N", "esc"),
+ key.WithHelp("n/esc", "keep editing"),
+ ),
+ keyClose: CloseKey,
+ }
+}
+
+// HandleKey processes a key press. Returns done=true when the user has
+// made a choice. Returns a tea.Cmd to perform the mode switch when the
+// user confirms.
+func (p *PlanHandoffInline) HandleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
+ switch {
+ case key.Matches(msg, p.keyNo), key.Matches(msg, p.keyClose):
+ return true, nil
+ case key.Matches(msg, p.keyLeftRight):
+ p.selectedNo = !p.selectedNo
+ return false, nil
+ case key.Matches(msg, p.keyEnter):
+ if !p.selectedNo {
+ return true, p.runConfirm()
+ }
+ return true, nil
+ case key.Matches(msg, p.keyYes):
+ return true, p.runConfirm()
+ }
+ return false, nil
+}
+
+func (p *PlanHandoffInline) runConfirm() tea.Cmd {
+ if p.OnConfirm != nil {
+ cmd := p.OnConfirm()
+ p.pendingCmd = cmd
+ return cmd
+ }
+ return nil
+}
+
+// PendingCmd returns a cmd queued during mouse confirmation.
+// The UI checks this via the CmdOnDone interface after a mouse-click dismissal.
+func (p *PlanHandoffInline) PendingCmd() tea.Cmd { return p.pendingCmd }
+
+// Height returns 3: question line + blank line + button line.
+func (p *PlanHandoffInline) Height() int { return 3 }
+
+// Draw renders the inline prompt at the given screen area.
+func (p *PlanHandoffInline) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+ y := area.Min.Y
+
+ iconPrompt := questionIconPrompt(p.com.Styles, p.focused)
+ qText := iconPrompt + p.com.Styles.Editor.QuestionUnselected.Render("Plan is ready. Switch to code mode?")
+ y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), qText)
+ y++ // blank
+
+ buttonOptsList := []common.ButtonOpts{
+ {Text: "Switch to code", Selected: !p.selectedNo, Padding: 3, UnderlineIndex: -1},
+ {Text: "Keep editing", Selected: p.selectedNo, Padding: 3, UnderlineIndex: -1},
+ }
+ hoveredBtn := common.HitButtonIndex(p.compositor, p.hoverX, p.hoverY)
+ buttonOptsList[0].Hovered = hoveredBtn == 0
+ buttonOptsList[1].Hovered = hoveredBtn == 1
+ p.compositor = common.ButtonHitCompositor(p.com.Styles, buttonOptsList, " ", area.Min.X, y)
+ buttons := common.ButtonGroup(p.com.Styles, buttonOptsList, " ")
+ drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), buttons)
+
+ return nil
+}
+
+// HeightChanged always returns false — Height is constant.
+func (p *PlanHandoffInline) HeightChanged() bool { return false }
+
+// SetFocused updates the focus state (affects icon styling).
+func (p *PlanHandoffInline) SetFocused(focused bool) { p.focused = focused }
+
+// ShortHelp returns key bindings for the status bar.
+func (p *PlanHandoffInline) ShortHelp() []key.Binding {
+ return []key.Binding{p.keyLeftRight, p.keyEnter, p.keyYes, p.keyNo}
+}
+
+// SetHover implements MouseClickableEditor.
+func (p *PlanHandoffInline) SetHover(x, y int) { p.hoverX = x; p.hoverY = y }
+
+// HandleMouseClick implements MouseClickableEditor. Clicking "Switch to code"
+// stores the confirm cmd via PendingCmd; clicking "Keep editing" cancels.
+func (p *PlanHandoffInline) HandleMouseClick(x, y int) (bool, bool) {
+ switch common.HitButtonIndex(p.compositor, x, y) {
+ case 0: // Switch to code
+ p.selectedNo = false
+ p.runConfirm()
+ return true, true
+ case 1: // Keep editing
+ p.selectedNo = true
+ return true, true
+ }
+ return false, false
+}
diff --git a/internal/ui/dialog/question_choice_base.go b/internal/ui/dialog/question_choice_base.go
new file mode 100644
index 0000000000..b6837d30f9
--- /dev/null
+++ b/internal/ui/dialog/question_choice_base.go
@@ -0,0 +1,600 @@
+package dialog
+
+import (
+ "fmt"
+ "image"
+ "strconv"
+ "strings"
+
+ "charm.land/bubbles/v2/key"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/question"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// choiceListMaxWidth is the maximum content width for choice
+// question components.
+const choiceListMaxWidth = 120
+
+// questionIconPrompt returns the themed question icon based on
+// focus state. Shared by all question component types.
+func questionIconPrompt(sty *styles.Styles, focused bool) string {
+ if focused {
+ return sty.Editor.PromptQuestionIconFocused.Render()
+ }
+ return sty.Editor.PromptQuestionIconBlurred.Render()
+}
+
+// choiceList is the shared base for single-choice and multi-choice
+// question components. It embeds questionEditor for fill-in, notes,
+// and editor handling. Concrete types embed it and only implement
+// selection semantics.
+type choiceList struct {
+ questionEditor
+ Request question.Question
+
+ cursorIdx int
+ scrollOffset int // lines scrolled past the top of the viewport
+ focused bool
+ lastWidth int
+ choiceCompositor *lipgloss.Compositor
+ suppressScroll bool // skip scroll clamping after mouse click
+ hoverX, hoverY int // current mouse position for hover highlight
+ hoveredChoice int // choice index under mouse, or -1
+ mouseActive bool // true when last interaction was mouse (hover mode)
+
+ // styleFillInAsSelected controls whether non-empty fill-in text
+ // gets the selected (pink) style. True for single-choice where
+ // the fill-in IS the answer; false for multi-choice.
+ styleFillInAsSelected bool
+
+ keyUp key.Binding
+ keyDown key.Binding
+ keyClose key.Binding
+}
+
+// numberKeyIndex returns the zero-based choice index for a number
+// key press (1-9), or -1 if the key is not a valid shortcut for
+// the current choices.
+func (c *choiceList) numberKeyIndex(msg tea.KeyPressMsg) int {
+ if len(msg.Text) != 1 {
+ return -1
+ }
+ n, err := strconv.Atoi(msg.Text)
+ if err != nil || n < 1 || n > len(c.Request.Choices) {
+ return -1
+ }
+ return n - 1
+}
+
+// newChoiceList creates a choiceList with a configured fill-in
+// textarea and navigation bindings.
+func newChoiceList(sty *styles.Styles, req question.Question) choiceList {
+ return choiceList{
+ questionEditor: newQuestionEditor(sty),
+ Request: req,
+ hoveredChoice: -1,
+ hoverX: -1,
+ hoverY: -1,
+ keyUp: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑", "up")),
+ keyDown: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓", "down")),
+ keyClose: CloseKey,
+ }
+}
+
+func (c *choiceList) itemCount() int {
+ return len(c.Request.Choices) + 1 // +1 for fill-in
+}
+
+func (c *choiceList) isFillIn() bool {
+ return c.cursorIdx == len(c.Request.Choices)
+}
+
+// moveUp moves the cursor up, wrapping around. Closes any active
+// note editor since the note context changes with the cursor.
+func (c *choiceList) moveUp() {
+ c.mouseActive = false
+ c.fillIn.Blur()
+ if c.activeNoteKey != "" {
+ c.closeNote(c.noteKey())
+ }
+ c.cursorIdx--
+ if c.cursorIdx < 0 {
+ c.cursorIdx = c.itemCount() - 1
+ }
+}
+
+// moveDown moves the cursor down, wrapping around. Closes any
+// active note editor since the note context changes with the cursor.
+func (c *choiceList) moveDown() {
+ c.mouseActive = false
+ c.fillIn.Blur()
+ if c.activeNoteKey != "" {
+ c.closeNote(c.noteKey())
+ }
+ c.cursorIdx++
+ if c.cursorIdx >= c.itemCount() {
+ c.cursorIdx = 0
+ }
+}
+
+// handleFillInKey processes keys when the fill-in textarea is
+// focused. Returns (cmd, handled). When handled is true the
+// caller should not process the key further.
+func (c *choiceList) handleFillInKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
+ switch {
+ case key.Matches(msg, c.keyClose):
+ c.fillIn.Blur()
+ return nil, true
+ case key.Matches(msg, c.navUp):
+ c.moveUp()
+ if c.isFillIn() {
+ c.fillIn.Focus()
+ return c.fillIn.Focus(), true
+ }
+ return nil, true
+ case key.Matches(msg, c.navDown):
+ c.moveDown()
+ if c.isFillIn() {
+ c.fillIn.Focus()
+ return c.fillIn.Focus(), true
+ }
+ return nil, true
+ default:
+ var cmd tea.Cmd
+ c.fillIn, cmd = c.fillIn.Update(msg)
+ return cmd, true
+ }
+}
+
+// handleNavKey processes up/down navigation keys when the
+// fill-in is NOT focused. Returns true if the key was consumed.
+func (c *choiceList) handleNavKey(msg tea.KeyPressMsg) bool {
+ switch {
+ case key.Matches(msg, c.keyUp):
+ c.moveUp()
+ if c.isFillIn() {
+ c.fillIn.Focus()
+ }
+ return true
+ case key.Matches(msg, c.keyDown):
+ c.moveDown()
+ if c.isFillIn() {
+ c.fillIn.Focus()
+ }
+ return true
+ }
+ return false
+}
+
+// noteKey returns the map key for the currently focused item's
+// note. Choices use their ID; the question itself uses "_question".
+func (c *choiceList) noteKey() string {
+ if c.isFillIn() || c.cursorIdx >= len(c.Request.Choices) {
+ return "_question"
+ }
+ return c.Request.Choices[c.cursorIdx].ID
+}
+
+// contentLine is one visual row of the choice list. text is the
+// pre-rendered, pre-styled string for the row. fillInRow marks the
+// first row of the focused fill-in textarea, where the hardware
+// cursor is placed. noteRow marks the first row of the focused
+// note editor.
+type contentLine struct {
+ text string
+ fillInRow bool
+ noteRow bool
+ cursorItem bool // belongs to the currently selected item
+ choiceIdx int // zero-based choice index, or -1 if not a choice row
+}
+
+// newContentLine creates a contentLine with choiceIdx initialized
+// to -1 so non-choice rows don't accidentally match choice_0.
+func newContentLine(text string) contentLine {
+ return contentLine{text: text, choiceIdx: -1}
+}
+
+// sectionHeight returns the visual line count of a text block
+// wrapped at width.
+func sectionHeight(text string, width int) int {
+ if text == "" {
+ return 0
+ }
+ return strings.Count(ansi.Wrap(text, width, ""), "\n") + 1
+}
+
+// wrapIndent wraps text at width and prefixes every continuation
+// line with indent so multi-line content aligns under the first
+// line's content rather than flush left.
+func wrapIndent(text string, width int, indent string) string {
+ wrapped := ansi.Wrap(text, width, "")
+ lines := strings.Split(wrapped, "\n")
+ for i := 1; i < len(lines); i++ {
+ lines[i] = indent + lines[i]
+ }
+ return strings.Join(lines, "\n")
+}
+
+// drawStyledText blits an ANSI-styled string into area and returns
+// the number of visual lines it occupies.
+func drawStyledText(scr uv.Screen, area uv.Rectangle, text string) int {
+ if text == "" {
+ return 0
+ }
+ uv.NewStyledString(text).Draw(scr, area)
+ return strings.Count(text, "\n") + 1
+}
+
+// buildLines renders the entire choice list into a flat slice of
+// rows. This is the single source of truth: height is len(lines),
+// scrolling is index math over the slice, and drawing blits a
+// window of it. itemFn renders a choice's label row(s) as a string.
+//
+// The final row is always a blank line, giving the list one line of
+// bottom padding as real content rather than a phantom offset.
+func (c *choiceList) buildLines(innerWidth int, fillInPrefix string, itemFn choiceItemRenderer) []contentLine {
+ bodyStyle := c.Styles.Editor.QuestionBody
+ barActive := c.Styles.Editor.QuestionCursorBar.Render("┃ ")
+ const barInactive = " "
+
+ var lines []contentLine
+ push := func(text string, flags ...bool) {
+ cl := newContentLine(text)
+ if len(flags) > 0 {
+ cl.fillInRow = flags[0]
+ }
+ if len(flags) > 1 {
+ cl.cursorItem = flags[1]
+ }
+ // Split multi-line strings into one row each.
+ for ln := range strings.SplitSeq(text, "\n") {
+ row := cl
+ row.text = ln
+ lines = append(lines, row)
+ }
+ }
+
+ // Question header + blank separator.
+ icon := c.iconPrompt()
+ iconWidth := lipgloss.Width(icon)
+ qIndent := strings.Repeat(" ", iconWidth)
+ push(icon + c.Styles.Editor.QuestionUnselected.Render(wrapIndent(c.Request.Text, innerWidth-iconWidth, qIndent)))
+ push("")
+
+ // Optional markdown description + blank separator.
+ if c.Request.Description != "" {
+ push(c.renderDescription(innerWidth))
+ push("")
+ }
+
+ // Choices: label row(s), optional wrapped description, note, blank.
+ for i, ch := range c.Request.Choices {
+ active := i == c.cursorIdx && !c.mouseActive
+ hovered := i == c.hoveredChoice && c.mouseActive
+ bar := barInactive
+ if active || hovered {
+ bar = barActive
+ }
+ content := itemFn(i, ch, active, innerWidth)
+ // Prepend bar to every line so continuation lines also
+ // show the selection indicator.
+ for j, ln := range strings.Split(content, "\n") {
+ b := bar
+ if j > 0 && !active {
+ b = barInactive
+ }
+ lines = append(lines, contentLine{text: b + ln, cursorItem: active, choiceIdx: i})
+ }
+
+ if ch.Description != "" {
+ descContent := bodyStyle.Render(wrapIndent(ch.Description, innerWidth-lipgloss.Width(bar), ""))
+ for j, ln := range strings.Split(descContent, "\n") {
+ b := bar
+ if j > 0 && !active {
+ b = barInactive
+ }
+ lines = append(lines, contentLine{text: b + ln, cursorItem: active, choiceIdx: i})
+ }
+ }
+
+ // Inline note editor or saved note for this choice.
+ c.drawNote(&lines, innerWidth, bar, barInactive, ch.ID, active)
+
+ // Blank separator — tag with current choice index so it's
+ // part of the clickable/hoverable zone.
+ lines = append(lines, contentLine{text: "", choiceIdx: i})
+ }
+
+ // Fill-in: live textarea when focused, otherwise placeholder.
+ // Show active gutter only when focused or has content.
+ hasFillInText := strings.TrimSpace(c.fillIn.Value()) != ""
+ fillActive := c.isFillIn() && (c.fillIn.Focused() || hasFillInText)
+ fillPrefix := c.Styles.Editor.QuestionBody.Render("> ")
+ if c.styleFillInAsSelected && hasFillInText {
+ fillPrefix = c.Styles.Editor.QuestionSelected.Render("> ")
+ }
+ fillBar := barInactive
+ if fillActive {
+ fillBar = barActive
+ }
+ linesBeforeFillIn := len(lines)
+ c.drawFillIn(&lines, innerWidth, fillBar, barInactive, fillPrefix, c.isFillIn(), false)
+
+ // Tag fill-in rows with the fill-in item index so clicks can
+ // navigate to it.
+ fillInIdx := len(c.Request.Choices)
+ for i := linesBeforeFillIn; i < len(lines); i++ {
+ lines[i].choiceIdx = fillInIdx
+ }
+
+ // Trailing blank line for bottom padding.
+ push("")
+
+ return lines
+}
+
+// renderDescription renders the markdown description at width.
+func (c *choiceList) renderDescription(width int) string {
+ r := common.MarkdownRenderer(c.Styles, width)
+ mu := common.LockMarkdownRenderer(r)
+ mu.Lock()
+ out, err := r.Render(c.Request.Description)
+ mu.Unlock()
+ if err != nil {
+ return c.Request.Description
+ }
+ return strings.TrimSuffix(out, "\n")
+}
+
+// choiceItemRenderer renders a choice's label content as a string.
+// The bar prefix is applied by buildLines so that continuation
+// lines also receive it. innerWidth is the available content
+// width for this particular render pass (may differ between
+// overflow-test and final render).
+type choiceItemRenderer func(index int, choice question.Choice, active bool, innerWidth int) string
+
+// height returns the total visual height at the given width. It is
+// len(buildLines), the single source of truth for layout.
+func (c *choiceList) height(width int) int {
+ w := c.lastWidth
+ if w <= 0 {
+ w = width
+ }
+ innerWidth := min(w-4, choiceListMaxWidth)
+ return len(c.buildLines(innerWidth, "> ", func(int, question.Choice, bool, int) string {
+ return "x" // single-line placeholder; only count matters
+ }))
+}
+
+func (c *choiceList) heightChanged() bool {
+ return false // height is deterministic
+}
+
+func (c *choiceList) setFocused(focused bool) {
+ c.focused = focused
+}
+
+// setHover updates the hover position and resolves which choice
+// is under the cursor using the compositor.
+func (c *choiceList) setHover(x, y int) {
+ c.hoverX = x
+ c.hoverY = y
+ c.mouseActive = true
+ c.hoveredChoice = -1
+ if c.choiceCompositor == nil {
+ return
+ }
+ hit := c.choiceCompositor.Hit(x, y)
+ if !hit.Empty() {
+ var idx int
+ if _, err := fmt.Sscanf(hit.ID(), "choice_%d", &idx); err == nil {
+ c.hoveredChoice = idx
+ }
+ }
+}
+
+// iconPrompt returns the themed question icon based on focus.
+func (c *choiceList) iconPrompt() string {
+ return questionIconPrompt(c.Styles, c.focused)
+}
+
+// drawContent renders the choice list with scroll support. It
+// builds the full line list, clamps the scroll offset to keep the
+// cursor visible, then blits the visible window. Returns the
+// hardware cursor position, or nil.
+func (c *choiceList) drawContent(scr uv.Screen, area uv.Rectangle, fillInPrefix string, itemFn choiceItemRenderer) *tea.Cursor {
+ c.lastWidth = area.Dx()
+ viewport := area.Dy()
+
+ // Build lines at the narrow width first to test overflow.
+ // If content fits without a scrollbar, rebuild at the wider
+ // width so text uses the full available space.
+ contentWidth := area.Dx()
+ innerNarrow := min(contentWidth-1-4, choiceListMaxWidth)
+ innerWide := min(contentWidth-4, choiceListMaxWidth)
+
+ lines := c.buildLines(innerNarrow, fillInPrefix, itemFn)
+ overflow := viewport > 0 && len(lines) > viewport
+ if !overflow && innerWide != innerNarrow {
+ lines = c.buildLines(innerWide, fillInPrefix, itemFn)
+ overflow = viewport > 0 && len(lines) > viewport
+ if overflow {
+ // Adding the scrollbar column caused wrapping that
+ // created overflow. Stick with the narrow width.
+ lines = c.buildLines(innerNarrow, fillInPrefix, itemFn)
+ }
+ }
+
+ if overflow {
+ contentWidth--
+ }
+ c.clampScroll(lines, viewport)
+
+ // Blit the visible window.
+ var cur *tea.Cursor
+ for screenRow := range viewport {
+ idx := c.scrollOffset + screenRow
+ if idx >= len(lines) {
+ break
+ }
+ ln := lines[idx]
+ y := area.Min.Y + screenRow
+ if ln.text != "" {
+ uv.NewStyledString(ln.text).Draw(scr, image.Rect(area.Min.X, y, area.Min.X+contentWidth, y+1))
+ }
+ if ln.fillInRow {
+ fillPrefix := c.Styles.Editor.QuestionBody.Render("> ")
+ if tc := c.fillInCursor(screenRow, area.Min.X, lipgloss.Width(fillPrefix)); tc != nil {
+ cur = tc
+ }
+ }
+ if ln.noteRow {
+ const notePrefix = "> "
+ if tc := c.noteCursor(screenRow, area.Min.X, lipgloss.Width(notePrefix)); tc != nil {
+ cur = tc
+ }
+ }
+ }
+
+ // Scrollbar.
+ if overflow {
+ sb := common.Scrollbar(c.Styles, viewport, len(lines), viewport, c.scrollOffset)
+ if sb != "" {
+ x := area.Max.X - 1
+ uv.NewStyledString(sb).Draw(scr, image.Rect(x, area.Min.Y, x+1, area.Min.Y+viewport))
+ }
+ }
+
+ // Build hit layers for choice rows.
+ c.buildChoiceCompositor(lines, area, contentWidth)
+
+ return cur
+}
+
+// buildChoiceCompositor creates hit layers for each visible choice
+// row so that mouse clicks can select choices directly. Each choice
+// gets a single layer spanning all its visible rows.
+func (c *choiceList) buildChoiceCompositor(lines []contentLine, area uv.Rectangle, contentWidth int) {
+ // Collect the screen-row range for each choice index.
+ type rowRange struct{ min, max int }
+ ranges := make(map[int]*rowRange)
+ for screenRow := range area.Dy() {
+ idx := c.scrollOffset + screenRow
+ if idx >= len(lines) {
+ break
+ }
+ ln := lines[idx]
+ if ln.choiceIdx < 0 {
+ continue
+ }
+ r, ok := ranges[ln.choiceIdx]
+ if !ok {
+ r = &rowRange{min: screenRow, max: screenRow}
+ ranges[ln.choiceIdx] = r
+ } else {
+ if screenRow < r.min {
+ r.min = screenRow
+ }
+ if screenRow > r.max {
+ r.max = screenRow
+ }
+ }
+ }
+
+ var layers []*lipgloss.Layer
+ for choiceIdx, r := range ranges {
+ height := r.max - r.min + 1
+ hitStr := strings.Repeat(strings.Repeat(" ", contentWidth)+"\n", height-1) + strings.Repeat(" ", contentWidth)
+ y := area.Min.Y + r.min
+ layers = append(layers, lipgloss.NewLayer(hitStr).X(area.Min.X).Y(y).ID(fmt.Sprintf("choice_%d", choiceIdx)))
+ }
+ if len(layers) > 0 {
+ c.choiceCompositor = lipgloss.NewCompositor(layers...)
+ } else {
+ c.choiceCompositor = nil
+ }
+}
+
+// clampScroll keeps the cursor item visible using a sliding
+// window: the cursor moves freely within the visible region and
+// only pushes the window when it reaches an edge. Going down pushes
+// the bottom; going up pushes the top until the start (header and
+// description) comes back into view.
+func (c *choiceList) clampScroll(lines []contentLine, viewport int) {
+ if c.suppressScroll {
+ c.suppressScroll = false
+ return
+ }
+ limit := max(0, len(lines)-viewport)
+ if limit == 0 {
+ c.scrollOffset = 0
+ return
+ }
+
+ // Row range of the cursor item.
+ cursorTop, cursorBottom := -1, -1
+ for i, ln := range lines {
+ if ln.cursorItem {
+ if cursorTop < 0 {
+ cursorTop = i
+ }
+ cursorBottom = i
+ }
+ }
+ if cursorTop < 0 {
+ c.scrollOffset = min(max(0, c.scrollOffset), limit)
+ return
+ }
+
+ // Keep one line below the cursor visible (trailing pad on the
+ // last item, a separator otherwise) so the selection is never
+ // flush against the bottom edge.
+ below := min(cursorBottom+1, len(lines)-1)
+
+ // On the first selectable item, prefer the top so the header
+ // and description (nothing selectable sits above them) come
+ // into view.
+ if c.cursorIdx == 0 {
+ c.scrollOffset = 0
+ }
+ // Push the window down if the cursor's bottom fell below it.
+ if below >= c.scrollOffset+viewport {
+ c.scrollOffset = below - viewport + 1
+ }
+ // Push the window up if the cursor's top rose above it.
+ if cursorTop < c.scrollOffset {
+ c.scrollOffset = cursorTop
+ }
+
+ c.scrollOffset = min(max(0, c.scrollOffset), limit)
+}
+
+// handleFillInFocused processes keys when the fill-in textarea is
+// focused. onClose is called for the close key, onDone for the
+// done key. Returns (done, cmd, handled). When handled is false
+// the caller should process the key itself.
+func (c *choiceList) handleFillInFocused(
+ msg tea.KeyPressMsg,
+ doneKey key.Binding,
+ onClose func() (bool, tea.Cmd),
+ onDone func() (bool, tea.Cmd),
+) (bool, tea.Cmd, bool) {
+ if !c.isFillIn() || !c.fillIn.Focused() {
+ return false, nil, false
+ }
+ if key.Matches(msg, c.keyClose) {
+ done, cmd := onClose()
+ return done, cmd, true
+ }
+ if key.Matches(msg, doneKey) {
+ done, cmd := onDone()
+ return done, cmd, true
+ }
+ cmd, handled := c.handleFillInKey(msg)
+ return false, cmd, handled
+}
diff --git a/internal/ui/dialog/question_confirm.go b/internal/ui/dialog/question_confirm.go
new file mode 100644
index 0000000000..e41fdb750c
--- /dev/null
+++ b/internal/ui/dialog/question_confirm.go
@@ -0,0 +1,290 @@
+package dialog
+
+import (
+ "fmt"
+ "image"
+ "strings"
+
+ "charm.land/bubbles/v2/key"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/question"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// ConfirmComponent is the confirmation tab shown at the end of a
+// multi-question batch. It displays an answer summary and lets the
+// user confirm or go back to editing. Implements questionResponder
+// so QuestionForm treats it like any other tab.
+type ConfirmComponent struct {
+ Styles *styles.Styles
+ Title string
+ Description string
+ QuestionLabels []string
+ QuestionRequests []question.Question
+ Answers []*question.Answer
+ confirmYes bool
+
+ keyLeft key.Binding
+ keyRight key.Binding
+ keyEnter key.Binding
+ keyClose key.Binding
+
+ focused bool
+ lastWidth int
+ compositor *lipgloss.Compositor
+ hoverX int
+ hoverY int
+
+ // OnConfirm is called when the user confirms.
+ OnConfirm func()
+ // OnReject is called when the user says "not yet".
+ OnReject func()
+}
+
+// NewConfirmComponent creates a new confirmation component.
+func NewConfirmComponent(sty *styles.Styles, title, description string, labels []string, requests []question.Question, answers []*question.Answer) *ConfirmComponent {
+ if title == "" || title == "Confirm" {
+ title = "Ready to go?"
+ }
+ return &ConfirmComponent{
+ Styles: sty,
+ Title: title,
+ Description: description,
+ QuestionLabels: labels,
+ QuestionRequests: requests,
+ Answers: answers,
+ confirmYes: true,
+ keyLeft: key.NewBinding(key.WithKeys("left"), key.WithHelp("←/→", "switch")),
+ keyRight: key.NewBinding(key.WithKeys("right"), key.WithHelp("←/→", "switch")),
+ keyEnter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
+ keyClose: CloseKey,
+ }
+}
+
+// HandleKey processes input on the confirm tab. Returns true when
+// the user has confirmed submission. Tab/shift+tab are NOT handled
+// here; QuestionForm intercepts them for tab navigation.
+func (c *ConfirmComponent) HandleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
+ switch {
+ case key.Matches(msg, c.keyLeft), key.Matches(msg, c.keyRight):
+ c.confirmYes = !c.confirmYes
+ return false, nil
+ case key.Matches(msg, c.keyEnter):
+ if c.confirmYes {
+ if c.OnConfirm != nil {
+ c.OnConfirm()
+ }
+ return true, nil
+ }
+ if c.OnReject != nil {
+ c.OnReject()
+ }
+ return false, nil
+ case key.Matches(msg, c.keyClose):
+ if c.OnReject != nil {
+ c.OnReject()
+ }
+ return false, nil
+ }
+ return false, nil
+}
+
+// Response returns an empty response. The confirm tab doesn't
+// produce a question answer; it controls form submission.
+func (c *ConfirmComponent) Response() question.Answer {
+ return question.Answer{}
+}
+
+// ShortHelp returns key bindings for the status bar.
+func (c *ConfirmComponent) ShortHelp() []key.Binding {
+ return []key.Binding{c.keyLeft, c.keyEnter, c.keyClose}
+}
+
+// unansweredCount returns how many questions have no meaningful answer.
+func (c *ConfirmComponent) unansweredCount() int {
+ n := 0
+ for _, ans := range c.Answers {
+ if ans == nil || (len(ans.SelectedIDs) == 0 && ans.FillInText == "" && ans.Yes == nil) {
+ n++
+ }
+ }
+ return n
+}
+
+// Height returns the visual height of the confirm content.
+func (c *ConfirmComponent) Height() int {
+ w := c.lastWidth
+ if w <= 0 {
+ w = choiceListMaxWidth
+ }
+ iconPrompt := questionIconPrompt(c.Styles, c.focused)
+ h := sectionHeight(c.Title, w-lipgloss.Width(iconPrompt)) // title
+ h++ // blank
+ if c.Description != "" {
+ r := common.MarkdownRenderer(c.Styles, w)
+ mu := common.LockMarkdownRenderer(r)
+ mu.Lock()
+ out, err := r.Render(c.Description)
+ mu.Unlock()
+ if err == nil {
+ out = strings.TrimSuffix(out, "\n")
+ h += strings.Count(out, "\n") + 1
+ } else {
+ h += sectionHeight(c.Description, w)
+ }
+ h++ // blank
+ }
+ h += len(c.QuestionLabels) // one bullet per question
+ h++ // blank
+ if c.unansweredCount() > 0 {
+ h++ // warning line
+ h++ // blank after warning
+ }
+ h++ // buttons
+ h++ // bottom margin
+ return h
+}
+
+// Draw renders the confirmation content.
+func (c *ConfirmComponent) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+ c.lastWidth = area.Dx()
+ y := area.Min.Y
+
+ // Title with ? icon prompt, using confirm style.
+ iconPrompt := questionIconPrompt(c.Styles, c.focused)
+ qText := iconPrompt + c.Styles.Editor.QuestionConfirm.Render(
+ ansi.Wrap(c.Title, area.Dx()-lipgloss.Width(iconPrompt), ""),
+ )
+ y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), qText)
+ y++ // blank
+
+ // Description.
+ if c.Description != "" {
+ r := common.MarkdownRenderer(c.Styles, area.Dx())
+ mu := common.LockMarkdownRenderer(r)
+ mu.Lock()
+ desc, err := r.Render(c.Description)
+ mu.Unlock()
+ if err == nil {
+ desc = strings.TrimSuffix(desc, "\n")
+ y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), desc)
+ } else {
+ y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), c.Description)
+ }
+ y++ // blank
+ }
+
+ // Answer summary bullets in description/body style.
+ bulletStyle := c.Styles.Editor.QuestionBody
+ for i, label := range c.QuestionLabels {
+ summary := c.answerSummary(i)
+ bullet := bulletStyle.Render("• " + label + ": " + summary)
+ y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), bullet)
+ }
+ y++ // blank
+
+ // Warning if some questions are unanswered.
+ if missed := c.unansweredCount(); missed > 0 {
+ warnStyle := c.Styles.Tool.WarnTag
+ msgStyle := c.Styles.Tool.WarnMessage
+ word := "question"
+ if missed > 1 {
+ word = "questions"
+ }
+ warn := warnStyle.Render("WARN") + " " + msgStyle.Render(fmt.Sprintf("%d %s unanswered", missed, word))
+ y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), warn)
+ y++ // blank
+ }
+
+ // Buttons. Build compositor first so hover uses current geometry.
+ confirmButtonOpts := []common.ButtonOpts{
+ {Text: "Yup!", Selected: c.confirmYes, Padding: 3, UnderlineIndex: -1},
+ {Text: "Not yet", Selected: !c.confirmYes, Padding: 3, UnderlineIndex: -1},
+ }
+ c.compositor = common.ButtonHitCompositor(c.Styles, confirmButtonOpts, " ", area.Min.X, y)
+ hoveredBtn := common.HitButtonIndex(c.compositor, c.hoverX, c.hoverY)
+ confirmButtonOpts[0].Hovered = hoveredBtn == 0
+ confirmButtonOpts[1].Hovered = hoveredBtn == 1
+ buttons := common.ButtonGroup(c.Styles, confirmButtonOpts, " ")
+ drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), buttons)
+
+ return nil
+}
+
+// HeightChanged always returns false.
+func (c *ConfirmComponent) HeightChanged() bool { return false }
+
+// SetFocused updates focus state.
+func (c *ConfirmComponent) SetFocused(focused bool) { c.focused = focused }
+
+// SetHover updates the hover position for button highlighting.
+func (c *ConfirmComponent) SetHover(x, y int) { c.hoverX = x; c.hoverY = y }
+
+// HandleMouseClick checks if the click landed on a button and
+// triggers the corresponding action. Returns done=true for Yup!,
+// done=false for Not yet (goes back to editing).
+func (c *ConfirmComponent) HandleMouseClick(x, y int) (bool, bool) {
+ switch common.HitButtonIndex(c.compositor, x, y) {
+ case 0: // Yup!
+ c.confirmYes = true
+ if c.OnConfirm != nil {
+ c.OnConfirm()
+ }
+ return true, true
+ case 1: // Not yet
+ c.confirmYes = false
+ if c.OnReject != nil {
+ c.OnReject()
+ }
+ return false, true
+ }
+ return false, false
+}
+
+// UpdateAnswers replaces the answer slice. Called by QuestionForm
+// when tabbing away from a question so the summary stays current.
+func (c *ConfirmComponent) UpdateAnswers(answers []*question.Answer) {
+ c.Answers = answers
+}
+
+// answerSummary returns a human-readable summary of an answer.
+// Choice IDs are resolved to display labels when possible.
+func (c *ConfirmComponent) answerSummary(idx int) string {
+ if idx >= len(c.Answers) || c.Answers[idx] == nil {
+ return "(not answered)"
+ }
+ resp := c.Answers[idx]
+ if resp.FillInText != "" {
+ return resp.FillInText
+ }
+ if resp.Yes != nil {
+ if *resp.Yes {
+ return "Yes"
+ }
+ return "No"
+ }
+ if len(resp.SelectedIDs) > 0 {
+ labels := make([]string, 0, len(resp.SelectedIDs))
+ for _, id := range resp.SelectedIDs {
+ labels = append(labels, c.choiceLabel(idx, id))
+ }
+ return strings.Join(labels, ", ")
+ }
+ return "(not answered)"
+}
+
+// choiceLabel resolves a choice ID to its display label.
+func (c *ConfirmComponent) choiceLabel(qIdx int, choiceID string) string {
+ if qIdx < len(c.QuestionRequests) {
+ for _, ch := range c.QuestionRequests[qIdx].Choices {
+ if ch.ID == choiceID {
+ return ch.Label
+ }
+ }
+ }
+ return choiceID
+}
diff --git a/internal/ui/dialog/question_editor.go b/internal/ui/dialog/question_editor.go
new file mode 100644
index 0000000000..927dfb7a6c
--- /dev/null
+++ b/internal/ui/dialog/question_editor.go
@@ -0,0 +1,272 @@
+package dialog
+
+import (
+ "image"
+ "strings"
+
+ "charm.land/bubbles/v2/key"
+ "charm.land/bubbles/v2/textarea"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ uv "github.com/charmbracelet/ultraviolet"
+)
+
+// newQuestionTextarea creates a configured textarea for question
+// input. All question textareas share the same base configuration;
+// only placeholder and char limit vary.
+func newQuestionTextarea(sty *styles.Styles, placeholder string, charLimit int) textarea.Model {
+ ta := textarea.New()
+ taStyles := sty.Editor.Textarea
+ taStyles.Cursor.Color = sty.Editor.PromptYoloDotsFocused.GetForeground()
+ ta.SetStyles(taStyles)
+ ta.Placeholder = placeholder
+ ta.ShowLineNumbers = false
+ ta.CharLimit = charLimit
+ ta.MaxWidth = choiceListMaxWidth
+ ta.SetVirtualCursor(false)
+ ta.DynamicHeight = true
+ ta.MinHeight = 1
+ ta.MaxHeight = 3
+ ta.SetHeight(1)
+ ta.SetPromptFunc(0, func(textarea.PromptInfo) string { return "" })
+ ta.KeyMap.InsertNewline = key.NewBinding(key.WithDisabled())
+ ta.Blur()
+ return ta
+}
+
+// questionEditor owns the fill-in textarea, note editor, and notes
+// map shared across all question component types. Components embed
+// this struct and call its methods instead of reimplementing editor
+// logic.
+type questionEditor struct {
+ Styles *styles.Styles
+
+ fillIn textarea.Model
+ noteEditor textarea.Model
+ activeNoteKey string // non-empty when a note editor is open
+ notes map[string]string
+
+ keyNote key.Binding
+ navUp key.Binding
+ navDown key.Binding
+}
+
+// newQuestionEditor creates a questionEditor with configured
+// fill-in and note textareas.
+func newQuestionEditor(sty *styles.Styles) questionEditor {
+ return questionEditor{
+ Styles: sty,
+ fillIn: newQuestionTextarea(sty, "Something else?", 500),
+ noteEditor: newQuestionTextarea(sty, "Add a note...", 300),
+ notes: make(map[string]string),
+ keyNote: key.NewBinding(key.WithKeys("alt+n"), key.WithHelp("alt+n", "note")),
+ navUp: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")),
+ navDown: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")),
+ }
+}
+
+// openNote opens the note editor for the given key, pre-populating
+// it with any existing note text.
+func (e *questionEditor) openNote(noteKey string) tea.Cmd {
+ e.activeNoteKey = noteKey
+ if existing, ok := e.notes[noteKey]; ok {
+ e.noteEditor.SetValue(existing)
+ } else {
+ e.noteEditor.Reset()
+ }
+ return e.noteEditor.Focus()
+}
+
+// closeNote saves the current note text and closes the editor.
+func (e *questionEditor) closeNote(noteKey string) {
+ e.activeNoteKey = ""
+ val := strings.TrimSpace(e.noteEditor.Value())
+ if val != "" {
+ e.notes[noteKey] = val
+ } else {
+ delete(e.notes, noteKey)
+ }
+ e.noteEditor.Blur()
+}
+
+// handleNoteKey processes keys when the note editor is focused.
+// Returns (cmd, handled). When handled is true the caller should
+// not process the key further. onClose is called for the close
+// key so the caller can control what happens after closing.
+func (e *questionEditor) handleNoteKey(msg tea.KeyPressMsg, closeKey key.Binding, onClose func()) (tea.Cmd, bool) {
+ switch {
+ case key.Matches(msg, closeKey):
+ onClose()
+ return nil, true
+ case key.Matches(msg, e.navUp), key.Matches(msg, e.navDown):
+ onClose()
+ return nil, false
+ default:
+ if key.Matches(msg, key.NewBinding(key.WithKeys("enter"))) {
+ onClose()
+ return nil, true
+ }
+ var cmd tea.Cmd
+ e.noteEditor, cmd = e.noteEditor.Update(msg)
+ return cmd, true
+ }
+}
+
+// handlePaste forwards a paste message to the currently focused
+// textarea (note editor or fill-in). Returns nil if no textarea
+// is focused.
+func (e *questionEditor) handlePaste(msg tea.PasteMsg) tea.Cmd {
+ if e.activeNoteKey != "" && e.noteEditor.Focused() {
+ var cmd tea.Cmd
+ e.noteEditor, cmd = e.noteEditor.Update(msg)
+ return cmd
+ }
+ if e.fillIn.Focused() {
+ var cmd tea.Cmd
+ e.fillIn, cmd = e.fillIn.Update(msg)
+ return cmd
+ }
+ return nil
+}
+
+// drawFillIn appends fill-in rows to lines. When focused, renders
+// the live textarea; otherwise shows saved text or placeholder.
+// styleFilled controls whether non-empty fill-in text gets the
+// selected (pink) style. Pass true for single-choice where the
+// fill-in IS the answer; false for multi-choice where it's supplementary.
+func (e *questionEditor) drawFillIn(lines *[]contentLine, innerWidth int, bar, barInactive, fillPrefix string, isActive bool, styleFilled bool) {
+ bodyStyle := e.Styles.Editor.QuestionBody
+ prefixWidth := lipgloss.Width(fillPrefix)
+
+ if isActive && e.fillIn.Focused() {
+ e.fillIn.SetWidth(innerWidth - 2 - prefixWidth)
+ indent := strings.Repeat(" ", prefixWidth)
+ for j, tl := range strings.Split(e.fillIn.View(), "\n") {
+ text := bar + fillPrefix + tl
+ if j > 0 {
+ text = barInactive + indent + tl
+ }
+ *lines = append(*lines, contentLine{text: text, fillInRow: j == 0, cursorItem: true, choiceIdx: -1})
+ }
+ return
+ }
+
+ val := strings.TrimSpace(e.fillIn.Value())
+ if val != "" {
+ rendered := e.Styles.Editor.QuestionUnselected.Render(val)
+ if styleFilled {
+ rendered = e.Styles.Editor.QuestionSelected.Render(val)
+ }
+ *lines = append(*lines, contentLine{text: bar + fillPrefix + rendered, cursorItem: isActive, choiceIdx: -1})
+ return
+ }
+ *lines = append(*lines, contentLine{text: bar + fillPrefix + bodyStyle.Render("Something else?"), cursorItem: isActive, choiceIdx: -1})
+}
+
+// drawNote appends note rows to lines for the given key. When the
+// note editor is active, renders the live textarea; otherwise shows
+// saved note text or nothing.
+func (e *questionEditor) drawNote(lines *[]contentLine, innerWidth int, bar, barInactive, noteKey string, isActive bool) {
+ noteStyle := e.Styles.Editor.QuestionNote
+ isEditing := e.activeNoteKey == noteKey && e.noteEditor.Focused()
+ const notePrefix = "> "
+
+ if isEditing && e.noteEditor.Focused() {
+ prefixWidth := lipgloss.Width(notePrefix)
+ e.noteEditor.SetWidth(innerWidth - 2 - prefixWidth)
+ indent := strings.Repeat(" ", prefixWidth)
+ for j, tl := range strings.Split(e.noteEditor.View(), "\n") {
+ text := bar + notePrefix + tl
+ if j > 0 {
+ text = barInactive + indent + tl
+ }
+ *lines = append(*lines, contentLine{text: text, noteRow: j == 0, cursorItem: true, choiceIdx: -1})
+ }
+ return
+ }
+
+ if saved, ok := e.notes[noteKey]; ok && saved != "" {
+ dimmed := noteStyle.Render(saved)
+ for _, ln := range strings.Split(dimmed, "\n") {
+ *lines = append(*lines, contentLine{text: bar + notePrefix + ln, cursorItem: isActive, choiceIdx: -1})
+ }
+ }
+}
+
+// fillInCursor returns the hardware cursor position for the fill-in
+// textarea when it's focused. areaMinX is the left edge of the
+// content area; prefixWidth is the visual width of the "> " prompt.
+func (e *questionEditor) fillInCursor(screenRow, areaMinX, prefixWidth int) *tea.Cursor {
+ if !e.fillIn.Focused() {
+ return nil
+ }
+ tc := e.fillIn.Cursor()
+ if tc == nil {
+ return nil
+ }
+ tc.X += areaMinX + 1 + prefixWidth
+ tc.Y += screenRow
+ return tc
+}
+
+// noteCursor returns the hardware cursor position for the note
+// editor when it's focused.
+func (e *questionEditor) noteCursor(screenRow, areaMinX, prefixWidth int) *tea.Cursor {
+ if !e.noteEditor.Focused() {
+ return nil
+ }
+ tc := e.noteEditor.Cursor()
+ if tc == nil {
+ return nil
+ }
+ tc.X += areaMinX + 1 + prefixWidth
+ tc.Y += screenRow
+ return tc
+}
+
+// hasNote reports whether a note exists for the given key.
+func (e *questionEditor) hasNote(key string) bool {
+ _, ok := e.notes[key]
+ return ok
+}
+
+// drawStandaloneNote draws a note editor or saved note directly
+// onto the screen (not via line list). Used by YesNo which doesn't
+// use the line-list model. Returns the cursor or nil.
+func (e *questionEditor) drawStandaloneNote(scr uv.Screen, area uv.Rectangle, y int, noteKey string) (*tea.Cursor, int) {
+ const notePrefix = "> "
+
+ if e.activeNoteKey != "" && e.noteEditor.Focused() {
+ y++
+ prefixWidth := lipgloss.Width(notePrefix)
+ e.noteEditor.SetWidth(area.Dx() - 2 - prefixWidth)
+ noteView := e.noteEditor.View()
+ var cur *tea.Cursor
+ for j, ln := range strings.Split(noteView, "\n") {
+ text := notePrefix + ln
+ if j > 0 {
+ text = strings.Repeat(" ", prefixWidth) + ln
+ }
+ lines := drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, y+1), text)
+ if j == 0 {
+ if tc := e.noteEditor.Cursor(); tc != nil {
+ tc.X += prefixWidth
+ tc.Y += y - area.Min.Y
+ cur = tc
+ }
+ }
+ y += lines
+ }
+ return cur, y
+ }
+
+ if saved, ok := e.notes[noteKey]; ok && saved != "" {
+ y++
+ noteStyle := e.Styles.Editor.QuestionNote
+ drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, y+1), notePrefix+noteStyle.Render(saved))
+ y++
+ }
+
+ return nil, y
+}
diff --git a/internal/ui/dialog/question_form.go b/internal/ui/dialog/question_form.go
new file mode 100644
index 0000000000..3cb0a7f605
--- /dev/null
+++ b/internal/ui/dialog/question_form.go
@@ -0,0 +1,631 @@
+package dialog
+
+import (
+ "fmt"
+ "image"
+ "strings"
+
+ "charm.land/bubbles/v2/key"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/question"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ uv "github.com/charmbracelet/ultraviolet"
+)
+
+// questionResponder extends InlineEditor with access to the last
+// response. Used internally by QuestionForm to collect answers
+// from child components.
+type questionResponder interface {
+ InlineEditor
+ Response() question.Answer
+ SetHover(x, y int)
+ HandleMouseClick(x, y int) (done bool, handled bool)
+}
+
+// QuestionForm presents multiple questions as a tabbed form.
+// Tab/shift+tab switches between questions; each question keeps
+// its own internal keybindings. For multi-question batches, a
+// Confirm tab is appended automatically.
+type QuestionForm struct {
+ Styles *styles.Styles
+ BatchID string
+ questions []questionResponder // includes ConfirmComponent as last item for batches
+ labels []string // includes "Confirm" for batches
+ requestIDs []string
+ answers []*question.Answer // nil until answered; only covers real questions
+ activeIdx int
+ focused bool
+ hasConfirm bool // whether a confirm tab exists
+ showTabs bool // whether to render tab chrome
+ numQuestions int // real question count (excludes confirm tab)
+ confirmComp *ConfirmComponent // nil when no confirm tab
+
+ keyPrevTab key.Binding
+ keyNextTab key.Binding
+ keyClose key.Binding
+
+ // Compositor for tab hit detection. Built during Draw() from
+ // tab layers positioned at their screen coordinates.
+ compositor *lipgloss.Compositor
+
+ // Hover position for highlighting interactive elements.
+ hoverX, hoverY int
+
+ // OnAnswer is called when the form is submitted. The UI sets
+ // this to wire up workspace submission.
+ OnAnswer func(responses []question.Answer)
+}
+
+// NewQuestionForm creates a tabbed multi-question form from a
+// batch request. Each question is wrapped in its existing
+// component type (YesNo, SingleChoice, MultiChoice, FreeText).
+// A Confirm tab is appended for multi-question batches.
+func NewQuestionForm(sty *styles.Styles, batch question.Request) *QuestionForm {
+ comps := make([]questionResponder, len(batch.Questions))
+ labels := make([]string, len(batch.Questions))
+ ids := make([]string, len(batch.Questions))
+ for i, req := range batch.Questions {
+ switch req.Type {
+ case question.TypeYesNo:
+ comps[i] = NewYesNo(sty, req)
+ case question.TypeSingleChoice:
+ comps[i] = NewSingleChoice(sty, req)
+ case question.TypeMultiChoice:
+ comps[i] = NewMultiChoice(sty, req)
+ case question.TypeFreeText:
+ comps[i] = NewFreeText(sty, req)
+ }
+ if req.Label != "" {
+ labels[i] = req.Label
+ } else {
+ labels[i] = shortLabel(req.Text)
+ }
+ ids[i] = req.ID
+ }
+
+ numQuestions := len(comps)
+ // Confirm tab only for multi-question batches.
+ hasConfirm := numQuestions > 1
+ answers := make([]*question.Answer, numQuestions)
+
+ var confirmComp *ConfirmComponent
+ allLabels := labels
+ if hasConfirm {
+ confirmTitle := batch.ConfirmTitle
+ if confirmTitle == "" {
+ confirmTitle = "Confirm"
+ }
+ confirmComp = NewConfirmComponent(
+ sty,
+ confirmTitle,
+ batch.ConfirmDescription,
+ labels,
+ batch.Questions,
+ answers,
+ )
+ allLabels = make([]string, len(labels)+1)
+ copy(allLabels, labels)
+ allLabels[len(labels)] = "Confirm"
+ }
+ showTabs := numQuestions > 1
+
+ f := &QuestionForm{
+ Styles: sty,
+ BatchID: batch.ID,
+ questions: comps,
+ labels: allLabels,
+ requestIDs: ids,
+ answers: answers,
+ hasConfirm: hasConfirm,
+ showTabs: showTabs,
+ numQuestions: numQuestions,
+ confirmComp: confirmComp,
+ keyPrevTab: key.NewBinding(
+ key.WithKeys("ctrl+left"),
+ key.WithHelp("ctrl+←", "prev tab"),
+ ),
+ keyNextTab: key.NewBinding(
+ key.WithKeys("ctrl+right"),
+ key.WithHelp("ctrl+→", "next tab"),
+ ),
+ keyClose: CloseKey,
+ }
+
+ // Wire confirm callbacks.
+ if confirmComp != nil {
+ confirmComp.OnConfirm = f.submit
+ confirmComp.OnReject = func() {
+ if idx := f.firstUnanswered(); idx >= 0 {
+ f.switchTab(idx)
+ } else if numQuestions > 0 {
+ f.switchTab(numQuestions - 1)
+ }
+ }
+ }
+
+ if len(comps) > 0 {
+ comps[0].SetFocused(true)
+ }
+ return f
+}
+
+// shortLabel truncates a question to at most three words for use
+// as a tab header.
+func shortLabel(q string) string {
+ q = strings.ReplaceAll(q, "\n", " ")
+ words := strings.Fields(q)
+ if len(words) > 3 {
+ words = words[:3]
+ }
+ return strings.Join(words, " ")
+}
+
+// isConfirmTab reports whether the active tab is the confirm tab.
+func (f *QuestionForm) isConfirmTab() bool {
+ return f.hasConfirm && f.activeIdx == f.numQuestions
+}
+
+// isAnswered reports whether a question has a meaningful answer.
+func (f *QuestionForm) isAnswered(idx int) bool {
+ if idx >= len(f.answers) || f.answers[idx] == nil {
+ return false
+ }
+ resp := f.answers[idx]
+ return len(resp.SelectedIDs) > 0 || resp.FillInText != "" || resp.Yes != nil
+}
+
+// firstUnanswered returns the index of the first unanswered
+// question, or -1 if all are answered.
+func (f *QuestionForm) firstUnanswered() int {
+ for i, ans := range f.answers {
+ if ans == nil {
+ return i
+ }
+ if len(ans.SelectedIDs) == 0 && ans.FillInText == "" && ans.Yes == nil {
+ return i
+ }
+ }
+ return -1
+}
+
+// HandleKey routes keys to the active tab. Returns true when the
+// entire batch is submitted.
+func (f *QuestionForm) HandleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
+ // Tab navigation works on all tabs including confirm.
+ switch {
+ case key.Matches(msg, f.keyNextTab):
+ f.switchTab(f.activeIdx + 1)
+ return false, nil
+ case key.Matches(msg, f.keyPrevTab):
+ f.switchTab(f.activeIdx - 1)
+ return false, nil
+ }
+
+ // Confirm tab delegates to ConfirmComponent.
+ if f.isConfirmTab() {
+ done, cmd := f.confirmComp.HandleKey(msg)
+ if done {
+ return true, cmd
+ }
+ return false, cmd
+ }
+
+ // Global keys for question tabs.
+ if key.Matches(msg, f.keyClose) {
+ f.submit()
+ return true, nil
+ }
+
+ // Route to active question.
+ if f.activeIdx < f.numQuestions {
+ done, cmd := f.questions[f.activeIdx].HandleKey(msg)
+ if done {
+ resp := f.questions[f.activeIdx].Response()
+ f.answers[f.activeIdx] = &resp
+ f.syncConfirmAnswers()
+ if f.activeIdx < len(f.labels)-1 {
+ f.switchTab(f.activeIdx + 1)
+ } else if !f.hasConfirm {
+ f.submit()
+ return true, cmd
+ }
+ return false, cmd
+ }
+ return false, cmd
+ }
+ return false, nil
+}
+
+// switchTab moves focus to the given tab index, wrapping around.
+// Snapshots the current question's response before leaving.
+func (f *QuestionForm) switchTab(idx int) {
+ totalTabs := len(f.labels)
+ if totalTabs == 0 {
+ return
+ }
+ // Snapshot and unfocus current.
+ if !f.isConfirmTab() && f.activeIdx < f.numQuestions {
+ resp := f.questions[f.activeIdx].Response()
+ f.answers[f.activeIdx] = &resp
+ f.questions[f.activeIdx].SetFocused(false)
+ } else if f.isConfirmTab() {
+ f.confirmComp.SetFocused(false)
+ }
+ // Wrap.
+ if idx < 0 {
+ idx = totalTabs - 1
+ } else if idx >= totalTabs {
+ idx = 0
+ }
+ f.activeIdx = idx
+ // Focus new.
+ if f.isConfirmTab() {
+ f.syncConfirmAnswers()
+ f.confirmComp.SetFocused(f.focused)
+ } else if f.activeIdx < f.numQuestions {
+ f.questions[f.activeIdx].SetFocused(f.focused)
+ }
+}
+
+// syncConfirmAnswers pushes the latest answers to the confirm
+// component so its summary stays current.
+func (f *QuestionForm) syncConfirmAnswers() {
+ if f.confirmComp != nil {
+ f.confirmComp.UpdateAnswers(f.answers)
+ }
+}
+
+// submit collects stored responses and calls OnAnswer.
+func (f *QuestionForm) submit() {
+ responses := make([]question.Answer, f.numQuestions)
+ for i, ans := range f.answers {
+ if ans != nil {
+ responses[i] = *ans
+ } else {
+ responses[i] = question.Answer{
+ QuestionID: f.requestIDs[i],
+ }
+ }
+ }
+ if f.OnAnswer != nil {
+ f.OnAnswer(responses)
+ }
+}
+
+// ShortHelp returns key bindings for the status bar.
+func (f *QuestionForm) ShortHelp() []key.Binding {
+ if f.isConfirmTab() {
+ return f.confirmComp.ShortHelp()
+ }
+ bindings := []key.Binding{f.keyPrevTab, f.keyNextTab}
+ if f.activeIdx < f.numQuestions {
+ bindings = append(bindings, f.questions[f.activeIdx].ShortHelp()...)
+ }
+ return bindings
+}
+
+// Height returns the total height using the max tab height so
+// switching tabs doesn't cause layout jumps.
+func (f *QuestionForm) Height() int {
+ h := 0
+ if f.showTabs {
+ h = 4 // bordered tab row (top + label + bottom) + blank line
+ }
+ maxQ := 0
+ for _, q := range f.questions {
+ if qh := q.Height(); qh > maxQ {
+ maxQ = qh
+ }
+ }
+ if f.confirmComp != nil {
+ if ch := f.confirmComp.Height(); ch > maxQ {
+ maxQ = ch
+ }
+ }
+ h += maxQ
+ return h
+}
+
+// CollapsedHeight returns the height of the collapsed summary
+// line shown when the editor area is not focused.
+func (f *QuestionForm) CollapsedHeight() int { return 1 }
+
+// DrawCollapsed renders a compact one-line summary of the form
+// when the user has tabbed away to the chat. For multi-question
+// batches it shows the active question text and answered count;
+// for single questions it shows just the question text.
+func (f *QuestionForm) DrawCollapsed(scr uv.Screen, area uv.Rectangle) {
+ icon := f.Styles.Editor.PromptQuestionIconBlurred.Render()
+ iconWidth := lipgloss.Width(icon)
+ textStyle := f.Styles.Messages.AssistantInfoModel
+ countStyle := f.Styles.Messages.AssistantInfoProvider
+ lineStyle := f.Styles.Section.Line
+
+ var plainText string
+ var confirmRendered string
+ if f.numQuestions > 1 {
+ answered := 0
+ for i := 0; i < f.numQuestions; i++ {
+ if f.isAnswered(i) {
+ answered++
+ }
+ }
+ if f.isConfirmTab() && f.confirmComp != nil {
+ plainText = f.confirmComp.Title
+ confirmRendered = f.Styles.Editor.QuestionConfirm.Render(f.confirmComp.Title)
+ } else if f.activeIdx < len(f.questions) {
+ plainText = f.getQuestionText(f.activeIdx)
+ }
+ count := fmt.Sprintf("(%d/%d answered)", answered, f.numQuestions)
+ plainLabel := plainText + " " + count
+ textWidth := iconWidth + 1 + lipgloss.Width(plainLabel)
+ remaining := area.Dx() - textWidth - 1
+
+ var rendered string
+ if confirmRendered != "" {
+ rendered = fmt.Sprintf("%s%s %s", icon, confirmRendered, countStyle.Render(count))
+ } else {
+ rendered = fmt.Sprintf("%s%s %s", icon, textStyle.Render(plainText), countStyle.Render(count))
+ }
+ if remaining > 0 {
+ rendered = rendered + " " + lineStyle.Render(strings.Repeat(styles.SectionSeparator, remaining))
+ }
+ drawStyledText(scr, area, rendered)
+ } else if f.numQuestions == 1 {
+ plainText = f.getQuestionText(0)
+ textWidth := iconWidth + 1 + lipgloss.Width(plainText)
+ remaining := area.Dx() - textWidth - 1
+ rendered := fmt.Sprintf("%s%s", icon, textStyle.Render(plainText))
+ if remaining > 0 {
+ rendered = rendered + " " + lineStyle.Render(strings.Repeat(styles.SectionSeparator, remaining))
+ }
+ drawStyledText(scr, area, rendered)
+ }
+}
+
+// getQuestionText returns the question text for the given index.
+func (f *QuestionForm) getQuestionText(idx int) string {
+ type hasRequest interface {
+ GetRequest() question.Question
+ }
+ if idx < len(f.questions) {
+ if hr, ok := f.questions[idx].(hasRequest); ok {
+ return hr.GetRequest().Text
+ }
+ }
+ if idx < len(f.labels) {
+ return f.labels[idx]
+ }
+ return ""
+}
+
+// Draw renders the tab bar and the active tab content. When
+// showTabs is false (single question), renders content directly
+// without tab chrome.
+func (f *QuestionForm) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+ contentY := area.Min.Y
+
+ if f.showTabs {
+ const tabPadX = 1
+ tabHeight := 3
+
+ // Compute display labels.
+ labels := make([]string, len(f.labels))
+ copy(labels, f.labels)
+
+ // Truncate if tabs exceed width.
+ totalWidth := 0
+ for _, l := range labels {
+ totalWidth += len(l) + tabPadX*2 + 2
+ }
+ if totalWidth > area.Dx() && len(labels) > 0 {
+ availPerTab := max((area.Dx()/len(labels))-tabPadX*2-2, 1)
+ for i, l := range labels {
+ if len(l) > availPerTab {
+ if availPerTab > 1 {
+ labels[i] = l[:availPerTab-1] + "…"
+ } else {
+ labels[i] = l[:availPerTab]
+ }
+ }
+ }
+ }
+
+ // Build tab layers for click hit detection.
+ var layers []*lipgloss.Layer
+ x := area.Min.X
+
+ // Determine hovered tab via simple bounds check.
+ hoveredTab := -1
+ if f.hoverY >= area.Min.Y && f.hoverY < area.Min.Y+tabHeight {
+ tx := area.Min.X
+ for i, label := range labels {
+ tw := len(label) + tabPadX*2 + 2
+ if f.hoverX >= tx && f.hoverX < tx+tw {
+ hoveredTab = i
+ break
+ }
+ tx += tw
+ }
+ }
+
+ for i, label := range labels {
+ isActive := i == f.activeIdx
+ isHovered := i == hoveredTab && !isActive
+ labelWidth := len(label)
+ tabWidth := labelWidth + tabPadX*2 + 2
+
+ tabArea := image.Rect(x, area.Min.Y, x+tabWidth, area.Min.Y+tabHeight)
+
+ border := f.Styles.Tab.InactiveBorder
+ textStyle := f.Styles.Tab.InactiveStyle
+ if !f.focused {
+ border = f.Styles.Tab.InactiveBorderBlurred
+ }
+ if isActive {
+ border = f.Styles.Tab.ActiveBorder
+ textStyle = f.Styles.Tab.ActiveStyle
+ if !f.focused {
+ border = f.Styles.Tab.ActiveBorderBlurred
+ }
+ } else if i < f.numQuestions && f.isAnswered(i) {
+ textStyle = f.Styles.Tab.ActiveStyle
+ }
+ if isHovered {
+ hovered := textStyle
+ hovered.Attrs |= uv.AttrBold
+ textStyle = hovered
+ }
+
+ if i == 0 {
+ if isActive {
+ border.BottomLeft = uv.Side{Content: "┘", Style: border.BottomLeft.Style}
+ } else {
+ border.BottomLeft = uv.Side{Content: "┴", Style: border.BottomLeft.Style}
+ }
+ }
+
+ border.Draw(scr, tabArea)
+
+ innerWidth := tabWidth - 2
+ xOff := (innerWidth - labelWidth) / 2
+ innerArea := image.Rect(
+ tabArea.Min.X+1+xOff, tabArea.Min.Y+1,
+ tabArea.Max.X-1, tabArea.Max.Y-1,
+ )
+ uv.NewStyledString(textStyle.Styled(label)).Draw(scr, innerArea)
+
+ // Create an invisible hit layer for this tab.
+ hitStr := strings.Repeat(strings.Repeat(" ", tabWidth)+"\n", tabHeight-1) + strings.Repeat(" ", tabWidth)
+ layers = append(layers, lipgloss.NewLayer(hitStr).X(x).Y(area.Min.Y).ID(fmt.Sprintf("tab_%d", i)))
+
+ x += tabWidth
+ }
+
+ f.compositor = lipgloss.NewCompositor(layers...)
+
+ lineY := area.Min.Y + tabHeight - 1
+ lineSide := f.Styles.Tab.InactiveBorder.Bottom
+ if !f.focused {
+ lineSide = f.Styles.Tab.InactiveBorderBlurred.Bottom
+ }
+ for lx := x; lx < area.Max.X; lx++ {
+ c := uv.NewCell(scr.WidthMethod(), lineSide.Content)
+ if c != nil {
+ c.Style = lineSide.Style
+ }
+ scr.SetCell(lx, lineY, c)
+ }
+
+ contentY = area.Min.Y + tabHeight + 1
+ } else {
+ f.compositor = nil
+ }
+
+ contentArea := image.Rect(area.Min.X, contentY, area.Max.X, area.Max.Y)
+
+ if f.isConfirmTab() {
+ return f.confirmComp.Draw(scr, contentArea)
+ }
+ if f.activeIdx < f.numQuestions {
+ cur := f.questions[f.activeIdx].Draw(scr, contentArea)
+ if cur != nil {
+ cur.Y += contentY - area.Min.Y
+ }
+ return cur
+ }
+ return nil
+}
+
+// HeightChanged reports whether any component's height changed.
+func (f *QuestionForm) HeightChanged() bool {
+ for _, q := range f.questions {
+ if q.HeightChanged() {
+ return true
+ }
+ }
+ if f.confirmComp != nil && f.confirmComp.HeightChanged() {
+ return true
+ }
+ return false
+}
+
+// SetFocused updates focus state for the active tab.
+func (f *QuestionForm) SetFocused(focused bool) {
+ f.focused = focused
+ if f.isConfirmTab() {
+ f.confirmComp.SetFocused(focused)
+ } else if f.activeIdx < f.numQuestions {
+ f.questions[f.activeIdx].SetFocused(focused)
+ }
+}
+
+// SetHover implements MouseClickableEditor. Stores the hover
+// position and propagates it to the active component.
+func (f *QuestionForm) SetHover(x, y int) {
+ f.hoverX = x
+ f.hoverY = y
+ if f.isConfirmTab() && f.confirmComp != nil {
+ f.confirmComp.SetHover(x, y)
+ } else if f.activeIdx < len(f.questions) {
+ f.questions[f.activeIdx].SetHover(x, y)
+ }
+}
+
+// HandlePaste implements PasteableEditor. Forwards paste events
+// to the active question component if it supports pasting.
+func (f *QuestionForm) HandlePaste(msg tea.PasteMsg) tea.Cmd {
+ if f.isConfirmTab() {
+ return nil
+ }
+ if f.activeIdx < f.numQuestions {
+ if p, ok := f.questions[f.activeIdx].(PasteableEditor); ok {
+ return p.HandlePaste(msg)
+ }
+ }
+ return nil
+}
+
+// HandleMouseClick implements MouseClickableEditor. It checks if
+// the click landed on a tab and switches to it, or delegates to
+// the active component for content-area clicks.
+func (f *QuestionForm) HandleMouseClick(x, y int) (bool, bool) {
+ // Check tabs first.
+ if f.showTabs && f.compositor != nil {
+ hit := f.compositor.Hit(x, y)
+ if !hit.Empty() {
+ var idx int
+ if _, err := fmt.Sscanf(hit.ID(), "tab_%d", &idx); err == nil {
+ if idx >= 0 && idx < len(f.labels) && idx != f.activeIdx {
+ f.switchTab(idx)
+ }
+ return false, true
+ }
+ }
+ }
+
+ // Delegate to active component.
+ if f.isConfirmTab() && f.confirmComp != nil {
+ return f.confirmComp.HandleMouseClick(x, y)
+ }
+ if f.activeIdx < len(f.questions) {
+ done, handled := f.questions[f.activeIdx].HandleMouseClick(x, y)
+ if handled {
+ resp := f.questions[f.activeIdx].Response()
+ f.answers[f.activeIdx] = &resp
+ f.syncConfirmAnswers()
+ if done {
+ if f.activeIdx < len(f.labels)-1 {
+ f.switchTab(f.activeIdx + 1)
+ return false, true
+ } else if !f.hasConfirm {
+ f.submit()
+ return true, true
+ }
+ }
+ return false, true
+ }
+ }
+ return false, false
+}
diff --git a/internal/ui/dialog/question_freetext.go b/internal/ui/dialog/question_freetext.go
new file mode 100644
index 0000000000..0807225e8a
--- /dev/null
+++ b/internal/ui/dialog/question_freetext.go
@@ -0,0 +1,201 @@
+package dialog
+
+import (
+ "image"
+ "strings"
+
+ "charm.land/bubbles/v2/key"
+ "charm.land/bubbles/v2/textarea"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/question"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// FreeText is an open-ended text input component for questions
+// that need a narrative answer rather than a selection.
+type FreeText struct {
+ Styles *styles.Styles
+ Request question.Question
+ focused bool
+
+ editor textarea.Model
+ keyEnter key.Binding
+ keyClose key.Binding
+
+ lastResponse question.Answer
+ lastWidth int
+}
+
+// NewFreeText creates a new free-text question component.
+func NewFreeText(sty *styles.Styles, req question.Question) *FreeText {
+ ta := newQuestionTextarea(sty, "Type your answer...", 1000)
+ ta.MinHeight = 3
+ ta.MaxHeight = 8
+ ta.SetHeight(3)
+
+ return &FreeText{
+ Styles: sty,
+ Request: req,
+ editor: ta,
+ keyEnter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")),
+ keyClose: CloseKey,
+ }
+}
+
+// HandleKey processes a key press. Returns true when the user has
+// submitted or dismissed the question.
+func (d *FreeText) HandleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
+ switch {
+ case key.Matches(msg, d.keyClose):
+ d.answer(question.Answer{QuestionID: d.Request.ID})
+ return true, nil
+ case key.Matches(msg, d.keyEnter):
+ val := strings.TrimSpace(d.editor.Value())
+ if val != "" {
+ d.answer(question.Answer{
+ QuestionID: d.Request.ID,
+ FillInText: val,
+ })
+ return true, nil
+ }
+ return false, nil
+ default:
+ var cmd tea.Cmd
+ d.editor, cmd = d.editor.Update(msg)
+ return false, cmd
+ }
+}
+
+func (d *FreeText) answer(resp question.Answer) {
+ d.lastResponse = resp
+}
+
+// Response returns the current answer, including any unsaved
+// editor content so that tabbing away preserves typed text.
+func (d *FreeText) Response() question.Answer {
+ if val := strings.TrimSpace(d.editor.Value()); val != "" {
+ return question.Answer{QuestionID: d.Request.ID, FillInText: val}
+ }
+ return d.lastResponse
+}
+
+// GetRequest returns the underlying question request.
+func (d *FreeText) GetRequest() question.Question { return d.Request }
+
+// ShortHelp returns key bindings for the status bar.
+func (d *FreeText) ShortHelp() []key.Binding {
+ return []key.Binding{d.keyEnter, d.keyClose}
+}
+
+// Height returns the visual height at the default max width.
+func (d *FreeText) Height() int {
+ w := d.lastWidth
+ if w <= 0 {
+ w = choiceListMaxWidth
+ }
+ iconPrompt := questionIconPrompt(d.Styles, d.focused)
+ h := sectionHeight(d.Request.Text, w-lipgloss.Width(iconPrompt)) // question
+ h++ // blank
+ if d.Request.Description != "" {
+ r := common.MarkdownRenderer(d.Styles, w)
+ mu := common.LockMarkdownRenderer(r)
+ mu.Lock()
+ out, err := r.Render(d.Request.Description)
+ mu.Unlock()
+ if err == nil {
+ out = strings.TrimSuffix(out, "\n")
+ h += strings.Count(out, "\n") + 1
+ } else {
+ h += sectionHeight(d.Request.Description, w)
+ }
+ h++ // blank
+ }
+ h += d.editor.Height() // textarea
+ h++ // trailing blank for bottom padding
+ return h
+}
+
+// Draw renders the free-text question directly to screen.
+// Returns the cursor position, or nil.
+func (d *FreeText) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+ d.lastWidth = area.Dx()
+ y := area.Min.Y
+
+ // Draw question header.
+ iconPrompt := questionIconPrompt(d.Styles, d.focused)
+ qText := iconPrompt + d.Styles.Editor.QuestionUnselected.Render(
+ ansi.Wrap(d.Request.Text, area.Dx()-lipgloss.Width(iconPrompt), ""),
+ )
+ y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), qText)
+ y++ // blank
+
+ // Draw optional description.
+ if d.Request.Description != "" {
+ r := common.MarkdownRenderer(d.Styles, area.Dx())
+ mu := common.LockMarkdownRenderer(r)
+ mu.Lock()
+ desc, err := r.Render(d.Request.Description)
+ mu.Unlock()
+ if err == nil {
+ desc = strings.TrimSuffix(desc, "\n")
+ y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), desc)
+ } else {
+ y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), d.Request.Description)
+ }
+ y++ // blank
+ }
+
+ // Draw textarea with > prompt prefix.
+ promptPrefix := d.Styles.Editor.QuestionBody.Render("> ")
+ prefixWidth := lipgloss.Width(promptPrefix)
+ d.editor.SetWidth(min(area.Dx()-2-prefixWidth, choiceListMaxWidth))
+ view := d.editor.View()
+ var cur *tea.Cursor
+ for j, ln := range strings.Split(view, "\n") {
+ text := promptPrefix + ln
+ if j > 0 {
+ text = strings.Repeat(" ", prefixWidth) + ln
+ }
+ drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, y+1), text)
+ if j == 0 {
+ if tc := d.editor.Cursor(); tc != nil {
+ tc.X += prefixWidth
+ tc.Y += y - area.Min.Y
+ cur = tc
+ }
+ }
+ y++
+ }
+
+ return cur
+}
+
+// HeightChanged reports whether the textarea height changed.
+func (d *FreeText) HeightChanged() bool { return false }
+
+// SetFocused updates focus state.
+func (d *FreeText) SetFocused(focused bool) {
+ d.focused = focused
+ if focused {
+ d.editor.Focus()
+ } else {
+ d.editor.Blur()
+ }
+}
+
+// SetHover is a no-op for free text questions.
+func (d *FreeText) SetHover(x, y int) {}
+
+// HandleMouseClick is a no-op for free text questions.
+func (d *FreeText) HandleMouseClick(x, y int) (bool, bool) { return false, false }
+
+// HandlePaste forwards paste events to the editor textarea.
+func (d *FreeText) HandlePaste(msg tea.PasteMsg) tea.Cmd {
+ var cmd tea.Cmd
+ d.editor, cmd = d.editor.Update(msg)
+ return cmd
+}
diff --git a/internal/ui/dialog/question_multi.go b/internal/ui/dialog/question_multi.go
new file mode 100644
index 0000000000..1a49204a59
--- /dev/null
+++ b/internal/ui/dialog/question_multi.go
@@ -0,0 +1,224 @@
+package dialog
+
+import (
+ "fmt"
+ "maps"
+ "strings"
+
+ "charm.land/bubbles/v2/key"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/question"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ uv "github.com/charmbracelet/ultraviolet"
+)
+
+// MultiChoice is an inline multi-choice question component.
+// It embeds choiceList for shared navigation, fill-in, and
+// rendering scaffold.
+type MultiChoice struct {
+ choiceList
+
+ selected map[int]bool
+ keyToggle key.Binding
+ keyDone key.Binding
+
+ lastResponse question.Answer
+}
+
+// NewMultiChoice creates a new inline multi-choice component.
+func NewMultiChoice(sty *styles.Styles, req question.Question) *MultiChoice {
+ cl := newChoiceList(sty, req)
+ cl.styleFillInAsSelected = true
+ return &MultiChoice{
+ choiceList: cl,
+ selected: make(map[int]bool),
+ keyToggle: key.NewBinding(key.WithKeys(" ", "space"), key.WithHelp("space", "toggle")),
+ keyDone: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "done")),
+ }
+}
+
+// HandleKey processes key events. Returns true when the user has
+// submitted or dismissed the question.
+func (d *MultiChoice) HandleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
+ // Note editor takes priority when active.
+ if d.activeNoteKey != "" && d.noteEditor.Focused() {
+ cmd, handled := d.handleNoteKey(msg, d.keyClose, func() { d.closeNote(d.noteKey()) })
+ if handled {
+ return false, cmd
+ }
+ }
+
+ if !d.fillIn.Focused() && d.activeNoteKey == "" {
+ if idx := d.numberKeyIndex(msg); idx >= 0 {
+ d.mouseActive = false
+ d.cursorIdx = idx
+ d.selected[idx] = !d.selected[idx]
+ if !d.selected[idx] {
+ delete(d.selected, idx)
+ }
+ return false, nil
+ }
+ }
+
+ if done, cmd, handled := d.handleFillInFocused(msg, d.keyDone, func() (bool, tea.Cmd) {
+ d.fillIn.Blur()
+ return false, nil
+ }, func() (bool, tea.Cmd) {
+ val := strings.TrimSpace(d.fillIn.Value())
+ if val != "" {
+ d.answer(d.respond())
+ return true, nil
+ }
+ return false, nil
+ }); handled {
+ return done, cmd
+ }
+
+ switch {
+ case key.Matches(msg, d.keyClose):
+ d.answer(question.Answer{QuestionID: d.Request.ID})
+ return true, nil
+ case key.Matches(msg, d.keyDone):
+ if d.isFillIn() && !d.fillIn.Focused() {
+ d.fillIn.Focus()
+ return false, d.fillIn.Focus()
+ }
+ d.answer(d.respond())
+ return true, nil
+ case key.Matches(msg, d.keyToggle):
+ if d.isFillIn() {
+ if !d.fillIn.Focused() {
+ d.fillIn.Focus()
+ return false, d.fillIn.Focus()
+ }
+ var cmd tea.Cmd
+ d.fillIn, cmd = d.fillIn.Update(msg)
+ return false, cmd
+ }
+ d.selected[d.cursorIdx] = !d.selected[d.cursorIdx]
+ if !d.selected[d.cursorIdx] {
+ delete(d.selected, d.cursorIdx)
+ }
+ case key.Matches(msg, d.keyNote) && !d.fillIn.Focused():
+ return false, d.openNote(d.noteKey())
+ }
+ if d.handleNavKey(msg) {
+ return false, nil
+ }
+ return false, nil
+}
+
+func (d *MultiChoice) answer(resp question.Answer) {
+ d.lastResponse = resp
+}
+
+// Response returns the last response. Used by QuestionForm to
+// collect answers from child components.
+// Response returns the current answer, reflecting live toggle
+// state so that tabbing away preserves selections.
+func (d *MultiChoice) Response() question.Answer { return d.respond() }
+
+// GetRequest returns the underlying question request.
+func (d *MultiChoice) GetRequest() question.Question { return d.Request }
+
+func (d *MultiChoice) respond() question.Answer {
+ resp := question.Answer{QuestionID: d.Request.ID}
+ for i := range d.Request.Choices {
+ if d.selected[i] {
+ resp.SelectedIDs = append(resp.SelectedIDs, d.Request.Choices[i].ID)
+ }
+ }
+ val := strings.TrimSpace(d.fillIn.Value())
+ if val != "" {
+ resp.FillInText = val
+ }
+ if len(d.notes) > 0 {
+ resp.Notes = make(map[string]string, len(d.notes))
+ maps.Copy(resp.Notes, d.notes)
+ }
+ return resp
+}
+
+// ShortHelp returns key bindings for the status bar.
+func (d *MultiChoice) ShortHelp() []key.Binding {
+ if d.activeNoteKey != "" && d.noteEditor.Focused() {
+ return []key.Binding{d.keyClose, key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "save note"))}
+ }
+ if d.isFillIn() && d.fillIn.Focused() {
+ return []key.Binding{d.navUp, d.keyDone, d.keyClose}
+ }
+ return []key.Binding{d.keyUp, d.keyDown, d.keyToggle, numKeyBinding(len(d.Request.Choices)), d.keyNote, d.keyDone, d.keyClose}
+}
+
+func (d *MultiChoice) Height() int { return d.height(choiceListMaxWidth + 4) }
+func (d *MultiChoice) HeightChanged() bool { return d.heightChanged() }
+func (d *MultiChoice) SetFocused(f bool) { d.setFocused(f) }
+func (d *MultiChoice) SetHover(x, y int) { d.setHover(x, y) }
+func (d *MultiChoice) HandlePaste(msg tea.PasteMsg) tea.Cmd {
+ return d.handlePaste(msg)
+}
+
+// HandleMouseClick checks if the click landed on a choice item and
+// toggles it, or focuses the fill-in. Returns done=false since
+// multi-choice requires explicit submission.
+func (d *MultiChoice) HandleMouseClick(x, y int) (bool, bool) {
+ if d.choiceCompositor == nil {
+ return false, false
+ }
+ hit := d.choiceCompositor.Hit(x, y)
+ if hit.Empty() {
+ return false, false
+ }
+ var idx int
+ if _, err := fmt.Sscanf(hit.ID(), "choice_%d", &idx); err != nil {
+ return false, false
+ }
+ if idx == len(d.Request.Choices) {
+ // Fill-in item.
+ d.cursorIdx = idx
+ d.mouseActive = false
+ d.suppressScroll = true
+ d.fillIn.Focus()
+ return false, true
+ }
+ if idx >= 0 && idx < len(d.Request.Choices) {
+ d.cursorIdx = idx
+ d.mouseActive = false
+ d.suppressScroll = true
+ d.fillIn.Blur()
+ d.selected[idx] = !d.selected[idx]
+ if !d.selected[idx] {
+ delete(d.selected, idx)
+ }
+ return false, true
+ }
+ return false, false
+}
+
+// Draw renders the multi-choice question directly to screen.
+// Returns the cursor position relative to area, or nil.
+func (d *MultiChoice) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+ fillPrefix := d.Styles.Editor.QuestionBody.Render("> ")
+ if strings.TrimSpace(d.fillIn.Value()) != "" {
+ fillPrefix = d.Styles.Editor.QuestionSelected.Render("> ")
+ }
+
+ unselectedHeader := d.Styles.Editor.QuestionUnselected
+ selectedStyle := d.Styles.Editor.QuestionSelected
+
+ return d.drawContent(scr, area, fillPrefix, func(i int, ch question.Choice, active bool, innerWidth int) string {
+ style := unselectedHeader
+ if active {
+ style = selectedStyle
+ }
+ check := d.Styles.Editor.QuestionCheckOff.Render() + " "
+ if d.selected[i] {
+ check = d.Styles.Editor.QuestionCheckOn.Render() + " "
+ }
+ checkWidth := lipgloss.Width(check)
+ barWidth := 2 // "┃ " or " ", applied by buildLines
+ labelIndent := strings.Repeat(" ", checkWidth)
+ return check + style.Render(wrapIndent(ch.Label, innerWidth-barWidth-checkWidth, labelIndent))
+ })
+}
diff --git a/internal/ui/dialog/question_single.go b/internal/ui/dialog/question_single.go
new file mode 100644
index 0000000000..d336016414
--- /dev/null
+++ b/internal/ui/dialog/question_single.go
@@ -0,0 +1,206 @@
+package dialog
+
+import (
+ "fmt"
+ "maps"
+ "strconv"
+ "strings"
+
+ "charm.land/bubbles/v2/key"
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/question"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ uv "github.com/charmbracelet/ultraviolet"
+)
+
+// SingleChoice is an inline single-choice question component.
+// It embeds choiceList for shared navigation, fill-in, and
+// rendering scaffold.
+type SingleChoice struct {
+ choiceList
+
+ keyEnter key.Binding
+
+ lastResponse question.Answer
+}
+
+// NewSingleChoice creates a new inline single-choice component.
+func NewSingleChoice(sty *styles.Styles, req question.Question) *SingleChoice {
+ return &SingleChoice{
+ choiceList: newChoiceList(sty, req),
+ keyEnter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
+ }
+}
+
+// HandleKey processes key events. Returns true when the user has
+// made a selection or dismissed the question.
+func (d *SingleChoice) HandleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
+ // Note editor takes priority when active.
+ if d.activeNoteKey != "" && d.noteEditor.Focused() {
+ cmd, handled := d.handleNoteKey(msg, d.keyClose, func() { d.closeNote(d.noteKey()) })
+ if handled {
+ return false, cmd
+ }
+ }
+
+ if !d.fillIn.Focused() && d.activeNoteKey == "" {
+ if idx := d.numberKeyIndex(msg); idx >= 0 {
+ d.mouseActive = false
+ d.cursorIdx = idx
+ return false, nil
+ }
+ }
+
+ if done, cmd, handled := d.handleFillInFocused(msg, d.keyEnter, func() (bool, tea.Cmd) {
+ d.fillIn.Blur()
+ d.answer(d.respond())
+ return true, nil
+ }, func() (bool, tea.Cmd) {
+ val := strings.TrimSpace(d.fillIn.Value())
+ if val != "" {
+ d.answer(d.respondFillIn(val))
+ return true, nil
+ }
+ return false, nil
+ }); handled {
+ return done, cmd
+ }
+
+ switch {
+ case key.Matches(msg, d.keyClose):
+ d.answer(question.Answer{QuestionID: d.Request.ID})
+ return true, nil
+ case key.Matches(msg, d.keyEnter):
+ d.answer(d.respond())
+ return true, nil
+ case key.Matches(msg, d.keyNote) && !d.fillIn.Focused():
+ return false, d.openNote(d.noteKey())
+ }
+ if d.handleNavKey(msg) {
+ return false, nil
+ }
+ return false, nil
+}
+
+func (d *SingleChoice) answer(resp question.Answer) {
+ d.lastResponse = resp
+}
+
+// Response returns the last response. Used by QuestionForm to
+// collect answers from child components.
+// Response returns the current answer, reflecting live cursor
+// and fill-in state so that tabbing away preserves selections.
+func (d *SingleChoice) Response() question.Answer { return d.respond() }
+
+// GetRequest returns the underlying question request.
+func (d *SingleChoice) GetRequest() question.Question { return d.Request }
+
+func (d *SingleChoice) respond() question.Answer {
+ resp := question.Answer{QuestionID: d.Request.ID}
+ if !d.isFillIn() && len(d.Request.Choices) > 0 {
+ resp.SelectedIDs = []string{d.Request.Choices[d.cursorIdx].ID}
+ }
+ if val := strings.TrimSpace(d.fillIn.Value()); val != "" {
+ resp.FillInText = val
+ }
+ if len(d.notes) > 0 {
+ resp.Notes = make(map[string]string, len(d.notes))
+ maps.Copy(resp.Notes, d.notes)
+ }
+ return resp
+}
+
+func (d *SingleChoice) respondFillIn(text string) question.Answer {
+ return question.Answer{QuestionID: d.Request.ID, FillInText: text}
+}
+
+// numKeyBinding returns a display-only binding showing the valid
+// number shortcut range for the given choice count.
+func numKeyBinding(n int) key.Binding {
+ if n <= 0 {
+ n = 1
+ }
+ if n > 9 {
+ n = 9
+ }
+ label := "1-" + strconv.Itoa(n)
+ return key.NewBinding(key.WithKeys("1-9"), key.WithHelp(label, "quick select"))
+}
+
+// ShortHelp returns key bindings for the status bar.
+func (d *SingleChoice) ShortHelp() []key.Binding {
+ if d.activeNoteKey != "" && d.noteEditor.Focused() {
+ return []key.Binding{d.keyClose, key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "save note"))}
+ }
+ if d.isFillIn() && d.fillIn.Focused() {
+ return []key.Binding{d.navUp, d.keyEnter, d.keyClose}
+ }
+ return []key.Binding{d.keyUp, d.keyDown, d.keyEnter, numKeyBinding(len(d.Request.Choices)), d.keyNote, d.keyClose}
+}
+
+func (d *SingleChoice) Height() int { return d.height(choiceListMaxWidth + 4) }
+func (d *SingleChoice) HeightChanged() bool { return d.heightChanged() }
+func (d *SingleChoice) SetFocused(f bool) { d.setFocused(f) }
+func (d *SingleChoice) SetHover(x, y int) { d.setHover(x, y) }
+func (d *SingleChoice) HandlePaste(msg tea.PasteMsg) tea.Cmd {
+ return d.handlePaste(msg)
+}
+
+// HandleMouseClick checks if the click landed on a choice item and
+// selects it. Does not advance — user can change their selection
+// before pressing Enter or clicking another option.
+func (d *SingleChoice) HandleMouseClick(x, y int) (bool, bool) {
+ if d.choiceCompositor == nil {
+ return false, false
+ }
+ hit := d.choiceCompositor.Hit(x, y)
+ if hit.Empty() {
+ return false, false
+ }
+ var idx int
+ if _, err := fmt.Sscanf(hit.ID(), "choice_%d", &idx); err != nil {
+ return false, false
+ }
+ if idx >= 0 && idx < len(d.Request.Choices) {
+ d.cursorIdx = idx
+ d.mouseActive = false
+ d.suppressScroll = true
+ d.fillIn.Blur()
+ d.answer(d.respond())
+ return false, true
+ }
+ if idx == len(d.Request.Choices) {
+ // Fill-in: focus but don't submit.
+ d.cursorIdx = idx
+ d.mouseActive = false
+ d.suppressScroll = true
+ d.fillIn.Focus()
+ return false, true
+ }
+ return false, false
+}
+
+// Draw renders the single-choice question directly to screen.
+// Returns the cursor position relative to area, or nil.
+func (d *SingleChoice) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+ fillPrefix := d.Styles.Editor.QuestionBody.Render("> ")
+ if d.isFillIn() && strings.TrimSpace(d.fillIn.Value()) != "" {
+ fillPrefix = d.Styles.Editor.QuestionSelected.Render("> ")
+ }
+
+ unselectedHeader := d.Styles.Editor.QuestionUnselected
+ selectedStyle := d.Styles.Editor.QuestionSelected
+
+ return d.drawContent(scr, area, fillPrefix, func(i int, ch question.Choice, active bool, innerWidth int) string {
+ isSelected := false
+ if len(d.lastResponse.SelectedIDs) > 0 {
+ isSelected = d.lastResponse.SelectedIDs[0] == ch.ID
+ }
+ style := unselectedHeader
+ if active || (isSelected && d.mouseActive) {
+ style = selectedStyle
+ }
+ barWidth := 2 // "┃ " or " ", applied by buildLines
+ return style.Render(wrapIndent(ch.Label, innerWidth-barWidth, ""))
+ })
+}
diff --git a/internal/ui/dialog/question_yesno.go b/internal/ui/dialog/question_yesno.go
new file mode 100644
index 0000000000..1a48065b25
--- /dev/null
+++ b/internal/ui/dialog/question_yesno.go
@@ -0,0 +1,230 @@
+package dialog
+
+import (
+ "image"
+ "maps"
+ "strings"
+
+ "charm.land/bubbles/v2/key"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/question"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// YesNo is an inline yes/no confirmation component. For open-ended
+// responses, use FreeText instead. Notes can be added via alt+n.
+type YesNo struct {
+ questionEditor
+ Request question.Question
+ selectedNo bool
+ focused bool
+ compositor *lipgloss.Compositor
+ hoverX int
+ hoverY int
+
+ keyLeftRight key.Binding
+ keyEnter key.Binding
+ keyYes key.Binding
+ keyNo key.Binding
+ keyClose key.Binding
+
+ lastResponse question.Answer
+ lastWidth int
+}
+
+// NewYesNo creates a new inline yes/no question component.
+func NewYesNo(sty *styles.Styles, req question.Question) *YesNo {
+ return &YesNo{
+ questionEditor: newQuestionEditor(sty),
+ Request: req,
+ selectedNo: true, // Default to "No" for safety.
+ keyLeftRight: key.NewBinding(key.WithKeys("left", "right"), key.WithHelp("←/→", "switch")),
+ keyEnter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
+ keyYes: key.NewBinding(key.WithKeys("y", "Y"), key.WithHelp("y", "yes")),
+ keyNo: key.NewBinding(key.WithKeys("n", "N"), key.WithHelp("n", "no")),
+ keyClose: CloseKey,
+ }
+}
+
+// HandleKey processes a key press. Returns true when the user has
+// made a choice or dismissed the question.
+func (d *YesNo) HandleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
+ // Note editor takes priority when active.
+ if d.activeNoteKey != "" && d.noteEditor.Focused() {
+ cmd, handled := d.handleNoteKey(msg, d.keyClose, func() { d.closeNote("_question") })
+ if handled {
+ return false, cmd
+ }
+ }
+
+ switch {
+ case key.Matches(msg, CloseKey):
+ d.answer(question.Answer{QuestionID: d.Request.ID})
+ return true, nil
+ case key.Matches(msg, d.keyLeftRight):
+ d.selectedNo = !d.selectedNo
+ return false, nil
+ case key.Matches(msg, d.keyEnter):
+ d.answer(d.respond(!d.selectedNo))
+ return true, nil
+ case key.Matches(msg, d.keyYes):
+ d.answer(d.respond(true))
+ return true, nil
+ case key.Matches(msg, d.keyNo):
+ d.answer(d.respond(false))
+ return true, nil
+ case key.Matches(msg, d.keyNote):
+ return false, d.openNote("_question")
+ }
+ return false, nil
+}
+
+func (d *YesNo) answer(resp question.Answer) {
+ d.lastResponse = resp
+}
+
+// Response returns the last response. Used by QuestionForm to
+// collect answers from child components.
+// Response returns the current answer, reflecting live selection
+// state so that tabbing away preserves the choice.
+func (d *YesNo) Response() question.Answer { return d.respond(!d.selectedNo) }
+
+// GetRequest returns the underlying question request.
+func (d *YesNo) GetRequest() question.Question { return d.Request }
+
+// ShortHelp returns key bindings for the status bar help display.
+func (d *YesNo) ShortHelp() []key.Binding {
+ if d.activeNoteKey != "" && d.noteEditor.Focused() {
+ return []key.Binding{d.keyClose, key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "save note"))}
+ }
+ return []key.Binding{d.keyLeftRight, d.keyEnter, d.keyYes, d.keyNo, d.keyNote}
+}
+
+func (d *YesNo) respond(yes bool) question.Answer {
+ resp := question.Answer{
+ QuestionID: d.Request.ID,
+ Yes: &yes,
+ }
+ if len(d.notes) > 0 {
+ resp.Notes = make(map[string]string, len(d.notes))
+ maps.Copy(resp.Notes, d.notes)
+ }
+ return resp
+}
+
+// Height returns the visual height at the default max width.
+// Pure function — no render-time state.
+func (d *YesNo) Height() int {
+ w := d.lastWidth
+ if w <= 0 {
+ w = choiceListMaxWidth
+ }
+ iconPrompt := questionIconPrompt(d.Styles, d.focused)
+ h := sectionHeight(d.Request.Text, w-lipgloss.Width(iconPrompt)) // question
+ h++ // blank
+ if d.Request.Description != "" {
+ r := common.MarkdownRenderer(d.Styles, w)
+ mu := common.LockMarkdownRenderer(r)
+ mu.Lock()
+ out, err := r.Render(d.Request.Description)
+ mu.Unlock()
+ if err == nil {
+ out = strings.TrimSuffix(out, "\n")
+ h += strings.Count(out, "\n") + 1
+ } else {
+ h += sectionHeight(d.Request.Description, w)
+ }
+ h++ // blank
+ }
+ h++ // buttons
+ // Note height if present.
+ if d.activeNoteKey != "" && d.noteEditor.Focused() {
+ h++ // blank separator before note editor
+ h += d.noteEditor.Height()
+ } else if len(d.notes) > 0 {
+ h++ // blank separator
+ h++ // saved note text (single line)
+ }
+ h++ // trailing blank for bottom padding
+ return h
+}
+
+// Draw renders the yes/no question directly to screen.
+// Returns the cursor position when the note editor is active, or nil.
+func (d *YesNo) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+ d.lastWidth = area.Dx()
+ y := area.Min.Y
+
+ // Draw question header.
+ iconPrompt := questionIconPrompt(d.Styles, d.focused)
+ qText := iconPrompt + d.Styles.Editor.QuestionUnselected.Render(
+ ansi.Wrap(d.Request.Text, area.Dx()-lipgloss.Width(iconPrompt), ""),
+ )
+ y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), qText)
+ y++ // blank
+
+ // Draw optional description.
+ if d.Request.Description != "" {
+ r := common.MarkdownRenderer(d.Styles, area.Dx())
+ mu := common.LockMarkdownRenderer(r)
+ mu.Lock()
+ desc, err := r.Render(d.Request.Description)
+ mu.Unlock()
+ if err == nil {
+ desc = strings.TrimSuffix(desc, "\n")
+ y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), desc)
+ } else {
+ y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), d.Request.Description)
+ }
+ y++ // blank
+ }
+
+ // Draw buttons. Build compositor first so hover uses current geometry.
+ buttonOptsList := []common.ButtonOpts{
+ {Text: "Yes", Selected: !d.selectedNo, Padding: 3, UnderlineIndex: -1},
+ {Text: "No", Selected: d.selectedNo, Padding: 3, UnderlineIndex: -1},
+ }
+ d.compositor = common.ButtonHitCompositor(d.Styles, buttonOptsList, " ", area.Min.X, y)
+ hoveredBtn := common.HitButtonIndex(d.compositor, d.hoverX, d.hoverY)
+ buttonOptsList[0].Hovered = hoveredBtn == 0
+ buttonOptsList[1].Hovered = hoveredBtn == 1
+ buttons := common.ButtonGroup(d.Styles, buttonOptsList, " ")
+ y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), buttons)
+
+ // Draw note editor or saved note.
+ cur, _ := d.drawStandaloneNote(scr, area, y, "_question")
+ return cur
+}
+
+// HeightChanged always returns false — Height is now pure.
+func (d *YesNo) HeightChanged() bool { return false }
+
+// SetFocused updates the icon style based on whether the editor
+// area is focused.
+func (d *YesNo) SetFocused(focused bool) { d.focused = focused }
+
+// SetHover updates the hover position for button highlighting.
+func (d *YesNo) SetHover(x, y int) { d.hoverX = x; d.hoverY = y }
+
+// HandlePaste forwards paste events to the note editor textarea.
+func (d *YesNo) HandlePaste(msg tea.PasteMsg) tea.Cmd { return d.handlePaste(msg) }
+
+// HandleMouseClick checks if the click landed on a button and
+// triggers the corresponding answer.
+func (d *YesNo) HandleMouseClick(x, y int) (bool, bool) {
+ switch common.HitButtonIndex(d.compositor, x, y) {
+ case 0: // Yes
+ d.selectedNo = false
+ d.answer(d.respond(true))
+ return false, true
+ case 1: // No
+ d.selectedNo = true
+ d.answer(d.respond(false))
+ return false, true
+ }
+ return false, false
+}
diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go
index ebf377035e..82de1854fa 100644
--- a/internal/ui/model/keys.go
+++ b/internal/ui/model/keys.go
@@ -65,6 +65,7 @@ type KeyMap struct {
Sessions key.Binding
Tab key.Binding
ToggleYolo key.Binding
+ ShiftTab key.Binding
}
func DefaultKeyMap() KeyMap {
@@ -101,6 +102,10 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("ctrl+y"),
key.WithHelp("ctrl+y", "toggle yolo"),
),
+ ShiftTab: key.NewBinding(
+ key.WithKeys("shift+tab"),
+ key.WithHelp("shift+tab", "toggle plan mode"),
+ ),
}
km.Editor.AddFile = key.NewBinding(
diff --git a/internal/ui/model/pills.go b/internal/ui/model/pills.go
index ae3b097258..416e7b37ad 100644
--- a/internal/ui/model/pills.go
+++ b/internal/ui/model/pills.go
@@ -144,6 +144,9 @@ func (m *UI) autoExpandPillsIfReasonable() tea.Cmd {
if !m.hasSession() {
return nil
}
+ if m.activeInline != nil {
+ return nil
+ }
if m.height < pillsHeightReasonableTerminalHeight {
return nil
}
@@ -224,6 +227,11 @@ func (m *UI) pillsAreaHeight() int {
if !m.hasSession() {
return 0
}
+ // Suppress pills when an inline editor (e.g. question form) is active
+ // to avoid competing for screen space.
+ if m.activeInline != nil {
+ return 0
+ }
hasIncomplete := hasIncompleteTodos(m.session.Todos)
hasQueue := m.promptQueue > 0
hasPills := hasIncomplete || hasQueue
@@ -248,6 +256,10 @@ func (m *UI) renderPills() {
if !m.hasSession() {
return
}
+ // Suppress pills when an inline editor (e.g. question form) is active.
+ if m.activeInline != nil {
+ return
+ }
width := m.layout.pills.Dx()
if width <= 0 {
diff --git a/internal/ui/model/status.go b/internal/ui/model/status.go
index cf53294cb9..7231b028ff 100644
--- a/internal/ui/model/status.go
+++ b/internal/ui/model/status.go
@@ -1,6 +1,7 @@
package model
import (
+ "image"
"strings"
"time"
@@ -18,11 +19,13 @@ const DefaultStatusTTL = 5 * time.Second
// Status is the status bar and help model.
type Status struct {
- com *common.Common
- hideHelp bool
- help help.Model
- helpKm help.KeyMap
- msg util.InfoMsg
+ com *common.Common
+ hideHelp bool
+ help help.Model
+ helpKm help.KeyMap
+ msg util.InfoMsg
+ planMode bool
+ planReady bool
}
// NewStatus creates a new status bar and help model.
@@ -45,6 +48,17 @@ func (s *Status) ClearInfoMsg() {
s.msg = util.InfoMsg{}
}
+// SetMode updates the plan mode indicator shown in the status bar.
+func (s *Status) SetMode(plan bool) {
+ s.planMode = plan
+}
+
+// SetPlanReady marks whether the current plan-mode session has a ready,
+// not-yet-confirmed plan; the badge then reads "plan ready" instead of "plan".
+func (s *Status) SetPlanReady(ready bool) {
+ s.planReady = ready
+}
+
// SetWidth sets the width of the status bar and help view.
func (s *Status) SetWidth(width int) {
helpStyle := s.com.Styles.Status.Help
@@ -67,6 +81,18 @@ func (s *Status) SetHideHelp(hideHelp bool) {
s.hideHelp = hideHelp
}
+// renderModeBadge returns a short styled badge string when plan mode is active,
+// or an empty string otherwise.
+func (s *Status) renderModeBadge() string {
+ if !s.planMode {
+ return ""
+ }
+ if s.planReady {
+ return s.com.Styles.Status.PlanBadge.Render("plan ready")
+ }
+ return s.com.Styles.Status.PlanBadge.Render("plan")
+}
+
// Draw draws the status bar onto the screen.
func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) {
if !s.hideHelp {
@@ -76,6 +102,13 @@ func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) {
// Render notifications
if s.msg.IsEmpty() {
+ badge := s.renderModeBadge()
+ if badge != "" {
+ bw := lipgloss.Width(badge)
+ bx := area.Max.X - bw
+ badgeArea := image.Rectangle{Min: image.Pt(bx, area.Min.Y), Max: area.Max}
+ uv.NewStyledString(badge).Draw(scr, badgeArea)
+ }
return
}
@@ -99,10 +132,13 @@ func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) {
msgStyle = s.com.Styles.Status.SuccessMessage
}
+ badge := s.renderModeBadge()
+ badgeWidth := lipgloss.Width(badge)
+
ind := indStyle.String()
indWidth := lipgloss.Width(ind)
msgPad := msgStyle.GetPaddingLeft() + msgStyle.GetPaddingRight()
- avail := max(0, area.Dx()-indWidth-msgPad)
+ avail := max(0, area.Dx()-indWidth-msgPad-badgeWidth)
msg := strings.Join(strings.Split(s.msg.Msg, "\n"), " ")
msg = ansi.Truncate(msg, avail, "…")
if w := lipgloss.Width(msg); w < avail {
@@ -112,6 +148,12 @@ func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) {
// Draw the info message over the help view
uv.NewStyledString(ind+info).Draw(scr, area)
+
+ if badge != "" {
+ bx := area.Max.X - badgeWidth
+ badgeArea := image.Rectangle{Min: image.Pt(bx, area.Min.Y), Max: area.Max}
+ uv.NewStyledString(badge).Draw(scr, badgeArea)
+ }
}
// clearInfoMsgCmd returns a command that clears the info message after the
diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go
index 9dfe722796..9bafc2cf01 100644
--- a/internal/ui/model/ui.go
+++ b/internal/ui/model/ui.go
@@ -39,6 +39,7 @@ import (
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/pubsub"
+ "github.com/charmbracelet/crush/internal/question"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/skills"
"github.com/charmbracelet/crush/internal/stringext"
@@ -111,6 +112,13 @@ const (
uiChat
)
+type uiInputMode uint8
+
+const (
+ uiInputModeCode uiInputMode = iota
+ uiInputModePlan
+)
+
type openEditorMsg struct {
Text string
}
@@ -186,6 +194,15 @@ type UI struct {
focus uiFocusState
state uiState
+ mode uiInputMode
+
+ // planReadySessionID holds the session whose plan run emitted the
+ // plan-ready marker but has not been confirmed for execution yet. It
+ // lets the user reopen the handoff prompt after dismissing it.
+ planReadySessionID string
+ // modeSwitching is true while the async agent-model update kicked off
+ // by setInputMode is still in flight; sending is blocked meanwhile.
+ modeSwitching bool
keyMap KeyMap
keyenh tea.KeyboardEnhancementsMsg
@@ -209,6 +226,12 @@ type UI struct {
// Editor components
textarea textarea.Model
+ // Active inline editor replaces the textarea when non-nil.
+ activeInline dialog.InlineEditor
+ // inlineCursor stores the cursor from the last inline editor
+ // Draw call, used by the cursor positioning logic below.
+ inlineCursor *tea.Cursor
+
// Attachment list
attachments *attachments.Attachments
@@ -272,6 +295,8 @@ type UI struct {
// mouse highlighting related state
lastClickTime time.Time
+ hoverX int
+ hoverY int
// hyperCredits is the remaining Hyper credits, updated after each prompt.
hyperCredits *int
@@ -352,10 +377,12 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
status := NewStatus(com, ui)
+ ui.mode = uiInputModeCode
ui.setEditorPrompt(com.Workspace.PermissionSkipRequests())
ui.randomizePlaceholders()
ui.textarea.Placeholder = ui.readyPlaceholder
ui.status = status
+ ui.status.SetMode(ui.mode == uiInputModePlan)
// Initialize compact mode from config
ui.forceCompactMode = com.Config().Options.TUI.CompactMode
@@ -581,10 +608,23 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd := m.handleAgentNotification(msg.Payload); cmd != nil {
cmds = append(cmds, cmd)
}
+ case pubsub.Event[notify.RunComplete]:
+ if cmd := m.handlePlanHandoff(msg.Payload); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
case loadSessionMsg:
if m.forceCompactMode {
m.isCompact = true
}
+ // Plan mode is scoped to the session it was enabled in: switching
+ // to another session falls back to code mode and drops any pending
+ // plan handoff. (Loading the session that was just created for the
+ // first plan-mode prompt is not a switch; the IDs match then.)
+ if m.session == nil || m.session.ID != msg.session.ID {
+ if cmd := m.resetPlanModeState(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
m.setState(uiChat, m.focus)
m.session = msg.session
m.sessionFiles = msg.files
@@ -621,6 +661,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
cmds = append(cmds, m.startLSPs(paths))
+ case modeSwitchedMsg:
+ m.modeSwitching = false
+ if msg.err != nil {
+ cmds = append(cmds, util.ReportError(msg.err))
+ break
+ }
+ cmds = append(cmds, util.ReportInfo("input mode: "+msg.label))
+
case sendMessageMsg:
cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
@@ -740,6 +788,19 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case pubsub.Event[permission.PermissionNotification]:
m.handlePermissionNotification(msg.Payload)
+ case pubsub.Event[question.Request]:
+ m.openBatchFormDialog(msg.Payload)
+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ if cmd := m.sendNotification(notification.Notification{
+ Title: "Crush is waiting...",
+ Message: fmt.Sprintf("%d questions need your input", len(msg.Payload.Questions)),
+ }); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ case pubsub.Event[question.Notification]:
+ m.handleQuestionNotification(msg.Payload)
case cancelTimerExpiredMsg:
m.isCanceling = false
case tea.TerminalVersionMsg:
@@ -775,6 +836,26 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
+ // Route clicks to inline editors that support mouse interaction.
+ if m.activeInline != nil {
+ if clickable, ok := m.activeInline.(dialog.MouseClickableEditor); ok {
+ if done, handled := clickable.HandleMouseClick(msg.X, msg.Y); handled {
+ if done {
+ prev := m.activeInline
+ m.activeInline = nil
+ m.textarea.Focus()
+ m.updateLayoutAndSize()
+ if cod, ok := prev.(dialog.CmdOnDone); ok {
+ if c := cod.PendingCmd(); c != nil {
+ cmds = append(cmds, c)
+ }
+ }
+ }
+ return m, tea.Batch(cmds...)
+ }
+ }
+ }
+
if cmd := m.handleClickFocus(msg); cmd != nil {
cmds = append(cmds, cmd)
}
@@ -802,6 +883,17 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
+ // Track hover position for inline editors.
+ if m.activeInline != nil {
+ if m.hoverX != msg.X || m.hoverY != msg.Y {
+ m.hoverX = msg.X
+ m.hoverY = msg.Y
+ if clickable, ok := m.activeInline.(dialog.MouseClickableEditor); ok {
+ clickable.SetHover(msg.X, msg.Y)
+ }
+ }
+ }
+
switch m.state {
case uiChat:
if msg.Y <= 0 {
@@ -924,6 +1016,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
}
case tea.PasteMsg:
+ if m.activeInline != nil && m.focus == uiFocusEditor {
+ if p, ok := m.activeInline.(dialog.PasteableEditor); ok {
+ if cmd := p.HandlePaste(msg); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ return m, tea.Batch(cmds...)
+ }
+ }
if cmd := m.handlePasteMsg(msg); cmd != nil {
cmds = append(cmds, cmd)
}
@@ -1189,7 +1289,11 @@ func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
return nil
case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor):
m.focus = uiFocusEditor
- cmd = m.textarea.Focus()
+ if m.activeInline != nil {
+ m.activeInline.SetFocused(true)
+ } else {
+ cmd = m.textarea.Focus()
+ }
m.chat.Blur()
case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main):
m.focus = uiFocusMain
@@ -1416,6 +1520,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
yolo := !m.com.Workspace.PermissionSkipRequests()
m.com.Workspace.PermissionSetSkipRequests(yolo)
m.setEditorPrompt(yolo)
+ m.status.SetMode(m.mode == uiInputModePlan)
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionSelectNotificationStyle:
cfg := m.com.Config()
@@ -1896,6 +2001,49 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
return m.handleDialogMsg(msg)
}
+ // Tab always toggles focus between editor and chat, even when
+ // an inline editor is active. This lets users collapse the
+ // question form to view chat.
+ if m.activeInline != nil && key.Matches(msg, m.keyMap.Tab) {
+ if m.focus == uiFocusEditor {
+ m.focus = uiFocusMain
+ m.activeInline.SetFocused(false)
+ m.chat.Focus()
+ m.chat.SetSelected(m.chat.Len() - 1)
+ } else {
+ m.focus = uiFocusEditor
+ m.activeInline.SetFocused(true)
+ m.chat.Blur()
+ }
+ m.updateLayoutAndSize()
+ return tea.Batch(cmds...)
+ }
+
+ // Route keys to active inline editor if one is showing.
+ if m.activeInline != nil && m.focus == uiFocusEditor {
+ if done, cmd := m.activeInline.HandleKey(msg); done {
+ prev := m.activeInline
+ m.activeInline = nil
+ m.textarea.Focus()
+ m.updateLayoutAndSize()
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ } else if cod, ok := prev.(dialog.CmdOnDone); ok {
+ if c := cod.PendingCmd(); c != nil {
+ cmds = append(cmds, c)
+ }
+ }
+ } else {
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ if m.activeInline.HeightChanged() {
+ m.updateLayoutAndSize()
+ }
+ }
+ return tea.Batch(cmds...)
+ }
+
// Handle cancel key when agent is busy.
if key.Matches(msg, m.keyMap.Chat.Cancel) {
if m.isAgentBusy() {
@@ -1941,6 +2089,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
}
switch {
+ case key.Matches(msg, m.keyMap.ShiftTab):
+ if cmd := m.toggleInputMode(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
case key.Matches(msg, m.keyMap.Editor.AddImage):
if !m.currentModelSupportsImages() {
break
@@ -1956,6 +2108,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
cmds = append(cmds, m.pasteImageFromClipboard)
case key.Matches(msg, m.keyMap.Editor.SendMessage):
+ if m.modeSwitching {
+ cmds = append(cmds, util.ReportInfo("Switching input mode, one moment..."))
+ break
+ }
prevHeight := m.textarea.Height()
value := m.textarea.Value()
if before, ok := strings.CutSuffix(value, "\\"); ok {
@@ -1981,6 +2137,11 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
attachments := m.attachments.List()
m.attachments.Reset()
if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
+ // Enter on an empty editor while a ready plan is pending
+ // reopens the dismissed handoff prompt.
+ if m.mode == uiInputModePlan && m.hasSession() && m.planReadySessionID == m.session.ID {
+ m.openPlanHandoff()
+ }
return nil
}
@@ -2097,6 +2258,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
}
case uiFocusMain:
switch {
+ case key.Matches(msg, m.keyMap.ShiftTab):
+ if cmd := m.toggleInputMode(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
case key.Matches(msg, m.keyMap.Tab):
m.focus = uiFocusEditor
cmds = append(cmds, m.textarea.Focus())
@@ -2235,8 +2400,21 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
main := uv.NewStyledString(m.landingView())
main.Draw(scr, layout.main)
- editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
- editor.Draw(scr, layout.editor)
+ if m.activeInline != nil {
+ m.activeInline.SetFocused(m.focus == uiFocusEditor)
+ if m.focus == uiFocusEditor {
+ m.inlineCursor = m.activeInline.Draw(scr, layout.editor)
+ } else if qf, ok := m.activeInline.(*dialog.QuestionForm); ok && m.shouldCollapseQuestion(qf) {
+ qf.DrawCollapsed(scr, layout.editor)
+ m.inlineCursor = nil
+ } else {
+ m.inlineCursor = m.activeInline.Draw(scr, layout.editor)
+ }
+ } else {
+ editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
+ editor.Draw(scr, layout.editor)
+ m.inlineCursor = nil
+ }
case uiChat:
if m.isCompact {
@@ -2250,12 +2428,25 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
}
- editorWidth := scr.Bounds().Dx()
- if !m.isCompact {
- editorWidth -= layout.sidebar.Dx()
+ if m.activeInline != nil {
+ m.activeInline.SetFocused(m.focus == uiFocusEditor)
+ if m.focus == uiFocusEditor {
+ m.inlineCursor = m.activeInline.Draw(scr, layout.editor)
+ } else if qf, ok := m.activeInline.(*dialog.QuestionForm); ok && m.shouldCollapseQuestion(qf) {
+ qf.DrawCollapsed(scr, layout.editor)
+ m.inlineCursor = nil
+ } else {
+ m.inlineCursor = m.activeInline.Draw(scr, layout.editor)
+ }
+ } else {
+ editorWidth := scr.Bounds().Dx()
+ if !m.isCompact {
+ editorWidth -= layout.sidebar.Dx()
+ }
+ editor := uv.NewStyledString(m.renderEditorView(editorWidth))
+ editor.Draw(scr, layout.editor)
+ m.inlineCursor = nil
}
- editor := uv.NewStyledString(m.renderEditorView(editorWidth))
- editor.Draw(scr, layout.editor)
// Draw details overlay in compact mode when open
if m.isCompact && m.detailsOpen {
@@ -2317,6 +2508,15 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
return nil
}
+ if m.activeInline != nil {
+ if cur := m.inlineCursor; cur != nil {
+ cur.X++ // Adjust for app margins
+ cur.Y += m.layout.editor.Min.Y // Inline editor draws from area top
+ return cur
+ }
+ return nil
+ }
+
if m.textarea.Focused() {
cur := m.textarea.Cursor()
cur.X++ // Adjust for app margins
@@ -2334,7 +2534,11 @@ func (m *UI) View() tea.View {
if !m.isTransparent {
v.BackgroundColor = m.com.Styles.Background
}
- v.MouseMode = tea.MouseModeCellMotion
+ if m.activeInline != nil {
+ v.MouseMode = tea.MouseModeAllMotion
+ } else {
+ v.MouseMode = tea.MouseModeCellMotion
+ }
v.ReportFocus = m.caps.ReportFocusEvents
v.WindowTitle = "crush " + home.Short(m.com.Workspace.WorkingDir())
@@ -2364,6 +2568,12 @@ func (m *UI) View() tea.View {
func (m *UI) ShortHelp() []key.Binding {
var binds []key.Binding
k := &m.keyMap
+
+ // When an inline editor is active, show its help.
+ if m.activeInline != nil {
+ return m.activeInline.ShortHelp()
+ }
+
tab := k.Tab
commands := k.Commands
if m.focus == uiFocusEditor && m.textarea.Value() == "" {
@@ -2394,6 +2604,7 @@ func (m *UI) ShortHelp() []key.Binding {
binds = append(
binds,
tab,
+ k.ShiftTab,
commands,
k.Models,
)
@@ -2421,8 +2632,8 @@ func (m *UI) ShortHelp() []key.Binding {
// TODO: other states
// if m.session == nil {
// no session selected
- binds = append(
- binds,
+ binds = append(binds,
+ k.ShiftTab,
commands,
k.Models,
k.Editor.Newline,
@@ -2440,6 +2651,11 @@ func (m *UI) ShortHelp() []key.Binding {
// FullHelp implements [help.KeyMap].
func (m *UI) FullHelp() [][]key.Binding {
+ // When an inline editor is active, show its help.
+ if m.activeInline != nil {
+ return [][]key.Binding{m.activeInline.ShortHelp()}
+ }
+
var binds [][]key.Binding
k := &m.keyMap
help := k.Help
@@ -2480,6 +2696,7 @@ func (m *UI) FullHelp() [][]key.Binding {
mainBinds = append(
mainBinds,
tab,
+ k.ShiftTab,
commands,
k.Models,
k.Sessions,
@@ -2542,6 +2759,7 @@ func (m *UI) FullHelp() [][]key.Binding {
binds = append(
binds,
[]key.Binding{
+ k.ShiftTab,
commands,
k.Models,
k.Sessions,
@@ -2697,7 +2915,17 @@ func (m *UI) generateLayout(w, h int) uiLayout {
// The help height
helpHeight := 1
// The editor height: textarea height + margin for attachments and bottom spacing.
+ // When an inline editor is active, use its height instead.
editorHeight := m.textarea.Height() + editorHeightMargin
+ if m.activeInline != nil {
+ if m.focus == uiFocusEditor {
+ editorHeight = m.activeInline.Height()
+ } else if qf, ok := m.activeInline.(*dialog.QuestionForm); ok && m.shouldCollapseQuestion(qf) {
+ editorHeight = qf.CollapsedHeight() + 1
+ } else {
+ editorHeight = m.activeInline.Height()
+ }
+ }
// The sidebar width
sidebarWidth := 30
// The header height
@@ -2947,8 +3175,9 @@ func (m *UI) openEditor(value string) tea.Cmd {
})
}
-// setEditorPrompt configures the textarea prompt function based on whether
-// yolo mode is enabled.
+// setEditorPrompt configures the textarea prompt icon based on
+// whether yolo mode is enabled. Plan mode is surfaced via the status-bar
+// badge (see Status.renderModeBadge), not the editor prompt.
func (m *UI) setEditorPrompt(yolo bool) {
if yolo {
m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
@@ -2990,6 +3219,51 @@ func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
return t.Editor.PromptYoloDotsBlurred.Render()
}
+func (m *UI) toggleInputMode() tea.Cmd {
+ if m.isAgentBusy() {
+ return util.ReportWarn("Agent is busy, please wait before switching input mode...")
+ }
+ target := uiInputModePlan
+ if m.mode == uiInputModePlan {
+ target = uiInputModeCode
+ }
+ return m.setInputMode(target)
+}
+
+func (m *UI) setInputMode(target uiInputMode) tea.Cmd {
+ label := "plan"
+ agentID := config.AgentPlan
+ if target == uiInputModeCode {
+ label = "code"
+ agentID = config.AgentCoder
+ }
+
+ m.mode = target
+ m.setEditorPrompt(m.com.Workspace.PermissionSkipRequests())
+ if m.status != nil {
+ m.status.SetMode(m.mode == uiInputModePlan)
+ }
+
+ if err := m.com.Workspace.AgentSetMain(agentID); err != nil {
+ return util.ReportError(err)
+ }
+
+ m.modeSwitching = true
+ return func() tea.Msg {
+ return modeSwitchedMsg{
+ label: label,
+ err: m.com.Workspace.UpdateAgentModel(context.Background()),
+ }
+ }
+}
+
+// modeSwitchedMsg reports that the async agent-model update started by
+// setInputMode has finished (successfully or not).
+type modeSwitchedMsg struct {
+ label string
+ err error
+}
+
// closeCompletions closes the completions popup and resets state.
func (m *UI) closeCompletions() {
m.completionsOpen = false
@@ -3257,6 +3531,9 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
return util.ReportError(fmt.Errorf("coder agent is not initialized"))
}
+ // Any new prompt supersedes a pending, unconfirmed plan.
+ m.setPlanReadyPending("")
+
var cmds []tea.Cmd
if !m.hasSession() {
newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
@@ -3523,6 +3800,44 @@ func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
return nil
}
+// openBatchFormDialog activates a tabbed multi-question form in
+// the editor area. Single questions render without tabs or confirm.
+func (m *UI) openBatchFormDialog(batch question.Request) {
+ // Close any existing question form first to prevent stacking.
+ if qf, ok := m.activeInline.(*dialog.QuestionForm); ok && qf != nil {
+ m.activeInline = nil
+ }
+
+ form := dialog.NewQuestionForm(m.com.Styles, batch)
+ form.OnAnswer = func(responses []question.Answer) {
+ m.com.Workspace.QuestionAnswer(responses)
+ }
+ m.activeInline = form
+ m.textarea.Blur()
+ m.focus = uiFocusEditor
+ m.activeInline.SetFocused(true)
+ m.updateLayoutAndSize()
+}
+
+// handleQuestionNotification dismisses an open question form when
+// any client resolved the pending batch. Only one question can be
+// pending at a time, so any notification means the current form
+// is stale regardless of BatchID.
+func (m *UI) handleQuestionNotification(_ question.Notification) {
+ if _, ok := m.activeInline.(*dialog.QuestionForm); ok {
+ m.activeInline = nil
+ m.textarea.Focus()
+ m.updateLayoutAndSize()
+ }
+}
+
+// shouldCollapseQuestion reports whether a question form should render
+// in its collapsed one-line view. This is true only when the form is
+// unfocused and would consume more than half the terminal height.
+func (m *UI) shouldCollapseQuestion(qf *dialog.QuestionForm) bool {
+ return m.focus != uiFocusEditor && m.height > 0 && qf.Height() > m.height*2/5
+}
+
// handlePermissionNotification updates tool items when permission state changes.
func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
if toolItem := m.chat.MessageItem(notification.ToolCallID); toolItem != nil {
@@ -3548,6 +3863,75 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti
}
}
+// handlePlanHandoff checks whether a completed run in plan mode contained the
+// plan-ready sentinel marker and, if so, opens the plan handoff dialog.
+func (m *UI) handlePlanHandoff(rc notify.RunComplete) tea.Cmd {
+ if m.mode != uiInputModePlan {
+ return nil
+ }
+ if rc.Error != "" || rc.Cancelled {
+ return nil
+ }
+ if m.session == nil || rc.SessionID != m.session.ID {
+ return nil
+ }
+ if !common.PlanReadyMarkerPresent(rc.Text) {
+ slog.Debug("Plan run completed without ready marker", "session_id", rc.SessionID)
+ return nil
+ }
+ m.setPlanReadyPending(rc.SessionID)
+ if _, ok := m.activeInline.(*dialog.PlanHandoffInline); ok {
+ return nil
+ }
+ m.openPlanHandoff()
+ return nil
+}
+
+// resetPlanModeState drops any pending plan handoff and, when plan mode is
+// active, switches back to code mode. Used when the UI moves to a different
+// session, since plan mode is scoped to the session it was enabled in.
+func (m *UI) resetPlanModeState() tea.Cmd {
+ m.setPlanReadyPending("")
+ if _, ok := m.activeInline.(*dialog.PlanHandoffInline); ok {
+ m.activeInline = nil
+ m.textarea.Focus()
+ }
+ if m.mode != uiInputModePlan {
+ return nil
+ }
+ return m.setInputMode(uiInputModeCode)
+}
+
+// setPlanReadyPending records (or clears, with an empty ID) the session that
+// has an unconfirmed ready plan and syncs the status-bar badge.
+func (m *UI) setPlanReadyPending(sessionID string) {
+ m.planReadySessionID = sessionID
+ if m.status != nil {
+ m.status.SetPlanReady(sessionID != "")
+ }
+}
+
+// openPlanHandoff replaces the textarea with the inline "switch to code"
+// prompt. Dismissing it keeps the pending plan, so the prompt can be reopened
+// by pressing enter on an empty editor while still in plan mode.
+func (m *UI) openPlanHandoff() {
+ inline := dialog.NewPlanHandoffInline(m.com)
+ inline.OnConfirm = func() tea.Cmd {
+ m.setPlanReadyPending("")
+ return tea.Sequence(
+ m.setInputMode(uiInputModeCode),
+ m.sendMessage("Implement the plan."),
+ )
+ }
+ m.activeInline = inline
+ m.textarea.Blur()
+ m.focus = uiFocusEditor
+ m.activeInline.SetFocused(true)
+ if m.status != nil {
+ m.updateLayoutAndSize()
+ }
+}
+
// handleAgentNotification translates domain agent events into desktop
// notifications using the UI notification backend.
func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
@@ -3593,6 +3977,7 @@ func (m *UI) newSession() tea.Cmd {
return nil
}
+ planCmd := m.resetPlanModeState()
m.session = nil
m.sessionFiles = nil
m.sessionFileReads = nil
@@ -3607,6 +3992,7 @@ func (m *UI) newSession() tea.Cmd {
m.historyReset()
agenttools.ResetCache()
return tea.Batch(
+ planCmd,
func() tea.Msg {
m.com.Workspace.LSPStopAll(context.Background())
return nil
diff --git a/internal/ui/model/ui_test.go b/internal/ui/model/ui_test.go
index 4032c80a05..e4d4f1412d 100644
--- a/internal/ui/model/ui_test.go
+++ b/internal/ui/model/ui_test.go
@@ -1,12 +1,18 @@
package model
import (
+ "context"
"testing"
+ "charm.land/bubbles/v2/textarea"
"charm.land/catwalk/pkg/catwalk"
+ "github.com/charmbracelet/crush/internal/agent/notify"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
+ "github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/ui/dialog"
+ "github.com/charmbracelet/crush/internal/ui/util"
"github.com/charmbracelet/crush/internal/workspace"
"github.com/stretchr/testify/require"
)
@@ -87,9 +93,336 @@ func newTestUIWithConfig(t *testing.T, cfg *config.Config) *UI {
// testWorkspace is a minimal [workspace.Workspace] stub for unit tests.
type testWorkspace struct {
workspace.Workspace
- cfg *config.Config
+ cfg *config.Config
+ setMainCalledWith string
+ updateCalls int
+ agentReady bool
+ agentBusy bool
+}
+
+func (w *testWorkspace) AgentIsReady() bool {
+ return w.agentReady
+}
+
+func (w *testWorkspace) AgentIsBusy() bool {
+ return w.agentBusy
}
func (w *testWorkspace) Config() *config.Config {
return w.cfg
}
+
+func (w *testWorkspace) AgentSetMain(agentID string) error {
+ w.setMainCalledWith = agentID
+ return nil
+}
+
+func (w *testWorkspace) UpdateAgentModel(context.Context) error {
+ w.updateCalls++
+ return nil
+}
+
+func (w *testWorkspace) PermissionSkipRequests() bool {
+ return false
+}
+
+func TestDefaultKeyMapHasShiftTab(t *testing.T) {
+ t.Parallel()
+
+ km := DefaultKeyMap()
+ require.Equal(t, []string{"shift+tab"}, km.ShiftTab.Keys())
+}
+
+func TestToggleInputMode(t *testing.T) {
+ t.Parallel()
+
+ cfg := &config.Config{
+ Providers: csync.NewMap[string, config.ProviderConfig](),
+ }
+ ws := &testWorkspace{cfg: cfg}
+ ui := &UI{
+ com: &common.Common{
+ Workspace: ws,
+ },
+ mode: uiInputModeCode,
+ textarea: textarea.New(),
+ }
+
+ msg := ui.toggleInputMode()()
+ require.NotNil(t, msg)
+ require.Equal(t, uiInputModePlan, ui.mode)
+ require.Equal(t, config.AgentPlan, ws.setMainCalledWith)
+ require.Equal(t, 1, ws.updateCalls)
+
+ msg = ui.toggleInputMode()()
+ require.NotNil(t, msg)
+ require.Equal(t, uiInputModeCode, ui.mode)
+ require.Equal(t, config.AgentCoder, ws.setMainCalledWith)
+ require.Equal(t, 2, ws.updateCalls)
+}
+
+func newPlanUI(t *testing.T, sessionID string) (*UI, *testWorkspace) {
+ t.Helper()
+ cfg := &config.Config{
+ Providers: csync.NewMap[string, config.ProviderConfig](),
+ }
+ ws := &testWorkspace{cfg: cfg}
+ var sess *session.Session
+ if sessionID != "" {
+ s := session.Session{ID: sessionID}
+ sess = &s
+ }
+ u := &UI{
+ com: &common.Common{
+ Workspace: ws,
+ },
+ mode: uiInputModePlan,
+ textarea: textarea.New(),
+ dialog: dialog.NewOverlay(),
+ session: sess,
+ }
+ return u, ws
+}
+
+func isPlanHandoffInline(u *UI) bool {
+ _, ok := u.activeInline.(*dialog.PlanHandoffInline)
+ return ok
+}
+
+func TestHandlePlanHandoff_MarkerOpensInline(t *testing.T) {
+ t.Parallel()
+ u, _ := newPlanUI(t, "sess-1")
+ u.handlePlanHandoff(notify.RunComplete{
+ SessionID: "sess-1",
+ Text: "Here is the plan.\n",
+ })
+ require.True(t, isPlanHandoffInline(u))
+}
+
+func TestHandlePlanHandoff_NoMarkerNoInline(t *testing.T) {
+ t.Parallel()
+ u, _ := newPlanUI(t, "sess-1")
+ u.handlePlanHandoff(notify.RunComplete{
+ SessionID: "sess-1",
+ Text: "Here is the plan without marker.",
+ })
+ require.Nil(t, u.activeInline)
+}
+
+func TestHandlePlanHandoff_MarkerInProseNoInline(t *testing.T) {
+ t.Parallel()
+ u, _ := newPlanUI(t, "sess-1")
+ // The marker mentioned mid-sentence must not trigger a handoff; it
+ // only counts when emitted on a line by itself.
+ u.handlePlanHandoff(notify.RunComplete{
+ SessionID: "sess-1",
+ Text: "I will end with once the plan is done.",
+ })
+ require.Nil(t, u.activeInline)
+}
+
+func TestHandlePlanHandoff_MarkerOwnLineWithTrailingText(t *testing.T) {
+ t.Parallel()
+ u, _ := newPlanUI(t, "sess-1")
+ // Marker on its own line still triggers even with trailing notes.
+ u.handlePlanHandoff(notify.RunComplete{
+ SessionID: "sess-1",
+ Text: "Here is the plan.\n\nLet me know if anything is off.",
+ })
+ require.True(t, isPlanHandoffInline(u))
+}
+
+func TestHandlePlanHandoff_ErrorRunNoInline(t *testing.T) {
+ t.Parallel()
+ u, _ := newPlanUI(t, "sess-1")
+ u.handlePlanHandoff(notify.RunComplete{
+ SessionID: "sess-1",
+ Text: "plan\n",
+ Error: "something went wrong",
+ })
+ require.Nil(t, u.activeInline)
+}
+
+func TestHandlePlanHandoff_CancelledRunNoInline(t *testing.T) {
+ t.Parallel()
+ u, _ := newPlanUI(t, "sess-1")
+ u.handlePlanHandoff(notify.RunComplete{
+ SessionID: "sess-1",
+ Text: "plan\n",
+ Cancelled: true,
+ })
+ require.Nil(t, u.activeInline)
+}
+
+func TestHandlePlanHandoff_SessionMismatchNoInline(t *testing.T) {
+ t.Parallel()
+ u, _ := newPlanUI(t, "sess-1")
+ u.handlePlanHandoff(notify.RunComplete{
+ SessionID: "sess-OTHER",
+ Text: "plan\n",
+ })
+ require.Nil(t, u.activeInline)
+}
+
+func TestHandlePlanHandoff_CodeModeNoInline(t *testing.T) {
+ t.Parallel()
+ u, _ := newPlanUI(t, "sess-1")
+ u.mode = uiInputModeCode
+ u.handlePlanHandoff(notify.RunComplete{
+ SessionID: "sess-1",
+ Text: "plan\n",
+ })
+ require.Nil(t, u.activeInline)
+}
+
+func TestHandlePlanHandoff_DuplicateGuard(t *testing.T) {
+ t.Parallel()
+ u, _ := newPlanUI(t, "sess-1")
+ rc := notify.RunComplete{
+ SessionID: "sess-1",
+ Text: "plan\n",
+ }
+ u.handlePlanHandoff(rc)
+ require.True(t, isPlanHandoffInline(u))
+ first := u.activeInline
+ u.handlePlanHandoff(rc) // guard: must not replace the existing inline
+ require.Same(t, first, u.activeInline)
+}
+
+func TestSetInputMode_SwitchesToCode(t *testing.T) {
+ t.Parallel()
+ cfg := &config.Config{Providers: csync.NewMap[string, config.ProviderConfig]()}
+ ws := &testWorkspace{cfg: cfg}
+ u := &UI{
+ com: &common.Common{Workspace: ws},
+ mode: uiInputModePlan,
+ textarea: textarea.New(),
+ }
+ u.setInputMode(uiInputModeCode)()
+ require.Equal(t, uiInputModeCode, u.mode)
+ require.Equal(t, config.AgentCoder, ws.setMainCalledWith)
+}
+
+func TestSetInputMode_SwitchesToPlan(t *testing.T) {
+ t.Parallel()
+ cfg := &config.Config{Providers: csync.NewMap[string, config.ProviderConfig]()}
+ ws := &testWorkspace{cfg: cfg}
+ u := &UI{
+ com: &common.Common{Workspace: ws},
+ mode: uiInputModeCode,
+ textarea: textarea.New(),
+ }
+ u.setInputMode(uiInputModePlan)()
+ require.Equal(t, uiInputModePlan, u.mode)
+ require.Equal(t, config.AgentPlan, ws.setMainCalledWith)
+}
+
+func TestToggleInputMode_BlockedWhileAgentBusy(t *testing.T) {
+ t.Parallel()
+ u, ws := newPlanUI(t, "sess-1")
+ ws.agentReady = true
+ ws.agentBusy = true
+
+ msg := u.toggleInputMode()()
+ require.Equal(t, uiInputModePlan, u.mode, "mode must not change while the agent is busy")
+ require.Empty(t, ws.setMainCalledWith)
+ info, ok := msg.(util.InfoMsg)
+ require.True(t, ok)
+ require.Equal(t, util.InfoTypeWarn, info.Type)
+}
+
+func TestSetInputMode_TracksModeSwitching(t *testing.T) {
+ t.Parallel()
+ u, _ := newPlanUI(t, "sess-1")
+
+ cmd := u.setInputMode(uiInputModeCode)
+ require.True(t, u.modeSwitching, "flag must be set until the async model update completes")
+
+ msg, ok := cmd().(modeSwitchedMsg)
+ require.True(t, ok)
+ require.NoError(t, msg.err)
+ require.Equal(t, "code", msg.label)
+}
+
+func TestHandlePlanHandoff_SetsPendingPlan(t *testing.T) {
+ t.Parallel()
+ u, _ := newPlanUI(t, "sess-1")
+ u.handlePlanHandoff(notify.RunComplete{
+ SessionID: "sess-1",
+ Text: "plan\n",
+ })
+ require.Equal(t, "sess-1", u.planReadySessionID)
+}
+
+func TestHandlePlanHandoff_DismissKeepsPendingAndReopens(t *testing.T) {
+ t.Parallel()
+ u, _ := newPlanUI(t, "sess-1")
+ u.handlePlanHandoff(notify.RunComplete{
+ SessionID: "sess-1",
+ Text: "plan\n",
+ })
+ require.True(t, isPlanHandoffInline(u))
+
+ // "Keep editing" dismisses the inline prompt but keeps the plan pending.
+ u.activeInline = nil
+ require.Equal(t, "sess-1", u.planReadySessionID)
+
+ // Enter on an empty editor reopens the prompt via openPlanHandoff.
+ u.openPlanHandoff()
+ require.True(t, isPlanHandoffInline(u))
+}
+
+func TestPlanHandoffConfirm_ClearsPendingAndSwitchesMode(t *testing.T) {
+ t.Parallel()
+ u, ws := newPlanUI(t, "sess-1")
+ ws.agentReady = true
+ u.handlePlanHandoff(notify.RunComplete{
+ SessionID: "sess-1",
+ Text: "plan\n",
+ })
+ inline, ok := u.activeInline.(*dialog.PlanHandoffInline)
+ require.True(t, ok)
+
+ cmd := inline.OnConfirm()
+ require.NotNil(t, cmd)
+ require.Equal(t, uiInputModeCode, u.mode)
+ require.Equal(t, config.AgentCoder, ws.setMainCalledWith)
+ require.Empty(t, u.planReadySessionID)
+}
+
+func TestSendMessage_ClearsPendingPlan(t *testing.T) {
+ t.Parallel()
+ u, ws := newPlanUI(t, "sess-1")
+ ws.agentReady = true
+ u.setPlanReadyPending("sess-1")
+
+ cmd := u.sendMessage("a new prompt that supersedes the plan")
+ require.NotNil(t, cmd)
+ require.Empty(t, u.planReadySessionID)
+}
+
+func TestResetPlanModeState(t *testing.T) {
+ t.Parallel()
+ u, ws := newPlanUI(t, "sess-1")
+ u.setPlanReadyPending("sess-1")
+ u.openPlanHandoff()
+ require.True(t, isPlanHandoffInline(u))
+
+ cmd := u.resetPlanModeState()
+ require.NotNil(t, cmd)
+ require.Equal(t, uiInputModeCode, u.mode)
+ require.Equal(t, config.AgentCoder, ws.setMainCalledWith)
+ require.Empty(t, u.planReadySessionID)
+ require.Nil(t, u.activeInline)
+}
+
+func TestResetPlanModeState_NoopInCodeMode(t *testing.T) {
+ t.Parallel()
+ u, ws := newPlanUI(t, "sess-1")
+ u.mode = uiInputModeCode
+
+ cmd := u.resetPlanModeState()
+ require.Nil(t, cmd)
+ require.Equal(t, uiInputModeCode, u.mode)
+ require.Empty(t, ws.setMainCalledWith)
+}
diff --git a/internal/ui/styles/quickstyle.go b/internal/ui/styles/quickstyle.go
index 465916db8f..93217386f8 100644
--- a/internal/ui/styles/quickstyle.go
+++ b/internal/ui/styles/quickstyle.go
@@ -11,6 +11,7 @@ import (
"charm.land/glamour/v2/ansi"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/crush/internal/ui/diffview"
+ uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/exp/charmtone"
)
@@ -332,6 +333,35 @@ func quickStyle(o quickStyleOpts) Styles {
},
}
+ // PlanMarkdown keeps the rich markdown colors but paints the plan-card
+ // background under every primitive, so glamour's per-token SGR resets
+ // cannot punch holes in the card that PlanBox draws around the content.
+ //
+ // H2–H5 in s.Markdown only set Prefix; they rely on glamour inheriting
+ // Color/Bold from the base Heading style. Once withMarkdownBackground
+ // adds BackgroundColor to those primitives, glamour stops inheriting and
+ // the heading text renders without color/bold. Copy them explicitly.
+ planMD := s.Markdown
+ headingColor := hex(o.info)
+ headingBold := new(true)
+ for _, h := range []*ansi.StyleBlock{&planMD.H2, &planMD.H3, &planMD.H4, &planMD.H5} {
+ if h.Color == nil {
+ h.Color = headingColor
+ }
+ if h.Bold == nil {
+ h.Bold = headingBold
+ }
+ }
+ // Replace raw markdown prefixes ("## ", "### ", …) with clean indentation so
+ // the plan card doesn't show literal ## / ### characters. H1 keeps its own
+ // distinct box styling; H2 gets no prefix (top-level sections stand on their
+ // own with bold+color); H3–H5 use increasing indentation for hierarchy.
+ planMD.H2.Prefix = ""
+ planMD.H3.Prefix = " "
+ planMD.H4.Prefix = " "
+ planMD.H5.Prefix = " "
+ s.PlanMarkdown = withMarkdownBackground(planMD, hex(o.bgLeastVisible))
+
// QuietMarkdown style - muted colors on subtle background for thinking content.
plainBg := hex(o.bgLeastVisible)
plainFg := hex(o.fgMoreSubtle)
@@ -691,6 +721,8 @@ func quickStyle(o quickStyleOpts) Styles {
// Buttons
s.Button.Focused = lipgloss.NewStyle().Foreground(o.onPrimary).Background(o.secondary)
s.Button.Blurred = lipgloss.NewStyle().Foreground(o.fgBase).Background(o.bgLessVisible)
+ s.Button.Hovered = lipgloss.NewStyle().Foreground(o.onPrimary).Background(o.fgMostSubtle)
+ s.Button.Negative = lipgloss.NewStyle().Foreground(o.onPrimary).Background(o.error)
// Editor
s.Editor.PromptNormalFocused = lipgloss.NewStyle().Foreground(o.successMostSubtle).SetString("::: ")
@@ -699,11 +731,53 @@ func quickStyle(o quickStyleOpts) Styles {
s.Editor.PromptYoloIconBlurred = s.Editor.PromptYoloIconFocused.Foreground(o.bgBase).Background(o.fgMoreSubtle)
s.Editor.PromptYoloDotsFocused = lipgloss.NewStyle().MarginRight(1).Foreground(o.warningSubtle).SetString(":::")
s.Editor.PromptYoloDotsBlurred = s.Editor.PromptYoloDotsFocused.Foreground(o.fgMoreSubtle)
+ s.Editor.PromptQuestionIconFocused = lipgloss.NewStyle().MarginRight(1).Foreground(o.fgBase).Background(o.primary).Bold(true).SetString(" ? ")
+ s.Editor.PromptQuestionIconBlurred = s.Editor.PromptQuestionIconFocused.Foreground(o.bgBase).Background(o.fgMoreSubtle)
+ s.Editor.QuestionSelected = lipgloss.NewStyle().Foreground(o.secondary).Bold(true)
+ s.Editor.QuestionUnselected = lipgloss.NewStyle().Foreground(o.fgBase)
+ s.Editor.QuestionBody = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
+ s.Editor.QuestionConfirm = lipgloss.NewStyle().Foreground(o.primary).Bold(true)
+ s.Editor.QuestionNote = lipgloss.NewStyle().Foreground(o.fgMostSubtle)
+ s.Editor.QuestionCursorBar = lipgloss.NewStyle().Foreground(o.secondary)
+ s.Editor.QuestionRadioOn = lipgloss.NewStyle().Foreground(o.secondary).SetString(RadioOn)
+ s.Editor.QuestionRadioOff = lipgloss.NewStyle().Foreground(o.fgSubtle).SetString(RadioOff)
+ s.Editor.QuestionCheckOn = lipgloss.NewStyle().Foreground(o.secondary).SetString(RadioOn)
+ s.Editor.QuestionCheckOff = lipgloss.NewStyle().Foreground(o.fgSubtle).SetString(RadioOff)
s.Radio.On = lipgloss.NewStyle().Foreground(o.fgSubtle).SetString(RadioOn)
s.Radio.Off = lipgloss.NewStyle().Foreground(o.fgSubtle).SetString(RadioOff)
s.Radio.Label = lipgloss.NewStyle().Foreground(o.fgSubtle)
+ // Tabs for batch question forms. All borders use charple
+ // (primary). Active tab has an open bottom that merges with
+ // the content area; inactive tabs have a closed bottom. First
+ // tab gets a right-angle bottom-left corner at draw time.
+ borderColor := uv.Style{Fg: o.primary}
+ inactiveBorder := uv.RoundedBorder().Style(borderColor)
+ inactiveBorder.BottomLeft = uv.Side{Content: "┴", Style: borderColor}
+ inactiveBorder.BottomRight = uv.Side{Content: "┴", Style: borderColor}
+ activeBorder := uv.RoundedBorder().Style(borderColor)
+ activeBorder.Bottom = uv.Side{Content: " ", Style: borderColor}
+ activeBorder.BottomLeft = uv.Side{Content: "┘", Style: borderColor}
+ activeBorder.BottomRight = uv.Side{Content: "└", Style: borderColor}
+
+ s.Tab.ActiveBorder = activeBorder
+ s.Tab.InactiveBorder = inactiveBorder
+
+ blurredBorderColor := uv.Style{Fg: o.fgMoreSubtle}
+ inactiveBorderBlurred := uv.RoundedBorder().Style(blurredBorderColor)
+ inactiveBorderBlurred.BottomLeft = uv.Side{Content: "┴", Style: blurredBorderColor}
+ inactiveBorderBlurred.BottomRight = uv.Side{Content: "┴", Style: blurredBorderColor}
+ activeBorderBlurred := uv.RoundedBorder().Style(blurredBorderColor)
+ activeBorderBlurred.Bottom = uv.Side{Content: " ", Style: blurredBorderColor}
+ activeBorderBlurred.BottomLeft = uv.Side{Content: "┘", Style: blurredBorderColor}
+ activeBorderBlurred.BottomRight = uv.Side{Content: "└", Style: blurredBorderColor}
+ s.Tab.ActiveBorderBlurred = activeBorderBlurred
+ s.Tab.InactiveBorderBlurred = inactiveBorderBlurred
+
+ s.Tab.ActiveStyle = uv.Style{Fg: o.fgBase}
+ s.Tab.InactiveStyle = uv.Style{Fg: o.fgMoreSubtle}
+
// Logo
s.Logo.FieldColor = o.primary
s.Logo.TitleColorA = o.secondary
@@ -810,6 +884,9 @@ func quickStyle(o quickStyleOpts) Styles {
s.Messages.AssistantInfoDuration = subtle
s.Messages.AssistantCanceled = lipgloss.NewStyle().Foreground(o.fgBase).Italic(true)
+ // Plan section styles
+ s.Messages.PlanBox = lipgloss.NewStyle().Foreground(o.fgBase).Background(o.bgLeastVisible).Padding(1, 2)
+
// Thinking section styles
s.Messages.ThinkingBox = subtle.Background(o.bgLeastVisible)
s.Messages.ThinkingTruncationHint = muted
@@ -907,6 +984,7 @@ func quickStyle(o quickStyleOpts) Styles {
s.Dialog.Sessions.InfoFocused = lipgloss.NewStyle().Foreground(o.fgBase)
s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
+ s.Status.PlanBadge = lipgloss.NewStyle().Foreground(o.onPrimary).Background(o.primary).Padding(0, 1).Bold(true)
s.Status.SuccessIndicator = base.Foreground(o.bgLessVisible).Background(o.success).Padding(0, 1).Bold(true).SetString("OKAY!")
s.Status.InfoIndicator = s.Status.SuccessIndicator
s.Status.UpdateIndicator = s.Status.SuccessIndicator.SetString("HEY!")
@@ -951,3 +1029,59 @@ func quickStyle(o quickStyleOpts) Styles {
return s
}
+
+// withMarkdownBackground returns a copy of cfg with bg applied to every style
+// primitive that does not already set its own background, so glamour paints an
+// uninterrupted background under all rendered text. Primitives that carry an
+// intentional background of their own (e.g. H1, inline code) keep it.
+//
+// The CodeBlock Chroma section is nilled out so that code blocks render without
+// per-token syntax highlighting. The xchroma formatter is registered globally
+// with a nil/zero background colour; each token's SGR reset would otherwise
+// punch a hole in the CodeBlock background mid-line. Removing Chroma from this
+// style lets glamour fall back to plain-text rendering for code blocks, which
+// keeps the background uninterrupted.
+func withMarkdownBackground(cfg ansi.StyleConfig, bg *string) ansi.StyleConfig {
+ for _, p := range []*ansi.StylePrimitive{
+ &cfg.Document.StylePrimitive,
+ &cfg.BlockQuote.StylePrimitive,
+ &cfg.Paragraph.StylePrimitive,
+ &cfg.Heading.StylePrimitive,
+ &cfg.H1.StylePrimitive,
+ &cfg.H2.StylePrimitive,
+ &cfg.H3.StylePrimitive,
+ &cfg.H4.StylePrimitive,
+ &cfg.H5.StylePrimitive,
+ &cfg.H6.StylePrimitive,
+ &cfg.Text,
+ &cfg.Strikethrough,
+ &cfg.Emph,
+ &cfg.Strong,
+ &cfg.HorizontalRule,
+ &cfg.Item,
+ &cfg.Enumeration,
+ &cfg.Task.StylePrimitive,
+ &cfg.Link,
+ &cfg.LinkText,
+ &cfg.Image,
+ &cfg.ImageText,
+ &cfg.Code.StylePrimitive,
+ &cfg.CodeBlock.StylePrimitive,
+ &cfg.Table.StylePrimitive,
+ &cfg.DefinitionList.StylePrimitive,
+ &cfg.DefinitionTerm,
+ &cfg.DefinitionDescription,
+ &cfg.HTMLBlock.StylePrimitive,
+ &cfg.HTMLSpan.StylePrimitive,
+ } {
+ if p.BackgroundColor == nil {
+ p.BackgroundColor = bg
+ }
+ }
+ // Chroma syntax-highlighting uses the globally registered xchroma formatter,
+ // which hardcodes a nil background per token. Those per-token SGR resets
+ // break the CodeBlock background. Nil out Chroma so code blocks in the plan
+ // card render as plain text with a consistent background.
+ cfg.CodeBlock.Chroma = nil
+ return cfg
+}
diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go
index c2ca9824ba..6f18bb1047 100644
--- a/internal/ui/styles/styles.go
+++ b/internal/ui/styles/styles.go
@@ -14,6 +14,7 @@ import (
"charm.land/lipgloss/v2"
"github.com/alecthomas/chroma/v2"
"github.com/charmbracelet/crush/internal/ui/diffview"
+ uv "github.com/charmbracelet/ultraviolet"
)
const (
@@ -91,6 +92,7 @@ type Styles struct {
// Markdown & Chroma
Markdown ansi.StyleConfig
QuietMarkdown ansi.StyleConfig
+ PlanMarkdown ansi.StyleConfig
// Inputs
TextInput textinput.Styles
@@ -106,8 +108,10 @@ type Styles struct {
// Buttons
Button struct {
- Focused lipgloss.Style
- Blurred lipgloss.Style
+ Focused lipgloss.Style
+ Blurred lipgloss.Style
+ Hovered lipgloss.Style
+ Negative lipgloss.Style // Selected negative/destructive action.
}
// Editor
@@ -123,6 +127,22 @@ type Styles struct {
PromptYoloIconBlurred lipgloss.Style
PromptYoloDotsFocused lipgloss.Style
PromptYoloDotsBlurred lipgloss.Style
+
+ // Question mode prompt (" ? " icon + ":::" dots).
+ PromptQuestionIconFocused lipgloss.Style
+ PromptQuestionIconBlurred lipgloss.Style
+
+ // Question choice styling.
+ QuestionSelected lipgloss.Style // Active choice text (Dolly).
+ QuestionUnselected lipgloss.Style // Inactive header text (Sash).
+ QuestionBody lipgloss.Style // Description/body text.
+ QuestionConfirm lipgloss.Style // Confirm tab title (primary).
+ QuestionNote lipgloss.Style // Saved note text (dimmer than body).
+ QuestionCursorBar lipgloss.Style // Active cursor indicator bar.
+ QuestionRadioOn lipgloss.Style // Selected single-choice radio.
+ QuestionRadioOff lipgloss.Style // Unselected single-choice radio.
+ QuestionCheckOn lipgloss.Style // Checked multi-choice indicator.
+ QuestionCheckOff lipgloss.Style // Unchecked multi-choice indicator.
}
// Radio
@@ -132,6 +152,17 @@ type Styles struct {
Label lipgloss.Style // Text next to a radio button
}
+ // Tabs for batch question forms. Uses uv types for direct
+ // screen rendering without lipgloss.
+ Tab struct {
+ ActiveBorder uv.Border
+ InactiveBorder uv.Border
+ ActiveBorderBlurred uv.Border
+ InactiveBorderBlurred uv.Border
+ ActiveStyle uv.Style
+ InactiveStyle uv.Style
+ }
+
// Background
Background color.Color
@@ -243,6 +274,9 @@ type Styles struct {
ToolCallBlurred lipgloss.Style
SectionHeader lipgloss.Style
+ // Plan section styles
+ PlanBox lipgloss.Style // Background+padding for the final plan message
+
// Thinking section styles
ThinkingBox lipgloss.Style // Background for thinking content
ThinkingTruncationHint lipgloss.Style // "… (N lines hidden)" hint
@@ -480,7 +514,8 @@ type Styles struct {
// Status bar and help
Status struct {
- Help lipgloss.Style
+ Help lipgloss.Style
+ PlanBadge lipgloss.Style
ErrorIndicator lipgloss.Style
WarnIndicator lipgloss.Style
diff --git a/internal/workspace/app_workspace.go b/internal/workspace/app_workspace.go
index c35a9f59fe..9d941ca221 100644
--- a/internal/workspace/app_workspace.go
+++ b/internal/workspace/app_workspace.go
@@ -17,6 +17,7 @@ import (
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/oauth"
"github.com/charmbracelet/crush/internal/permission"
+ "github.com/charmbracelet/crush/internal/question"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/skills"
)
@@ -160,6 +161,13 @@ func (w *AppWorkspace) AgentClearQueue(sessionID string) {
}
}
+func (w *AppWorkspace) AgentSetMain(agentID string) error {
+ if w.app.AgentCoordinator == nil {
+ return errors.New("agent coordinator not initialized")
+ }
+ return w.app.AgentCoordinator.SetMainAgent(agentID)
+}
+
func (w *AppWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
if w.app.AgentCoordinator == nil {
return errors.New("agent coordinator not initialized")
@@ -175,6 +183,10 @@ func (w *AppWorkspace) InitCoderAgent(ctx context.Context) error {
return w.app.InitCoderAgent(ctx)
}
+func (w *AppWorkspace) InitCoderAgentNonInteractive(ctx context.Context) error {
+ return w.app.InitCoderAgentNonInteractive(ctx)
+}
+
func (w *AppWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
return w.app.GetDefaultSmallModel(providerID)
}
@@ -201,6 +213,12 @@ func (w *AppWorkspace) PermissionSetSkipRequests(skip bool) {
w.app.Permissions.SetSkipRequests(skip)
}
+// -- Questions --
+
+func (w *AppWorkspace) QuestionAnswer(responses []question.Answer) bool {
+ return w.app.Questions.Answer(responses)
+}
+
// -- FileTracker --
func (w *AppWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
diff --git a/internal/workspace/client_workspace.go b/internal/workspace/client_workspace.go
index 09ff57c612..50990bd786 100644
--- a/internal/workspace/client_workspace.go
+++ b/internal/workspace/client_workspace.go
@@ -22,6 +22,7 @@ import (
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/proto"
"github.com/charmbracelet/crush/internal/pubsub"
+ "github.com/charmbracelet/crush/internal/question"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/skills"
"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
@@ -245,6 +246,10 @@ func (w *ClientWorkspace) AgentClearQueue(sessionID string) {
_ = w.client.ClearAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
}
+func (w *ClientWorkspace) AgentSetMain(agentID string) error {
+ return fmt.Errorf("set main agent is not supported in client workspace: %s", agentID)
+}
+
func (w *ClientWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
return w.client.AgentSummarizeSession(ctx, w.workspaceID(), sessionID)
}
@@ -254,7 +259,11 @@ func (w *ClientWorkspace) UpdateAgentModel(ctx context.Context) error {
}
func (w *ClientWorkspace) InitCoderAgent(ctx context.Context) error {
- return w.client.InitiateAgentProcessing(ctx, w.workspaceID())
+ return w.client.InitiateAgentProcessing(ctx, w.workspaceID(), true)
+}
+
+func (w *ClientWorkspace) InitCoderAgentNonInteractive(ctx context.Context) error {
+ return w.client.InitiateAgentProcessing(ctx, w.workspaceID(), false)
}
func (w *ClientWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
@@ -330,6 +339,30 @@ func (w *ClientWorkspace) PermissionSetSkipRequests(skip bool) {
_ = w.client.SetPermissionsSkipRequests(context.Background(), w.workspaceID(), skip)
}
+// -- Questions --
+
+// QuestionAnswer submits answers for a question via the client SDK.
+func (w *ClientWorkspace) QuestionAnswer(responses []question.Answer) bool {
+ protoResp := proto.QuestionAnswer{
+ Responses: make([]proto.QuestionResponse, len(responses)),
+ }
+ for i, r := range responses {
+ protoResp.Responses[i] = proto.QuestionResponse{
+ QuestionID: r.QuestionID,
+ SelectedIDs: r.SelectedIDs,
+ FillInText: r.FillInText,
+ Yes: r.Yes,
+ Notes: r.Notes,
+ }
+ }
+ resolved, err := w.client.AnswerQuestionBatch(context.Background(), w.workspaceID(), protoResp)
+ if err != nil {
+ slog.Error("Failed to answer question", "error", err)
+ return false
+ }
+ return resolved
+}
+
// -- FileTracker --
func (w *ClientWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
@@ -688,6 +721,25 @@ func (w *ClientWorkspace) translateEvent(ev any) tea.Msg {
Denied: e.Payload.Denied,
},
}
+ case pubsub.Event[proto.QuestionRequest]:
+ return pubsub.Event[question.Request]{
+ Type: e.Type,
+ Payload: question.Request{
+ ID: e.Payload.ID,
+ SessionID: e.Payload.SessionID,
+ ToolCallID: e.Payload.ToolCallID,
+ Questions: protoQuestionsToDomain(e.Payload.Questions),
+ ConfirmTitle: e.Payload.ConfirmTitle,
+ ConfirmDescription: e.Payload.ConfirmDescription,
+ },
+ }
+ case pubsub.Event[proto.QuestionNotification]:
+ return pubsub.Event[question.Notification]{
+ Type: e.Type,
+ Payload: question.Notification{
+ BatchID: e.Payload.BatchID,
+ },
+ }
case pubsub.Event[proto.Message]:
return pubsub.Event[message.Message]{
Type: e.Type,
@@ -939,3 +991,29 @@ func todosToProto(todos []session.Todo) []proto.Todo {
}
return out
}
+
+func protoQuestionsToDomain(qs []proto.QuestionItem) []question.Question {
+ if len(qs) == 0 {
+ return nil
+ }
+ out := make([]question.Question, len(qs))
+ for i, q := range qs {
+ choices := make([]question.Choice, len(q.Choices))
+ for j, c := range q.Choices {
+ choices[j] = question.Choice{
+ ID: c.ID,
+ Label: c.Label,
+ Description: c.Description,
+ }
+ }
+ out[i] = question.Question{
+ ID: q.ID,
+ Type: question.Type(q.Type),
+ Label: q.Label,
+ Text: q.Question,
+ Description: q.Description,
+ Choices: choices,
+ }
+ }
+ return out
+}
diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go
index 9049b7bc68..83d930d8eb 100644
--- a/internal/workspace/workspace.go
+++ b/internal/workspace/workspace.go
@@ -17,6 +17,7 @@ import (
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/oauth"
"github.com/charmbracelet/crush/internal/permission"
+ "github.com/charmbracelet/crush/internal/question"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/skills"
)
@@ -90,9 +91,11 @@ type Workspace interface {
AgentQueuedPrompts(sessionID string) int
AgentQueuedPromptsList(sessionID string) []string
AgentClearQueue(sessionID string)
+ AgentSetMain(agentID string) error
AgentSummarize(ctx context.Context, sessionID string) error
UpdateAgentModel(ctx context.Context) error
InitCoderAgent(ctx context.Context) error
+ InitCoderAgentNonInteractive(ctx context.Context) error
GetDefaultSmallModel(providerID string) config.SelectedModel
// Permissions
@@ -110,6 +113,11 @@ type Workspace interface {
PermissionSkipRequests() bool
PermissionSetSkipRequests(skip bool)
+ // Questions
+ //
+ // QuestionAnswer resolves the pending question with responses.
+ QuestionAnswer(responses []question.Answer) bool
+
// FileTracker
FileTrackerRecordRead(ctx context.Context, sessionID, path string)
FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time