From 8c3bcc9fb84a32329e13a690592a754b206ccc86 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Thu, 4 Jun 2026 14:57:45 -0400 Subject: [PATCH 01/39] feat(question): add question tool with structured UI --- internal/agent/agenttest/coordinator.go | 14 +- internal/agent/coordinator.go | 12 + internal/agent/tools/question.go | 184 ++++++++ internal/agent/tools/question.md | 102 ++++ internal/agent/tools/question_test.go | 49 ++ internal/app/app.go | 21 + internal/app/testing.go | 2 + internal/config/config.go | 1 + internal/config/load_test.go | 4 +- internal/pubsub/events.go | 1 + internal/question/question.go | 219 +++++++++ internal/ui/chat/question.go | 220 +++++++++ internal/ui/chat/tools.go | 2 + internal/ui/dialog/inline_editor.go | 37 ++ internal/ui/dialog/question_choice_base.go | 490 +++++++++++++++++++ internal/ui/dialog/question_confirm.go | 230 +++++++++ internal/ui/dialog/question_editor.go | 255 ++++++++++ internal/ui/dialog/question_form.go | 522 +++++++++++++++++++++ internal/ui/dialog/question_freetext.go | 181 +++++++ internal/ui/dialog/question_multi.go | 180 +++++++ internal/ui/dialog/question_single.go | 158 +++++++ internal/ui/dialog/question_yesno.go | 194 ++++++++ internal/ui/model/pills.go | 12 + internal/ui/model/ui.go | 154 +++++- internal/ui/styles/quickstyle.go | 41 ++ internal/ui/styles/styles.go | 26 + internal/workspace/app_workspace.go | 7 + internal/workspace/client_workspace.go | 7 + internal/workspace/workspace.go | 6 + 29 files changed, 3313 insertions(+), 18 deletions(-) create mode 100644 internal/agent/tools/question.go create mode 100644 internal/agent/tools/question.md create mode 100644 internal/agent/tools/question_test.go create mode 100644 internal/question/question.go create mode 100644 internal/ui/chat/question.go create mode 100644 internal/ui/dialog/inline_editor.go create mode 100644 internal/ui/dialog/question_choice_base.go create mode 100644 internal/ui/dialog/question_confirm.go create mode 100644 internal/ui/dialog/question_editor.go create mode 100644 internal/ui/dialog/question_form.go create mode 100644 internal/ui/dialog/question_freetext.go create mode 100644 internal/ui/dialog/question_multi.go create mode 100644 internal/ui/dialog/question_single.go create mode 100644 internal/ui/dialog/question_yesno.go diff --git a/internal/agent/agenttest/coordinator.go b/internal/agent/agenttest/coordinator.go index fdacb7e129..26046e359d 100644 --- a/internal/agent/agenttest/coordinator.go +++ b/internal/agent/agenttest/coordinator.go @@ -70,11 +70,13 @@ func NewCoordinator( sessions, messages, permission.NewPermissionService(workingDir, true, nil), - nil, - nil, - nil, - nil, - nil, - nil, + nil, // questions + nil, // history + nil, // filetracker + nil, // lsp + nil, // notifications + nil, // run completions + nil, // skills + false, // interactive ) } diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 86ca09e3bf..6bbc4aa575 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" @@ -107,11 +108,13 @@ 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 @@ -130,12 +133,14 @@ func NewCoordinator( 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], skillsMgr *skills.Manager, + interactive bool, ) (Coordinator, error) { // Skills are pre-discovered by the caller (see app.New / // backend.CreateWorkspace) and passed in via the manager. If no @@ -155,6 +160,7 @@ func NewCoordinator( sessions: sessions, messages: messages, permissions: permissions, + questions: questions, history: history, filetracker: filetracker, lspManager: lspManager, @@ -164,6 +170,7 @@ func NewCoordinator( allSkills: allSkills, activeSkills: activeSkills, skillTracker: skillTracker, + interactive: interactive, } agentCfg, ok := cfg.Config().Agents[config.AgentCoder] @@ -623,6 +630,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)) 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..1217654400 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,7 @@ 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, "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,6 +583,16 @@ 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") @@ -585,12 +604,14 @@ func (app *App) InitCoderAgent(ctx context.Context) error { app.Sessions, app.Messages, app.Permissions, + app.Questions, app.History, app.FileTracker, app.LSPManager, app.agentNotifications, app.runCompletions, app.Skills, + interactive, ) if err != nil { slog.Error("Failed to create coder agent", "err", err) diff --git a/internal/app/testing.go b/internal/app/testing.go index 1722e2b154..0e2023e313 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{}, diff --git a/internal/config/config.go b/internal/config/config.go index abd36f3e71..c845a1e26e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -684,6 +684,7 @@ func allToolNames() []string { "glob", "grep", "ls", + "question", "sourcegraph", "todos", "view", diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 9d67acaeec..88d0b2c741 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -706,7 +706,7 @@ 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) @@ -729,7 +729,7 @@ 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{"agent", "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) diff --git a/internal/pubsub/events.go b/internal/pubsub/events.go index 682672dfb2..7e62f30d29 100644 --- a/internal/pubsub/events.go +++ b/internal/pubsub/events.go @@ -27,6 +27,7 @@ const ( PayloadTypeConfigChanged PayloadType = "config_changed" PayloadTypeSkillsEvent PayloadType = "skills_event" PayloadTypeRunComplete PayloadType = "run_complete" + PayloadTypeQuestionRequest PayloadType = "question_batch_request" ) // 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..afe5c9267c --- /dev/null +++ b/internal/question/question.go @@ -0,0 +1,219 @@ +// 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" + + "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 +) + +// Service manages the lifecycle of question requests. Only one +// question can be pending at a time. +type Service interface { + pubsub.Subscriber[Request] + + // 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] + pending chan []Answer +} + +// NewService creates a new question service. +func NewService() *questionService { + return &questionService{ + broker: pubsub.NewBroker[Request](), + } +} + +// Subscribe returns a channel for question events. +func (s *questionService) Subscribe(ctx context.Context) <-chan pubsub.Event[Request] { + return s.broker.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.pending = make(chan []Answer, 1) + defer func() { s.pending = nil }() + + 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 { + if s.pending == nil { + return false + } + s.pending <- answers + return true +} 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/dialog/inline_editor.go b/internal/ui/dialog/inline_editor.go new file mode 100644 index 0000000000..79898362e7 --- /dev/null +++ b/internal/ui/dialog/inline_editor.go @@ -0,0 +1,37 @@ +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) +} diff --git a/internal/ui/dialog/question_choice_base.go b/internal/ui/dialog/question_choice_base.go new file mode 100644 index 0000000000..3a0a4c1e67 --- /dev/null +++ b/internal/ui/dialog/question_choice_base.go @@ -0,0 +1,490 @@ +package dialog + +import ( + "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 + + // 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, + 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.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.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 +} + +// 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 := contentLine{text: 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 + bar := barInactive + if active { + bar = barActive + } + content := itemFn(i, ch, active) + // 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}) + } + + 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}) + } + } + + // Inline note editor or saved note for this choice. + c.drawNote(&lines, innerWidth, bar, barInactive, ch.ID, active) + + push("") + } + + // 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 + } + c.drawFillIn(&lines, innerWidth, fillBar, barInactive, fillPrefix, c.isFillIn(), false) + + // 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. +type choiceItemRenderer func(index int, choice question.Choice, active bool) 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) 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 +} + +// 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() + + // Reserve a scrollbar column only when content overflows. The + // narrower width can wrap more, so test against it. + contentWidth := area.Dx() + innerNarrow := min(contentWidth-1-4, choiceListMaxWidth) + overflow := viewport > 0 && len(c.buildLines(innerNarrow, fillInPrefix, itemFn)) > viewport + + innerWidth := min(contentWidth-4, choiceListMaxWidth) + if overflow { + contentWidth-- + innerWidth = innerNarrow + } + + lines := c.buildLines(innerWidth, fillInPrefix, itemFn) + 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)) + } + } + + return cur +} + +// 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) { + 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..73e537100e --- /dev/null +++ b/internal/ui/dialog/question_confirm.go @@ -0,0 +1,230 @@ +package dialog + +import ( + "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 + + // 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} +} + +// 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 + 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 + + // Buttons. + buttonOpts := []common.ButtonOpts{ + {Text: "Yup!", Selected: c.confirmYes, Padding: 3, UnderlineIndex: -1}, + {Text: "Not yet", Selected: !c.confirmYes, Padding: 3, UnderlineIndex: -1}, + } + buttons := common.ButtonGroup(c.Styles, buttonOpts, " ") + 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 } + +// 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..230d7c8485 --- /dev/null +++ b/internal/ui/dialog/question_editor.go @@ -0,0 +1,255 @@ +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 + } +} + +// 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}) + } + 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}) + return + } + *lines = append(*lines, contentLine{text: bar + fillPrefix + bodyStyle.Render("Something else?"), cursorItem: isActive}) +} + +// 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}) + } + 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}) + } + } +} + +// 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..8eb45a42a0 --- /dev/null +++ b/internal/ui/dialog/question_form.go @@ -0,0 +1,522 @@ +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 +} + +// 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 + + // 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] + } + } + } + } + + x := area.Min.X + for i, label := range labels { + isActive := i == f.activeIdx + 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 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) + + x += tabWidth + } + + 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 + } + + 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) + } +} diff --git a/internal/ui/dialog/question_freetext.go b/internal/ui/dialog/question_freetext.go new file mode 100644 index 0000000000..577bbdfcf1 --- /dev/null +++ b/internal/ui/dialog/question_freetext.go @@ -0,0 +1,181 @@ +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 last response. +func (d *FreeText) Response() question.Answer { 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 + 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() + } +} diff --git a/internal/ui/dialog/question_multi.go b/internal/ui/dialog/question_multi.go new file mode 100644 index 0000000000..c9f03a831d --- /dev/null +++ b/internal/ui/dialog/question_multi.go @@ -0,0 +1,180 @@ +package dialog + +import ( + "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.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. +func (d *MultiChoice) Response() question.Answer { return d.lastResponse } + +// 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) } + +// 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("> ") + } + + innerWidth := min(area.Dx()-4, choiceListMaxWidth) + unselectedHeader := d.Styles.Editor.QuestionUnselected + selectedStyle := d.Styles.Editor.QuestionSelected + + return d.drawContent(scr, area, fillPrefix, func(i int, ch question.Choice, active bool) string { + style := unselectedHeader + if active { + style = selectedStyle + } + check := d.Styles.Editor.QuestionRadioOff.Render() + " " + if d.selected[i] { + check = d.Styles.Editor.QuestionRadioOn.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..eee17a0835 --- /dev/null +++ b/internal/ui/dialog/question_single.go @@ -0,0 +1,158 @@ +package dialog + +import ( + "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.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. +func (d *SingleChoice) Response() question.Answer { return d.lastResponse } + +// 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 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) } + +// 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("> ") + } + + innerWidth := min(area.Dx()-4, choiceListMaxWidth) + unselectedHeader := d.Styles.Editor.QuestionUnselected + selectedStyle := d.Styles.Editor.QuestionSelected + + return d.drawContent(scr, area, fillPrefix, func(i int, ch question.Choice, active bool) string { + style := unselectedHeader + if active { + 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..8419180d15 --- /dev/null +++ b/internal/ui/dialog/question_yesno.go @@ -0,0 +1,194 @@ +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 + + 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. +func (d *YesNo) Response() question.Answer { return d.lastResponse } + +// 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 line if present. + if d.activeNoteKey != "" || len(d.notes) > 0 { + h++ + } + 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. + buttonOpts := []common.ButtonOpts{ + {Text: "Yes", Selected: !d.selectedNo, Padding: 3}, + {Text: "No", Selected: d.selectedNo, Padding: 3}, + } + buttons := common.ButtonGroup(d.Styles, buttonOpts, " ") + 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 } 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/ui.go b/internal/ui/model/ui.go index 9dfe722796..ddb7fb9910 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" @@ -209,6 +210,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 @@ -740,6 +747,17 @@ 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 cancelTimerExpiredMsg: m.isCanceling = false case tea.TerminalVersionMsg: @@ -1189,7 +1207,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 @@ -1896,6 +1918,41 @@ 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 { + m.activeInline = nil + m.textarea.Focus() + m.updateLayoutAndSize() + } 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() { @@ -2235,8 +2292,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 +2320,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 +2400,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 @@ -2364,6 +2456,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() == "" { @@ -2440,6 +2538,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 @@ -2697,7 +2800,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 +3060,8 @@ 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. func (m *UI) setEditorPrompt(yolo bool) { if yolo { m.textarea.SetPromptFunc(4, m.yoloPromptFunc) @@ -3523,6 +3636,27 @@ 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) { + 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() +} + +// 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 { diff --git a/internal/ui/styles/quickstyle.go b/internal/ui/styles/quickstyle.go index 465916db8f..c90abec287 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" ) @@ -699,11 +700,51 @@ 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.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 diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index c2ca9824ba..7f9492f5de 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 ( @@ -123,6 +124,20 @@ 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 multi-choice bullet. + QuestionRadioOff lipgloss.Style // Unselected multi-choice bullet. } // Radio @@ -132,6 +147,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 diff --git a/internal/workspace/app_workspace.go b/internal/workspace/app_workspace.go index c35a9f59fe..e79ee3970e 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" ) @@ -201,6 +202,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..4c75a3069e 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" @@ -330,6 +331,12 @@ func (w *ClientWorkspace) PermissionSetSkipRequests(skip bool) { _ = w.client.SetPermissionsSkipRequests(context.Background(), w.workspaceID(), skip) } +// -- Questions -- + +func (w *ClientWorkspace) QuestionAnswer(responses []question.Answer) bool { + return false +} + // -- FileTracker -- func (w *ClientWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) { diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 9049b7bc68..045bdf4874 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" ) @@ -110,6 +111,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 From 56f7e4188288732c61145c6de5ee31c5a3dc38d4 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Thu, 4 Jun 2026 14:57:49 -0400 Subject: [PATCH 02/39] feat(question): add client server integration --- internal/app/app.go | 1 + internal/app/testing.go | 4 ++ internal/backend/question.go | 29 ++++++++++++ internal/client/proto.go | 31 ++++++++++++ internal/proto/proto.go | 56 ++++++++++++++++++++++ internal/pubsub/events.go | 1 + internal/question/question.go | 55 +++++++++++++++++++--- internal/server/e2e_test.go | 6 +++ internal/server/events.go | 47 +++++++++++++++++++ internal/server/proto.go | 30 ++++++++++++ internal/server/server.go | 1 + internal/ui/model/ui.go | 19 ++++++++ internal/workspace/client_workspace.go | 65 +++++++++++++++++++++++++- 13 files changed, 338 insertions(+), 7 deletions(-) create mode 100644 internal/backend/question.go diff --git a/internal/app/app.go b/internal/app/app.go index 1217654400..ded4c1633c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -508,6 +508,7 @@ func (app *App) setupEvents() { 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) diff --git a/internal/app/testing.go b/internal/app/testing.go index 0e2023e313..a90c36e073 100644 --- a/internal/app/testing.go +++ b/internal/app/testing.go @@ -45,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/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..d6dff13be7 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) @@ -594,6 +606,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/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/pubsub/events.go b/internal/pubsub/events.go index 7e62f30d29..dfe8b4c3a2 100644 --- a/internal/pubsub/events.go +++ b/internal/pubsub/events.go @@ -28,6 +28,7 @@ const ( 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 index afe5c9267c..923bc8050e 100644 --- a/internal/question/question.go +++ b/internal/question/question.go @@ -10,6 +10,7 @@ package question import ( "context" "fmt" + "sync" "github.com/charmbracelet/crush/internal/pubsub" "github.com/google/uuid" @@ -150,11 +151,21 @@ const ( 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) @@ -164,14 +175,18 @@ type Service interface { } type questionService struct { - broker *pubsub.Broker[Request] - pending chan []Answer + 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](), + broker: pubsub.NewBroker[Request](), + notificationBroker: pubsub.NewBroker[Notification](), } } @@ -180,6 +195,12 @@ func (s *questionService) Subscribe(ctx context.Context) <-chan pubsub.Event[Req 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 == "" { @@ -195,8 +216,17 @@ func (s *questionService) Ask(ctx context.Context, req Request) ([]Answer, error return nil, err } + s.mu.Lock() s.pending = make(chan []Answer, 1) - defer func() { s.pending = nil }() + 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) @@ -211,9 +241,22 @@ func (s *questionService) Ask(ctx context.Context, req Request) ([]Answer, error // Answer resolves the pending question. Returns false if no // question is pending (already answered or cancelled). func (s *questionService) Answer(answers []Answer) bool { - if s.pending == nil { + s.mu.Lock() + batchID := s.pendingID + ch := s.pending + s.mu.Unlock() + + if ch == nil { return false } - s.pending <- answers + 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/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..a37ba3dbd6 100644 --- a/internal/server/proto.go +++ b/internal/server/proto.go @@ -989,6 +989,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/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/ui/model/ui.go b/internal/ui/model/ui.go index ddb7fb9910..0fc6b54928 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -758,6 +758,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }); cmd != nil { cmds = append(cmds, cmd) } + case pubsub.Event[question.Notification]: + m.handleQuestionNotification(msg.Payload) case cancelTimerExpiredMsg: m.isCanceling = false case tea.TerminalVersionMsg: @@ -3639,6 +3641,11 @@ func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd { // 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) @@ -3650,6 +3657,18 @@ func (m *UI) openBatchFormDialog(batch question.Request) { 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. diff --git a/internal/workspace/client_workspace.go b/internal/workspace/client_workspace.go index 4c75a3069e..31feb8f356 100644 --- a/internal/workspace/client_workspace.go +++ b/internal/workspace/client_workspace.go @@ -333,8 +333,26 @@ func (w *ClientWorkspace) PermissionSetSkipRequests(skip bool) { // -- Questions -- +// QuestionAnswer submits answers for a question via the client SDK. func (w *ClientWorkspace) QuestionAnswer(responses []question.Answer) bool { - return false + 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 -- @@ -695,6 +713,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, @@ -946,3 +983,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 +} From 6b0f52e717c7dc5baf62c6ff14d687e7ef03a312 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Fri, 5 Jun 2026 14:24:08 -0400 Subject: [PATCH 03/39] feat(question): add mouse support --- internal/ui/common/button.go | 52 ++++++++- internal/ui/dialog/inline_editor.go | 16 +++ internal/ui/dialog/question_choice_base.go | 119 +++++++++++++++++++-- internal/ui/dialog/question_confirm.go | 74 +++++++++++-- internal/ui/dialog/question_editor.go | 10 +- internal/ui/dialog/question_form.go | 95 ++++++++++++++++ internal/ui/dialog/question_freetext.go | 16 ++- internal/ui/dialog/question_multi.go | 48 ++++++++- internal/ui/dialog/question_single.go | 50 ++++++++- internal/ui/dialog/question_yesno.go | 41 +++++-- internal/ui/model/ui.go | 33 +++++- internal/ui/styles/quickstyle.go | 4 + internal/ui/styles/styles.go | 12 ++- 13 files changed, 528 insertions(+), 42 deletions(-) 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/dialog/inline_editor.go b/internal/ui/dialog/inline_editor.go index 79898362e7..caaad1d1ab 100644 --- a/internal/ui/dialog/inline_editor.go +++ b/internal/ui/dialog/inline_editor.go @@ -35,3 +35,19 @@ type InlineEditor interface { // focused. SetFocused(focused bool) } + +// 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) +} diff --git a/internal/ui/dialog/question_choice_base.go b/internal/ui/dialog/question_choice_base.go index 3a0a4c1e67..335d979d16 100644 --- a/internal/ui/dialog/question_choice_base.go +++ b/internal/ui/dialog/question_choice_base.go @@ -1,6 +1,7 @@ package dialog import ( + "fmt" "image" "strconv" "strings" @@ -36,10 +37,15 @@ type choiceList struct { questionEditor Request question.Question - cursorIdx int - scrollOffset int // lines scrolled past the top of the viewport - focused bool - lastWidth int + 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 @@ -71,6 +77,9 @@ 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, @@ -88,6 +97,7 @@ func (c *choiceList) isFillIn() bool { // 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()) @@ -101,6 +111,7 @@ func (c *choiceList) moveUp() { // 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()) @@ -179,6 +190,13 @@ type contentLine struct { 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 @@ -226,7 +244,7 @@ func (c *choiceList) buildLines(innerWidth int, fillInPrefix string, itemFn choi var lines []contentLine push := func(text string, flags ...bool) { - cl := contentLine{text: text} + cl := newContentLine(text) if len(flags) > 0 { cl.fillInRow = flags[0] } @@ -256,9 +274,10 @@ func (c *choiceList) buildLines(innerWidth int, fillInPrefix string, itemFn choi // Choices: label row(s), optional wrapped description, note, blank. for i, ch := range c.Request.Choices { - active := i == c.cursorIdx + active := i == c.cursorIdx && !c.mouseActive + hovered := i == c.hoveredChoice && c.mouseActive bar := barInactive - if active { + if active || hovered { bar = barActive } content := itemFn(i, ch, active) @@ -269,7 +288,7 @@ func (c *choiceList) buildLines(innerWidth int, fillInPrefix string, itemFn choi if j > 0 && !active { b = barInactive } - lines = append(lines, contentLine{text: b + ln, cursorItem: active}) + lines = append(lines, contentLine{text: b + ln, cursorItem: active, choiceIdx: i}) } if ch.Description != "" { @@ -279,14 +298,16 @@ func (c *choiceList) buildLines(innerWidth int, fillInPrefix string, itemFn choi if j > 0 && !active { b = barInactive } - lines = append(lines, contentLine{text: b + ln, cursorItem: active}) + 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) - push("") + // 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. @@ -301,8 +322,16 @@ func (c *choiceList) buildLines(innerWidth int, fillInPrefix string, itemFn choi 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("") @@ -348,6 +377,25 @@ 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) @@ -411,15 +459,66 @@ func (c *choiceList) drawContent(scr uv.Screen, area uv.Rectangle, fillInPrefix } } + // 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 diff --git a/internal/ui/dialog/question_confirm.go b/internal/ui/dialog/question_confirm.go index 73e537100e..e41fdb750c 100644 --- a/internal/ui/dialog/question_confirm.go +++ b/internal/ui/dialog/question_confirm.go @@ -1,6 +1,7 @@ package dialog import ( + "fmt" "image" "strings" @@ -32,8 +33,11 @@ type ConfirmComponent struct { keyEnter key.Binding keyClose key.Binding - focused bool - lastWidth int + focused bool + lastWidth int + compositor *lipgloss.Compositor + hoverX int + hoverY int // OnConfirm is called when the user confirms. OnConfirm func() @@ -100,6 +104,17 @@ 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 @@ -125,8 +140,12 @@ func (c *ConfirmComponent) Height() int { } h += len(c.QuestionLabels) // one bullet per question h++ // blank - h++ // buttons - h++ // bottom margin + if c.unansweredCount() > 0 { + h++ // warning line + h++ // blank after warning + } + h++ // buttons + h++ // bottom margin return h } @@ -168,12 +187,29 @@ func (c *ConfirmComponent) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { } y++ // blank - // Buttons. - buttonOpts := []common.ButtonOpts{ + // 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}, } - buttons := common.ButtonGroup(c.Styles, buttonOpts, " ") + 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 @@ -185,6 +221,30 @@ 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) { diff --git a/internal/ui/dialog/question_editor.go b/internal/ui/dialog/question_editor.go index 230d7c8485..7e9ece88a3 100644 --- a/internal/ui/dialog/question_editor.go +++ b/internal/ui/dialog/question_editor.go @@ -130,7 +130,7 @@ func (e *questionEditor) drawFillIn(lines *[]contentLine, innerWidth int, bar, b if j > 0 { text = barInactive + indent + tl } - *lines = append(*lines, contentLine{text: text, fillInRow: j == 0, cursorItem: true}) + *lines = append(*lines, contentLine{text: text, fillInRow: j == 0, cursorItem: true, choiceIdx: -1}) } return } @@ -141,10 +141,10 @@ func (e *questionEditor) drawFillIn(lines *[]contentLine, innerWidth int, bar, b if styleFilled { rendered = e.Styles.Editor.QuestionSelected.Render(val) } - *lines = append(*lines, contentLine{text: bar + fillPrefix + rendered, cursorItem: isActive}) + *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}) + *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 @@ -164,7 +164,7 @@ func (e *questionEditor) drawNote(lines *[]contentLine, innerWidth int, bar, bar if j > 0 { text = barInactive + indent + tl } - *lines = append(*lines, contentLine{text: text, noteRow: j == 0, cursorItem: true}) + *lines = append(*lines, contentLine{text: text, noteRow: j == 0, cursorItem: true, choiceIdx: -1}) } return } @@ -172,7 +172,7 @@ func (e *questionEditor) drawNote(lines *[]contentLine, innerWidth int, bar, bar 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}) + *lines = append(*lines, contentLine{text: bar + notePrefix + ln, cursorItem: isActive, choiceIdx: -1}) } } } diff --git a/internal/ui/dialog/question_form.go b/internal/ui/dialog/question_form.go index 8eb45a42a0..c0ac875295 100644 --- a/internal/ui/dialog/question_form.go +++ b/internal/ui/dialog/question_form.go @@ -19,6 +19,8 @@ import ( 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. @@ -43,6 +45,13 @@ type QuestionForm struct { 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) @@ -423,9 +432,27 @@ func (f *QuestionForm) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { } } + // 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 @@ -445,6 +472,11 @@ func (f *QuestionForm) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { } 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 { @@ -464,9 +496,15 @@ func (f *QuestionForm) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { ) 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 { @@ -481,6 +519,8 @@ func (f *QuestionForm) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { } contentY = area.Min.Y + tabHeight + 1 + } else { + f.compositor = nil } contentArea := image.Rect(area.Min.X, contentY, area.Max.X, area.Max.Y) @@ -520,3 +560,58 @@ func (f *QuestionForm) SetFocused(focused bool) { 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) + } +} + +// 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 index 577bbdfcf1..059e0732cf 100644 --- a/internal/ui/dialog/question_freetext.go +++ b/internal/ui/dialog/question_freetext.go @@ -74,8 +74,14 @@ func (d *FreeText) answer(resp question.Answer) { d.lastResponse = resp } -// Response returns the last response. -func (d *FreeText) Response() question.Answer { return d.lastResponse } +// 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 } @@ -179,3 +185,9 @@ func (d *FreeText) SetFocused(focused bool) { 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 } diff --git a/internal/ui/dialog/question_multi.go b/internal/ui/dialog/question_multi.go index c9f03a831d..9249955979 100644 --- a/internal/ui/dialog/question_multi.go +++ b/internal/ui/dialog/question_multi.go @@ -1,6 +1,7 @@ package dialog import ( + "fmt" "maps" "strings" @@ -50,6 +51,7 @@ func (d *MultiChoice) HandleKey(msg tea.KeyPressMsg) (bool, tea.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] { @@ -113,7 +115,9 @@ func (d *MultiChoice) answer(resp question.Answer) { // Response returns the last response. Used by QuestionForm to // collect answers from child components. -func (d *MultiChoice) Response() question.Answer { return d.lastResponse } +// 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 } @@ -150,6 +154,44 @@ func (d *MultiChoice) ShortHelp() []key.Binding { 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) } + +// 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. @@ -168,9 +210,9 @@ func (d *MultiChoice) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { if active { style = selectedStyle } - check := d.Styles.Editor.QuestionRadioOff.Render() + " " + check := d.Styles.Editor.QuestionCheckOff.Render() + " " if d.selected[i] { - check = d.Styles.Editor.QuestionRadioOn.Render() + " " + check = d.Styles.Editor.QuestionCheckOn.Render() + " " } checkWidth := lipgloss.Width(check) barWidth := 2 // "┃ " or " ", applied by buildLines diff --git a/internal/ui/dialog/question_single.go b/internal/ui/dialog/question_single.go index eee17a0835..3325c5cf03 100644 --- a/internal/ui/dialog/question_single.go +++ b/internal/ui/dialog/question_single.go @@ -1,6 +1,7 @@ package dialog import ( + "fmt" "maps" "strconv" "strings" @@ -44,6 +45,7 @@ func (d *SingleChoice) HandleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { if !d.fillIn.Focused() && d.activeNoteKey == "" { if idx := d.numberKeyIndex(msg); idx >= 0 { + d.mouseActive = false d.cursorIdx = idx return false, nil } @@ -86,7 +88,9 @@ func (d *SingleChoice) answer(resp question.Answer) { // Response returns the last response. Used by QuestionForm to // collect answers from child components. -func (d *SingleChoice) Response() question.Answer { return d.lastResponse } +// 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 } @@ -96,6 +100,9 @@ func (d *SingleChoice) respond() question.Answer { 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) @@ -134,6 +141,41 @@ func (d *SingleChoice) ShortHelp() []key.Binding { 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) } + +// 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. @@ -148,8 +190,12 @@ func (d *SingleChoice) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { selectedStyle := d.Styles.Editor.QuestionSelected return d.drawContent(scr, area, fillPrefix, func(i int, ch question.Choice, active bool) string { + isSelected := false + if len(d.lastResponse.SelectedIDs) > 0 { + isSelected = d.lastResponse.SelectedIDs[0] == ch.ID + } style := unselectedHeader - if active { + if active || (isSelected && d.mouseActive) { style = selectedStyle } barWidth := 2 // "┃ " or " ", applied by buildLines diff --git a/internal/ui/dialog/question_yesno.go b/internal/ui/dialog/question_yesno.go index 8419180d15..d10469fbee 100644 --- a/internal/ui/dialog/question_yesno.go +++ b/internal/ui/dialog/question_yesno.go @@ -22,6 +22,9 @@ type YesNo struct { Request question.Question selectedNo bool focused bool + compositor *lipgloss.Compositor + hoverX int + hoverY int keyLeftRight key.Binding keyEnter key.Binding @@ -86,7 +89,9 @@ func (d *YesNo) answer(resp question.Answer) { // Response returns the last response. Used by QuestionForm to // collect answers from child components. -func (d *YesNo) Response() question.Answer { return d.lastResponse } +// 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 } @@ -136,6 +141,7 @@ func (d *YesNo) Height() int { h++ // blank } h++ // buttons + h++ // trailing blank for bottom padding // Note line if present. if d.activeNoteKey != "" || len(d.notes) > 0 { h++ @@ -173,12 +179,16 @@ func (d *YesNo) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { y++ // blank } - // Draw buttons. - buttonOpts := []common.ButtonOpts{ - {Text: "Yes", Selected: !d.selectedNo, Padding: 3}, - {Text: "No", Selected: d.selectedNo, Padding: 3}, + // 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}, } - buttons := common.ButtonGroup(d.Styles, buttonOpts, " ") + 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. @@ -192,3 +202,22 @@ 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 } + +// 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/ui.go b/internal/ui/model/ui.go index 0fc6b54928..217148718a 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -279,6 +279,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 @@ -795,6 +797,20 @@ 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 { + m.activeInline = nil + m.textarea.Focus() + m.updateLayoutAndSize() + } + return m, tea.Batch(cmds...) + } + } + } + if cmd := m.handleClickFocus(msg); cmd != nil { cmds = append(cmds, cmd) } @@ -822,6 +838,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 { @@ -2428,7 +2455,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()) diff --git a/internal/ui/styles/quickstyle.go b/internal/ui/styles/quickstyle.go index c90abec287..701157b482 100644 --- a/internal/ui/styles/quickstyle.go +++ b/internal/ui/styles/quickstyle.go @@ -692,6 +692,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("::: ") @@ -710,6 +712,8 @@ func quickStyle(o quickStyleOpts) Styles { 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) diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 7f9492f5de..5055ec3a2f 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -107,8 +107,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 @@ -136,8 +138,10 @@ type Styles struct { 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 multi-choice bullet. - QuestionRadioOff lipgloss.Style // Unselected multi-choice bullet. + 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 From 48baac4ceab89ce75ac19ac2677e0e0630efbe60 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Tue, 9 Jun 2026 11:10:20 -0400 Subject: [PATCH 04/39] feat(question): add paste support in text areas --- internal/ui/dialog/inline_editor.go | 9 +++++++++ internal/ui/dialog/question_editor.go | 17 +++++++++++++++++ internal/ui/dialog/question_form.go | 14 ++++++++++++++ internal/ui/dialog/question_freetext.go | 8 ++++++++ internal/ui/dialog/question_multi.go | 3 +++ internal/ui/dialog/question_single.go | 3 +++ internal/ui/dialog/question_yesno.go | 15 +++++++++++---- internal/ui/model/ui.go | 8 ++++++++ 8 files changed, 73 insertions(+), 4 deletions(-) diff --git a/internal/ui/dialog/inline_editor.go b/internal/ui/dialog/inline_editor.go index caaad1d1ab..999b66dcbc 100644 --- a/internal/ui/dialog/inline_editor.go +++ b/internal/ui/dialog/inline_editor.go @@ -51,3 +51,12 @@ type MouseClickableEditor interface { // 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/question_editor.go b/internal/ui/dialog/question_editor.go index 7e9ece88a3..927dfb7a6c 100644 --- a/internal/ui/dialog/question_editor.go +++ b/internal/ui/dialog/question_editor.go @@ -113,6 +113,23 @@ func (e *questionEditor) handleNoteKey(msg tea.KeyPressMsg, closeKey key.Binding } } +// 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 diff --git a/internal/ui/dialog/question_form.go b/internal/ui/dialog/question_form.go index c0ac875295..3cb0a7f605 100644 --- a/internal/ui/dialog/question_form.go +++ b/internal/ui/dialog/question_form.go @@ -573,6 +573,20 @@ func (f *QuestionForm) SetHover(x, y int) { } } +// 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. diff --git a/internal/ui/dialog/question_freetext.go b/internal/ui/dialog/question_freetext.go index 059e0732cf..0807225e8a 100644 --- a/internal/ui/dialog/question_freetext.go +++ b/internal/ui/dialog/question_freetext.go @@ -115,6 +115,7 @@ func (d *FreeText) Height() int { h++ // blank } h += d.editor.Height() // textarea + h++ // trailing blank for bottom padding return h } @@ -191,3 +192,10 @@ 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 index 9249955979..038c8cc349 100644 --- a/internal/ui/dialog/question_multi.go +++ b/internal/ui/dialog/question_multi.go @@ -155,6 +155,9 @@ func (d *MultiChoice) Height() int { return d.height(choiceListMaxWidth 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 diff --git a/internal/ui/dialog/question_single.go b/internal/ui/dialog/question_single.go index 3325c5cf03..120d8ec1f5 100644 --- a/internal/ui/dialog/question_single.go +++ b/internal/ui/dialog/question_single.go @@ -142,6 +142,9 @@ func (d *SingleChoice) Height() int { return d.height(choiceListMaxWidth 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 diff --git a/internal/ui/dialog/question_yesno.go b/internal/ui/dialog/question_yesno.go index d10469fbee..1a48065b25 100644 --- a/internal/ui/dialog/question_yesno.go +++ b/internal/ui/dialog/question_yesno.go @@ -141,11 +141,15 @@ func (d *YesNo) Height() int { h++ // blank } h++ // buttons - h++ // trailing blank for bottom padding - // Note line if present. - if d.activeNoteKey != "" || len(d.notes) > 0 { - h++ + // 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 } @@ -206,6 +210,9 @@ 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) { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 217148718a..89abfffda2 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -971,6 +971,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) } From 1005606bf94ed1ef2a35f37927635992d6d19792 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Fri, 12 Jun 2026 10:34:49 -0400 Subject: [PATCH 05/39] refactor: make the coordinator use a struct --- internal/agent/agenttest/coordinator.go | 21 +++----- internal/agent/coordinator.go | 66 +++++++++++++------------ internal/app/app.go | 29 ++++++----- 3 files changed, 55 insertions(+), 61 deletions(-) diff --git a/internal/agent/agenttest/coordinator.go b/internal/agent/agenttest/coordinator.go index 26046e359d..618e8dfec5 100644 --- a/internal/agent/agenttest/coordinator.go +++ b/internal/agent/agenttest/coordinator.go @@ -64,19 +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, // questions - nil, // history - nil, // filetracker - nil, // lsp - nil, // notifications - nil, // run completions - nil, // skills - false, // interactive - ) + 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 6bbc4aa575..19d39407a2 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -127,53 +127,57 @@ type coordinator struct { readyWg errgroup.Group } -func NewCoordinator( - ctx context.Context, - cfg *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], - skillsMgr *skills.Manager, - interactive bool, -) (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, - questions: questions, - 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: interactive, + interactive: opts.Interactive, } - agentCfg, ok := cfg.Config().Agents[config.AgentCoder] + agentCfg, ok := opts.Config.Config().Agents[config.AgentCoder] if !ok { return nil, errCoderAgentNotConfigured } diff --git a/internal/app/app.go b/internal/app/app.go index ded4c1633c..b75523663e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -599,21 +599,20 @@ func (app *App) initCoderAgent(ctx context.Context, interactive bool) error { 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.Questions, - app.History, - app.FileTracker, - app.LSPManager, - app.agentNotifications, - app.runCompletions, - app.Skills, - interactive, - ) + 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 From 2edbf290283493be99b46b36e458475252b5ec8b Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Fri, 12 Jun 2026 10:48:07 -0400 Subject: [PATCH 06/39] bug(client/server): fix non interactive init --- internal/backend/agent.go | 7 +++++-- internal/client/proto.go | 5 +++-- internal/cmd/run.go | 13 +++++++++---- internal/proto/requests.go | 5 +++++ internal/server/proto.go | 12 +++++++++++- internal/workspace/app_workspace.go | 4 ++++ internal/workspace/client_workspace.go | 6 +++++- internal/workspace/workspace.go | 1 + 8 files changed, 43 insertions(+), 10 deletions(-) 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/client/proto.go b/internal/client/proto.go index d6dff13be7..3e85ada813 100644 --- a/internal/client/proto.go +++ b/internal/client/proto.go @@ -488,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) } 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/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/server/proto.go b/internal/server/proto.go index a37ba3dbd6..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 } diff --git a/internal/workspace/app_workspace.go b/internal/workspace/app_workspace.go index e79ee3970e..ef2f9e4a42 100644 --- a/internal/workspace/app_workspace.go +++ b/internal/workspace/app_workspace.go @@ -176,6 +176,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) } diff --git a/internal/workspace/client_workspace.go b/internal/workspace/client_workspace.go index 31feb8f356..f54f4c99b4 100644 --- a/internal/workspace/client_workspace.go +++ b/internal/workspace/client_workspace.go @@ -255,7 +255,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 { diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 045bdf4874..e27162e7da 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -94,6 +94,7 @@ type Workspace interface { 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 From ec6a2a63bb1c2f05ebac999b79941c8ae54564ed Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Fri, 12 Jun 2026 10:55:09 -0400 Subject: [PATCH 07/39] fix(question): fix scrollbar disappearing in single-select --- internal/ui/dialog/question_choice_base.go | 33 ++++++++++++++-------- internal/ui/dialog/question_multi.go | 3 +- internal/ui/dialog/question_single.go | 3 +- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/internal/ui/dialog/question_choice_base.go b/internal/ui/dialog/question_choice_base.go index 335d979d16..b6837d30f9 100644 --- a/internal/ui/dialog/question_choice_base.go +++ b/internal/ui/dialog/question_choice_base.go @@ -280,7 +280,7 @@ func (c *choiceList) buildLines(innerWidth int, fillInPrefix string, itemFn choi if active || hovered { bar = barActive } - content := itemFn(i, ch, active) + 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") { @@ -353,8 +353,10 @@ func (c *choiceList) renderDescription(width int) string { // choiceItemRenderer renders a choice's label content as a string. // The bar prefix is applied by buildLines so that continuation -// lines also receive it. -type choiceItemRenderer func(index int, choice question.Choice, active bool) string +// 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. @@ -364,7 +366,7 @@ func (c *choiceList) height(width int) int { w = width } innerWidth := min(w-4, choiceListMaxWidth) - return len(c.buildLines(innerWidth, "> ", func(int, question.Choice, bool) string { + return len(c.buildLines(innerWidth, "> ", func(int, question.Choice, bool, int) string { return "x" // single-line placeholder; only count matters })) } @@ -409,19 +411,28 @@ func (c *choiceList) drawContent(scr uv.Screen, area uv.Rectangle, fillInPrefix c.lastWidth = area.Dx() viewport := area.Dy() - // Reserve a scrollbar column only when content overflows. The - // narrower width can wrap more, so test against it. + // 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) - overflow := viewport > 0 && len(c.buildLines(innerNarrow, fillInPrefix, itemFn)) > viewport + 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) + } + } - innerWidth := min(contentWidth-4, choiceListMaxWidth) if overflow { contentWidth-- - innerWidth = innerNarrow } - - lines := c.buildLines(innerWidth, fillInPrefix, itemFn) c.clampScroll(lines, viewport) // Blit the visible window. diff --git a/internal/ui/dialog/question_multi.go b/internal/ui/dialog/question_multi.go index 038c8cc349..1a49204a59 100644 --- a/internal/ui/dialog/question_multi.go +++ b/internal/ui/dialog/question_multi.go @@ -204,11 +204,10 @@ func (d *MultiChoice) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { fillPrefix = d.Styles.Editor.QuestionSelected.Render("> ") } - innerWidth := min(area.Dx()-4, choiceListMaxWidth) unselectedHeader := d.Styles.Editor.QuestionUnselected selectedStyle := d.Styles.Editor.QuestionSelected - return d.drawContent(scr, area, fillPrefix, func(i int, ch question.Choice, active bool) string { + return d.drawContent(scr, area, fillPrefix, func(i int, ch question.Choice, active bool, innerWidth int) string { style := unselectedHeader if active { style = selectedStyle diff --git a/internal/ui/dialog/question_single.go b/internal/ui/dialog/question_single.go index 120d8ec1f5..d336016414 100644 --- a/internal/ui/dialog/question_single.go +++ b/internal/ui/dialog/question_single.go @@ -188,11 +188,10 @@ func (d *SingleChoice) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { fillPrefix = d.Styles.Editor.QuestionSelected.Render("> ") } - innerWidth := min(area.Dx()-4, choiceListMaxWidth) unselectedHeader := d.Styles.Editor.QuestionUnselected selectedStyle := d.Styles.Editor.QuestionSelected - return d.drawContent(scr, area, fillPrefix, func(i int, ch question.Choice, active bool) string { + 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 From 3da1bed23f711c2c3b856176fe6b330244407975 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Thu, 7 May 2026 03:09:15 +0500 Subject: [PATCH 08/39] feat: add plan mode --- internal/agent/coordinator.go | 49 ++++++++++++++++++----- internal/agent/coordinator_test.go | 32 +++++++++++++++ internal/agent/prompts.go | 11 ++++++ internal/agent/templates/plan.md.tpl | 27 +++++++++++++ internal/config/agent_id_test.go | 6 +++ internal/config/config.go | 17 ++++++++ internal/config/load_test.go | 13 ++++++ internal/ui/model/keys.go | 5 +++ internal/ui/model/ui.go | 48 +++++++++++++++++++++- internal/ui/model/ui_test.go | 55 +++++++++++++++++++++++++- internal/workspace/app_workspace.go | 7 ++++ internal/workspace/client_workspace.go | 4 ++ internal/workspace/workspace.go | 1 + 13 files changed, 262 insertions(+), 13 deletions(-) create mode 100644 internal/agent/templates/plan.md.tpl diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 19d39407a2..bea4525712 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -53,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") @@ -79,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 @@ -116,8 +117,9 @@ type coordinator struct { 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. @@ -182,21 +184,48 @@ func NewCoordinator(ctx context.Context, opts CoordinatorOptions) (Coordinator, 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 := 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...) @@ -1102,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..1f890c9dca --- /dev/null +++ b/internal/agent/templates/plan.md.tpl @@ -0,0 +1,27 @@ +You are Crush in plan mode. + + +These rules override everything else. Follow them strictly: + +1. do not modify files, create files, delete files, or run write operations. +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. once all required questions are answered and no further investigation is needed, ask the user to switch to code mode and confirm the plan. + + + +1. explore the codebase and gather relevant context. +2. produce a concrete, actionable implementation plan. +3. if needed, ask only the minimum clarifying questions required to unblock the plan. +4. when the plan is ready and complete, explicitly request: + - switch to code mode + - confirmation to execute the plan + + + 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 c845a1e26e..03bcb5ff96 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" ) @@ -708,6 +709,11 @@ func resolveReadOnlyTools(tools []string) []string { return filterSlice(tools, readOnlyTools, true) } +func resolvePlanTools(tools []string) []string { + planTools := []string{"agent", "glob", "grep", "ls", "sourcegraph", "view"} + return filterSlice(tools, planTools, true) +} + func filterSlice(data []string, mask []string, include bool) []string { var filtered []string for _, s := range data { @@ -743,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 88d0b2c741..f73c76e7c9 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", "sourcegraph", "view"}, planAgent.AllowedTools) } func TestConfig_setupAgentsWithDisabledTools(t *testing.T) { @@ -711,12 +715,17 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) { 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", "sourcegraph", "view"}, planAgent.AllowedTools) } func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) { cfg := &Config{ Options: &Options{ DisabledTools: []string{ + "agent", "glob", "grep", "ls", @@ -734,6 +743,10 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) { 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.Len(t, planAgent.AllowedTools, 0) } func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) { 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/ui.go b/internal/ui/model/ui.go index 89abfffda2..022cccb56d 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -112,6 +112,13 @@ const ( uiChat ) +type uiInputMode uint8 + +const ( + uiInputModeCode uiInputMode = iota + uiInputModePlan +) + type openEditorMsg struct { Text string } @@ -187,6 +194,7 @@ type UI struct { focus uiFocusState state uiState + mode uiInputMode keyMap KeyMap keyenh tea.KeyboardEnhancementsMsg @@ -361,7 +369,8 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI { status := NewStatus(com, ui) - ui.setEditorPrompt(com.Workspace.PermissionSkipRequests()) + ui.mode = uiInputModeCode + ui.setEditorPrompt(com.Workspace.PermissionSkipRequests(), ui.mode) ui.randomizePlaceholders() ui.textarea.Placeholder = ui.readyPlaceholder ui.status = status @@ -1474,7 +1483,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { case dialog.ActionToggleYoloMode: yolo := !m.com.Workspace.PermissionSkipRequests() m.com.Workspace.PermissionSetSkipRequests(yolo) - m.setEditorPrompt(yolo) + m.setEditorPrompt(yolo, m.mode) m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionSelectNotificationStyle: cfg := m.com.Config() @@ -2035,6 +2044,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 @@ -2191,6 +2204,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()) @@ -2533,6 +2550,7 @@ func (m *UI) ShortHelp() []key.Binding { binds = append( binds, tab, + k.ShiftTab, commands, k.Models, ) @@ -2624,6 +2642,7 @@ func (m *UI) FullHelp() [][]key.Binding { mainBinds = append( mainBinds, tab, + k.ShiftTab, commands, k.Models, k.Sessions, @@ -3108,6 +3127,7 @@ func (m *UI) setEditorPrompt(yolo bool) { m.textarea.SetPromptFunc(4, m.yoloPromptFunc) return } + m.textarea.SetPromptFunc(4, m.normalPromptFunc) } @@ -3144,6 +3164,30 @@ func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string { return t.Editor.PromptYoloDotsBlurred.Render() } +func (m *UI) toggleInputMode() tea.Cmd { + targetMode := uiInputModePlan + targetAgentID := config.AgentPlan + targetModeLabel := "plan" + if m.mode == uiInputModePlan { + targetMode = uiInputModeCode + targetAgentID = config.AgentCoder + targetModeLabel = "code" + } + + return func() tea.Msg { + if err := m.com.Workspace.AgentSetMain(targetAgentID); err != nil { + return util.ReportError(err)() + } + + m.mode = targetMode + m.setEditorPrompt(m.com.Workspace.PermissionSkipRequests(), m.mode) + if err := m.com.Workspace.UpdateAgentModel(context.Background()); err != nil { + return util.ReportError(err)() + } + return util.NewInfoMsg("input mode: " + targetModeLabel) + } +} + // closeCompletions closes the completions popup and resets state. func (m *UI) closeCompletions() { m.completionsOpen = false diff --git a/internal/ui/model/ui_test.go b/internal/ui/model/ui_test.go index 4032c80a05..245213779b 100644 --- a/internal/ui/model/ui_test.go +++ b/internal/ui/model/ui_test.go @@ -1,8 +1,10 @@ package model import ( + "context" "testing" + "charm.land/bubbles/v2/textarea" "charm.land/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" @@ -87,9 +89,60 @@ 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 } 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) +} diff --git a/internal/workspace/app_workspace.go b/internal/workspace/app_workspace.go index ef2f9e4a42..9d941ca221 100644 --- a/internal/workspace/app_workspace.go +++ b/internal/workspace/app_workspace.go @@ -161,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") diff --git a/internal/workspace/client_workspace.go b/internal/workspace/client_workspace.go index f54f4c99b4..50990bd786 100644 --- a/internal/workspace/client_workspace.go +++ b/internal/workspace/client_workspace.go @@ -246,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) } diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index e27162e7da..83d930d8eb 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -91,6 +91,7 @@ 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 From d98f86af3e6626102c76cce834f18e76e04dae96 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Sat, 9 May 2026 22:03:45 +0500 Subject: [PATCH 09/39] fix(ui): show both yolo and plan markers when both modes are active --- internal/ui/model/ui.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 022cccb56d..a4dd2f0039 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2578,8 +2578,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, @@ -2705,6 +2705,7 @@ func (m *UI) FullHelp() [][]key.Binding { binds = append( binds, []key.Binding{ + k.ShiftTab, commands, k.Models, k.Sessions, From 0d1c33eff6c846dd12f65801a36dfa7676002ac8 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Wed, 20 May 2026 16:53:18 +0500 Subject: [PATCH 10/39] fix(plan): add risks consideration --- internal/agent/templates/plan.md.tpl | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/agent/templates/plan.md.tpl b/internal/agent/templates/plan.md.tpl index 1f890c9dca..5c9bd679cb 100644 --- a/internal/agent/templates/plan.md.tpl +++ b/internal/agent/templates/plan.md.tpl @@ -13,9 +13,10 @@ These rules override everything else. Follow them strictly: 1. explore the codebase and gather relevant context. -2. produce a concrete, actionable implementation plan. -3. if needed, ask only the minimum clarifying questions required to unblock the plan. -4. when the plan is ready and complete, explicitly request: +2. for non-trivial decisions, present trade-offs, risks, and alternatives before settling on an approach. +3. produce a concrete, actionable implementation plan. +4. if needed, ask only the minimum clarifying questions required to unblock the plan. +5. when the plan is ready and complete, explicitly request: - switch to code mode - confirmation to execute the plan From df293bfd66e90e63c7c7afe6f5c23e0944258d76 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Wed, 20 May 2026 18:20:47 +0500 Subject: [PATCH 11/39] fix(ui): show plan in status bar only --- internal/ui/model/status.go | 34 +++++++++++++++++++++++++++++++- internal/ui/model/ui.go | 4 +++- internal/ui/styles/quickstyle.go | 1 + internal/ui/styles/styles.go | 3 ++- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/internal/ui/model/status.go b/internal/ui/model/status.go index cf53294cb9..cf95bc48ac 100644 --- a/internal/ui/model/status.go +++ b/internal/ui/model/status.go @@ -1,6 +1,7 @@ package model import ( + "image" "strings" "time" @@ -23,6 +24,7 @@ type Status struct { help help.Model helpKm help.KeyMap msg util.InfoMsg + planMode bool } // NewStatus creates a new status bar and help model. @@ -45,6 +47,11 @@ 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 +} + // 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 +74,15 @@ 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 s.com.Styles.Status.PlanBadge.Render("plan") + } + return "" +} + // Draw draws the status bar onto the screen. func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) { if !s.hideHelp { @@ -76,6 +92,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 +122,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 +138,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 a4dd2f0039..7b07b43249 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -374,6 +374,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI { 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 @@ -1484,6 +1485,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { yolo := !m.com.Workspace.PermissionSkipRequests() m.com.Workspace.PermissionSetSkipRequests(yolo) m.setEditorPrompt(yolo, m.mode) + m.status.SetMode(m.mode == uiInputModePlan) m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionSelectNotificationStyle: cfg := m.com.Config() @@ -3128,7 +3130,6 @@ func (m *UI) setEditorPrompt(yolo bool) { m.textarea.SetPromptFunc(4, m.yoloPromptFunc) return } - m.textarea.SetPromptFunc(4, m.normalPromptFunc) } @@ -3182,6 +3183,7 @@ func (m *UI) toggleInputMode() tea.Cmd { m.mode = targetMode m.setEditorPrompt(m.com.Workspace.PermissionSkipRequests(), m.mode) + m.status.SetMode(m.mode == uiInputModePlan) if err := m.com.Workspace.UpdateAgentModel(context.Background()); err != nil { return util.ReportError(err)() } diff --git a/internal/ui/styles/quickstyle.go b/internal/ui/styles/quickstyle.go index 701157b482..ecc0ead6c4 100644 --- a/internal/ui/styles/quickstyle.go +++ b/internal/ui/styles/quickstyle.go @@ -952,6 +952,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!") diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 5055ec3a2f..78aad55765 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -510,7 +510,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 From 516890a39ccb2bab3ccf50796ffa30721af00e1d Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Wed, 20 May 2026 18:24:44 +0500 Subject: [PATCH 12/39] fix(plan): added instructs --- internal/agent/templates/plan.md.tpl | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/agent/templates/plan.md.tpl b/internal/agent/templates/plan.md.tpl index 5c9bd679cb..855fc6c9cf 100644 --- a/internal/agent/templates/plan.md.tpl +++ b/internal/agent/templates/plan.md.tpl @@ -12,11 +12,13 @@ These rules override everything else. Follow them strictly: -1. explore the codebase and gather relevant context. -2. for non-trivial decisions, present trade-offs, risks, and alternatives before settling on an approach. -3. produce a concrete, actionable implementation plan. -4. if needed, ask only the minimum clarifying questions required to unblock the plan. -5. when the plan is ready and complete, explicitly request: +1. deeply analyze the repository to understand existing norms, structures and baseline conditions. +2. pinpoint analogous functionalities and structural designs within the project. +3. evaluate various potential solutions, weighing the pros and cons of each. +4. assess potential risks, edge cases, and failure modes. +5. produce a concrete, actionable implementation plan. +6. if needed, ask only the minimum clarifying questions required to unblock the plan. +7. when the plan is ready and complete, explicitly request: - switch to code mode - confirmation to execute the plan From 3a591b4dc10b60cc79f3666cb910544d8befa27d Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Wed, 20 May 2026 21:22:32 +0500 Subject: [PATCH 13/39] fix: check status exists --- internal/ui/model/ui.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 7b07b43249..6a1ade0602 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -3183,7 +3183,9 @@ func (m *UI) toggleInputMode() tea.Cmd { m.mode = targetMode m.setEditorPrompt(m.com.Workspace.PermissionSkipRequests(), m.mode) - m.status.SetMode(m.mode == uiInputModePlan) + if m.status != nil { + m.status.SetMode(m.mode == uiInputModePlan) + } if err := m.com.Workspace.UpdateAgentModel(context.Background()); err != nil { return util.ReportError(err)() } From ccae613e5005f38b4b272abc1dcd96e13abc6806 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Fri, 22 May 2026 16:24:35 +0500 Subject: [PATCH 14/39] fix(plan): refine workflow and style guidelines for clarity and precision --- internal/agent/templates/plan.md.tpl | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/agent/templates/plan.md.tpl b/internal/agent/templates/plan.md.tpl index 855fc6c9cf..f599bd3bec 100644 --- a/internal/agent/templates/plan.md.tpl +++ b/internal/agent/templates/plan.md.tpl @@ -12,9 +12,9 @@ These rules override everything else. Follow them strictly: -1. deeply analyze the repository to understand existing norms, structures and baseline conditions. -2. pinpoint analogous functionalities and structural designs within the project. -3. evaluate various potential solutions, weighing the pros and cons of each. +1. thoroughly explore the codebase using read-only tools +2. understand existing patterns and architecture +3. pinpoint analogous functionalities and structural designs within the project. 4. assess potential risks, edge cases, and failure modes. 5. produce a concrete, actionable implementation plan. 6. if needed, ask only the minimum clarifying questions required to unblock the plan. @@ -24,7 +24,9 @@ These rules override everything else. Follow them strictly: From 6b0347445d6368831e84c7799a464954ee81ca8c Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Fri, 22 May 2026 22:37:52 +0500 Subject: [PATCH 15/39] fix(plan): mutate model synchronously in toggleInputMode to prevent cursor glitch --- internal/ui/model/ui.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 6a1ade0602..7823fbec53 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -3176,16 +3176,16 @@ func (m *UI) toggleInputMode() tea.Cmd { targetModeLabel = "code" } + m.mode = targetMode + m.setEditorPrompt(m.com.Workspace.PermissionSkipRequests(), m.mode) + if m.status != nil { + m.status.SetMode(m.mode == uiInputModePlan) + } + return func() tea.Msg { if err := m.com.Workspace.AgentSetMain(targetAgentID); err != nil { return util.ReportError(err)() } - - m.mode = targetMode - m.setEditorPrompt(m.com.Workspace.PermissionSkipRequests(), m.mode) - if m.status != nil { - m.status.SetMode(m.mode == uiInputModePlan) - } if err := m.com.Workspace.UpdateAgentModel(context.Background()); err != nil { return util.ReportError(err)() } From 2c7d4f3bfae9dc4eeeacf5d514f420c28e7da056 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Sun, 24 May 2026 11:32:34 +0500 Subject: [PATCH 16/39] fix(plan): enhance plan mode description --- internal/agent/templates/plan.md.tpl | 37 +++++++++++++++++++--------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/internal/agent/templates/plan.md.tpl b/internal/agent/templates/plan.md.tpl index f599bd3bec..98c8d17e8d 100644 --- a/internal/agent/templates/plan.md.tpl +++ b/internal/agent/templates/plan.md.tpl @@ -1,4 +1,6 @@ -You are Crush in plan mode. +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. These rules override everything else. Follow them strictly: @@ -12,21 +14,34 @@ These rules override everything else. Follow them strictly: -1. thoroughly explore the codebase using read-only tools -2. understand existing patterns and architecture -3. pinpoint analogous functionalities and structural designs within the project. -4. assess potential risks, edge cases, and failure modes. -5. produce a concrete, actionable implementation plan. -6. if needed, ask only the minimum clarifying questions required to unblock the plan. -7. when the plan is ready and complete, explicitly request: - - switch to code mode - - confirmation to execute the plan +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 `glob`, `grep`, `ls`, and `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 +8. when the plan is ready and complete, explicitly request: + - switch to code mode + - confirmation to execute the plan + +### Critical Files for Implementation +List 3-5 files most critical for implementing this plan: +- path/to/file1 +- path/to/file2 +- path/to/file3 \ No newline at end of file From b957f1ff0bc80fc5cb12c94e78ab5e5c9e2a5a25 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Sun, 24 May 2026 12:19:51 +0500 Subject: [PATCH 17/39] fix(plan): clarify wording for search tools in workflow instructions --- internal/agent/templates/plan.md.tpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/agent/templates/plan.md.tpl b/internal/agent/templates/plan.md.tpl index 98c8d17e8d..1fd4169135 100644 --- a/internal/agent/templates/plan.md.tpl +++ b/internal/agent/templates/plan.md.tpl @@ -15,7 +15,7 @@ These rules override everything else. Follow them strictly: 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 `glob`, `grep`, `ls`, and `view` only for simple, targeted lookups you can resolve in one or two calls +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 From 657346a5103a9a650d89df6d58f77831ce4b356a Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Tue, 26 May 2026 03:24:11 +0500 Subject: [PATCH 18/39] fix(plan): update setEditorPrompt to include mode parameter --- internal/ui/model/ui.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 7823fbec53..111da37315 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1941,7 +1941,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { case key.Matches(msg, m.keyMap.ToggleYolo): yolo := !m.com.Workspace.PermissionSkipRequests() m.com.Workspace.PermissionSetSkipRequests(yolo) - m.setEditorPrompt(yolo) + m.setEditorPrompt(yolo, m.mode) status := "disabled" if yolo { status = "enabled" From efc21a05a4559a60da1c9c4280397ac0072c7971 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Sat, 30 May 2026 19:57:27 +0500 Subject: [PATCH 19/39] chore: rebase maintenance --- internal/server/agent_cancel_test.go | 1 + internal/server/recover_test.go | 8 ++++---- internal/server/sessions_isbusy_test.go | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) 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/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/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 From 45807ed2bcff9b2e0d063cc39bd84d31474250e3 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Thu, 7 May 2026 03:09:15 +0500 Subject: [PATCH 20/39] feat: add plan mode --- internal/agent/templates/plan.md.tpl | 40 +++++++--------------------- internal/ui/model/ui.go | 31 +++++++++++++++------ 2 files changed, 33 insertions(+), 38 deletions(-) diff --git a/internal/agent/templates/plan.md.tpl b/internal/agent/templates/plan.md.tpl index 1fd4169135..1f890c9dca 100644 --- a/internal/agent/templates/plan.md.tpl +++ b/internal/agent/templates/plan.md.tpl @@ -1,6 +1,4 @@ -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 are Crush in plan mode. These rules override everything else. Follow them strictly: @@ -14,34 +12,16 @@ These rules override everything else. Follow them strictly: -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 -8. when the plan is ready and complete, explicitly request: - - switch to code mode - - confirmation to execute the plan +1. explore the codebase and gather relevant context. +2. produce a concrete, actionable implementation plan. +3. if needed, ask only the minimum clarifying questions required to unblock the plan. +4. when the plan is ready and complete, explicitly request: + - switch to code mode + - confirmation to execute the plan - -### Critical Files for Implementation -List 3-5 files most critical for implementing this plan: -- path/to/file1 -- path/to/file2 -- path/to/file3 \ No newline at end of file diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 111da37315..2125c529c7 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1484,7 +1484,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { case dialog.ActionToggleYoloMode: yolo := !m.com.Workspace.PermissionSkipRequests() m.com.Workspace.PermissionSetSkipRequests(yolo) - m.setEditorPrompt(yolo, m.mode) + m.setEditorPrompt(yolo) m.status.SetMode(m.mode == uiInputModePlan) m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionSelectNotificationStyle: @@ -3125,11 +3125,15 @@ func (m *UI) openEditor(value string) tea.Cmd { // setEditorPrompt configures the textarea prompt icon based on // whether yolo mode is enabled. -func (m *UI) setEditorPrompt(yolo bool) { +func (m *UI) setEditorPrompt(yolo bool, mode uiInputMode) { if yolo { m.textarea.SetPromptFunc(4, m.yoloPromptFunc) return } + if mode == uiInputModePlan { + m.textarea.SetPromptFunc(4, m.planPromptFunc) + return + } m.textarea.SetPromptFunc(4, m.normalPromptFunc) } @@ -3149,6 +3153,20 @@ func (m *UI) normalPromptFunc(info textarea.PromptInfo) string { return t.Editor.PromptNormalBlurred.Render() } +func (m *UI) planPromptFunc(info textarea.PromptInfo) string { + t := m.com.Styles + if info.LineNumber == 0 { + if info.Focused { + return "[plan] > " + } + return "[plan]:: " + } + if info.Focused { + return t.Editor.PromptNormalFocused.Render() + } + return t.Editor.PromptNormalBlurred.Render() +} + // yoloPromptFunc returns the yolo mode editor prompt style with warning icon // and colored dots. func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string { @@ -3176,16 +3194,13 @@ func (m *UI) toggleInputMode() tea.Cmd { targetModeLabel = "code" } - m.mode = targetMode - m.setEditorPrompt(m.com.Workspace.PermissionSkipRequests(), m.mode) - if m.status != nil { - m.status.SetMode(m.mode == uiInputModePlan) - } - return func() tea.Msg { if err := m.com.Workspace.AgentSetMain(targetAgentID); err != nil { return util.ReportError(err)() } + + m.mode = targetMode + m.setEditorPrompt(m.com.Workspace.PermissionSkipRequests(), m.mode) if err := m.com.Workspace.UpdateAgentModel(context.Background()); err != nil { return util.ReportError(err)() } From 73db2440908d8141b10288c70ac3eee586d5891f Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Sat, 9 May 2026 22:03:45 +0500 Subject: [PATCH 21/39] fix(ui): show both yolo and plan markers when both modes are active --- internal/ui/model/ui.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 2125c529c7..98b58c62f1 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -3126,6 +3126,10 @@ func (m *UI) openEditor(value string) tea.Cmd { // setEditorPrompt configures the textarea prompt icon based on // whether yolo mode is enabled. func (m *UI) setEditorPrompt(yolo bool, mode uiInputMode) { + if yolo && mode == uiInputModePlan { + m.textarea.SetPromptFunc(4, m.yoloPlanPromptFunc) + return + } if yolo { m.textarea.SetPromptFunc(4, m.yoloPromptFunc) return @@ -3167,6 +3171,20 @@ func (m *UI) planPromptFunc(info textarea.PromptInfo) string { return t.Editor.PromptNormalBlurred.Render() } +func (m *UI) yoloPlanPromptFunc(info textarea.PromptInfo) string { + t := m.com.Styles + if info.LineNumber == 0 { + if info.Focused { + return t.Editor.PromptYoloIconFocused.Render() + "[plan] > " + } + return t.Editor.PromptYoloIconBlurred.Render() + "[plan]:: " + } + if info.Focused { + return t.Editor.PromptYoloDotsFocused.Render() + } + return t.Editor.PromptYoloDotsBlurred.Render() +} + // yoloPromptFunc returns the yolo mode editor prompt style with warning icon // and colored dots. func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string { From 8ea287f3f6d01084b2b5620fdd3694eae6a6fb35 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Wed, 20 May 2026 16:53:18 +0500 Subject: [PATCH 22/39] fix(plan): add risks consideration --- internal/agent/templates/plan.md.tpl | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/agent/templates/plan.md.tpl b/internal/agent/templates/plan.md.tpl index 1f890c9dca..5c9bd679cb 100644 --- a/internal/agent/templates/plan.md.tpl +++ b/internal/agent/templates/plan.md.tpl @@ -13,9 +13,10 @@ These rules override everything else. Follow them strictly: 1. explore the codebase and gather relevant context. -2. produce a concrete, actionable implementation plan. -3. if needed, ask only the minimum clarifying questions required to unblock the plan. -4. when the plan is ready and complete, explicitly request: +2. for non-trivial decisions, present trade-offs, risks, and alternatives before settling on an approach. +3. produce a concrete, actionable implementation plan. +4. if needed, ask only the minimum clarifying questions required to unblock the plan. +5. when the plan is ready and complete, explicitly request: - switch to code mode - confirmation to execute the plan From 6f4765c3e450ee4ed23eb679c8055ae588c0a106 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Wed, 20 May 2026 18:20:47 +0500 Subject: [PATCH 23/39] fix(ui): show plan in status bar only --- internal/ui/model/ui.go | 33 +-------------------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 98b58c62f1..65dc9851ad 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -3134,10 +3134,6 @@ func (m *UI) setEditorPrompt(yolo bool, mode uiInputMode) { m.textarea.SetPromptFunc(4, m.yoloPromptFunc) return } - if mode == uiInputModePlan { - m.textarea.SetPromptFunc(4, m.planPromptFunc) - return - } m.textarea.SetPromptFunc(4, m.normalPromptFunc) } @@ -3157,34 +3153,6 @@ func (m *UI) normalPromptFunc(info textarea.PromptInfo) string { return t.Editor.PromptNormalBlurred.Render() } -func (m *UI) planPromptFunc(info textarea.PromptInfo) string { - t := m.com.Styles - if info.LineNumber == 0 { - if info.Focused { - return "[plan] > " - } - return "[plan]:: " - } - if info.Focused { - return t.Editor.PromptNormalFocused.Render() - } - return t.Editor.PromptNormalBlurred.Render() -} - -func (m *UI) yoloPlanPromptFunc(info textarea.PromptInfo) string { - t := m.com.Styles - if info.LineNumber == 0 { - if info.Focused { - return t.Editor.PromptYoloIconFocused.Render() + "[plan] > " - } - return t.Editor.PromptYoloIconBlurred.Render() + "[plan]:: " - } - if info.Focused { - return t.Editor.PromptYoloDotsFocused.Render() - } - return t.Editor.PromptYoloDotsBlurred.Render() -} - // yoloPromptFunc returns the yolo mode editor prompt style with warning icon // and colored dots. func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string { @@ -3219,6 +3187,7 @@ func (m *UI) toggleInputMode() tea.Cmd { m.mode = targetMode m.setEditorPrompt(m.com.Workspace.PermissionSkipRequests(), m.mode) + m.status.SetMode(m.mode == uiInputModePlan) if err := m.com.Workspace.UpdateAgentModel(context.Background()); err != nil { return util.ReportError(err)() } From a34218deda970b66f5610690c5245163d01f6f1b Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Wed, 20 May 2026 18:24:44 +0500 Subject: [PATCH 24/39] fix(plan): added instructs --- internal/agent/templates/plan.md.tpl | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/agent/templates/plan.md.tpl b/internal/agent/templates/plan.md.tpl index 5c9bd679cb..855fc6c9cf 100644 --- a/internal/agent/templates/plan.md.tpl +++ b/internal/agent/templates/plan.md.tpl @@ -12,11 +12,13 @@ These rules override everything else. Follow them strictly: -1. explore the codebase and gather relevant context. -2. for non-trivial decisions, present trade-offs, risks, and alternatives before settling on an approach. -3. produce a concrete, actionable implementation plan. -4. if needed, ask only the minimum clarifying questions required to unblock the plan. -5. when the plan is ready and complete, explicitly request: +1. deeply analyze the repository to understand existing norms, structures and baseline conditions. +2. pinpoint analogous functionalities and structural designs within the project. +3. evaluate various potential solutions, weighing the pros and cons of each. +4. assess potential risks, edge cases, and failure modes. +5. produce a concrete, actionable implementation plan. +6. if needed, ask only the minimum clarifying questions required to unblock the plan. +7. when the plan is ready and complete, explicitly request: - switch to code mode - confirmation to execute the plan From 4fbc8231b600bc5348bf35e2bc4438f87b1b855c Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Wed, 20 May 2026 21:22:32 +0500 Subject: [PATCH 25/39] fix: check status exists --- internal/ui/model/ui.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 65dc9851ad..d287edf44b 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -3187,7 +3187,9 @@ func (m *UI) toggleInputMode() tea.Cmd { m.mode = targetMode m.setEditorPrompt(m.com.Workspace.PermissionSkipRequests(), m.mode) - m.status.SetMode(m.mode == uiInputModePlan) + if m.status != nil { + m.status.SetMode(m.mode == uiInputModePlan) + } if err := m.com.Workspace.UpdateAgentModel(context.Background()); err != nil { return util.ReportError(err)() } From a183889c8d1b14569179f611262672fa2bd6ff68 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Fri, 22 May 2026 16:24:35 +0500 Subject: [PATCH 26/39] fix(plan): refine workflow and style guidelines for clarity and precision --- internal/agent/templates/plan.md.tpl | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/agent/templates/plan.md.tpl b/internal/agent/templates/plan.md.tpl index 855fc6c9cf..f599bd3bec 100644 --- a/internal/agent/templates/plan.md.tpl +++ b/internal/agent/templates/plan.md.tpl @@ -12,9 +12,9 @@ These rules override everything else. Follow them strictly: -1. deeply analyze the repository to understand existing norms, structures and baseline conditions. -2. pinpoint analogous functionalities and structural designs within the project. -3. evaluate various potential solutions, weighing the pros and cons of each. +1. thoroughly explore the codebase using read-only tools +2. understand existing patterns and architecture +3. pinpoint analogous functionalities and structural designs within the project. 4. assess potential risks, edge cases, and failure modes. 5. produce a concrete, actionable implementation plan. 6. if needed, ask only the minimum clarifying questions required to unblock the plan. @@ -24,7 +24,9 @@ These rules override everything else. Follow them strictly: From 56f06fa3851e8b379a86eda5783a27e6d3b0cf63 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Fri, 22 May 2026 22:37:52 +0500 Subject: [PATCH 27/39] fix(plan): mutate model synchronously in toggleInputMode to prevent cursor glitch --- internal/ui/model/ui.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index d287edf44b..4258267222 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -3180,16 +3180,16 @@ func (m *UI) toggleInputMode() tea.Cmd { targetModeLabel = "code" } + m.mode = targetMode + m.setEditorPrompt(m.com.Workspace.PermissionSkipRequests(), m.mode) + if m.status != nil { + m.status.SetMode(m.mode == uiInputModePlan) + } + return func() tea.Msg { if err := m.com.Workspace.AgentSetMain(targetAgentID); err != nil { return util.ReportError(err)() } - - m.mode = targetMode - m.setEditorPrompt(m.com.Workspace.PermissionSkipRequests(), m.mode) - if m.status != nil { - m.status.SetMode(m.mode == uiInputModePlan) - } if err := m.com.Workspace.UpdateAgentModel(context.Background()); err != nil { return util.ReportError(err)() } From f9f014a1605eac81d5f385af778dd901f34b6c0d Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Sun, 24 May 2026 11:32:34 +0500 Subject: [PATCH 28/39] fix(plan): enhance plan mode description --- internal/agent/templates/plan.md.tpl | 37 +++++++++++++++++++--------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/internal/agent/templates/plan.md.tpl b/internal/agent/templates/plan.md.tpl index f599bd3bec..98c8d17e8d 100644 --- a/internal/agent/templates/plan.md.tpl +++ b/internal/agent/templates/plan.md.tpl @@ -1,4 +1,6 @@ -You are Crush in plan mode. +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. These rules override everything else. Follow them strictly: @@ -12,21 +14,34 @@ These rules override everything else. Follow them strictly: -1. thoroughly explore the codebase using read-only tools -2. understand existing patterns and architecture -3. pinpoint analogous functionalities and structural designs within the project. -4. assess potential risks, edge cases, and failure modes. -5. produce a concrete, actionable implementation plan. -6. if needed, ask only the minimum clarifying questions required to unblock the plan. -7. when the plan is ready and complete, explicitly request: - - switch to code mode - - confirmation to execute the plan +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 `glob`, `grep`, `ls`, and `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 +8. when the plan is ready and complete, explicitly request: + - switch to code mode + - confirmation to execute the plan + +### Critical Files for Implementation +List 3-5 files most critical for implementing this plan: +- path/to/file1 +- path/to/file2 +- path/to/file3 \ No newline at end of file From a718d087613005d6168a5019faaaaaf508567d55 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Sun, 24 May 2026 12:19:51 +0500 Subject: [PATCH 29/39] fix(plan): clarify wording for search tools in workflow instructions --- internal/agent/templates/plan.md.tpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/agent/templates/plan.md.tpl b/internal/agent/templates/plan.md.tpl index 98c8d17e8d..1fd4169135 100644 --- a/internal/agent/templates/plan.md.tpl +++ b/internal/agent/templates/plan.md.tpl @@ -15,7 +15,7 @@ These rules override everything else. Follow them strictly: 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 `glob`, `grep`, `ls`, and `view` only for simple, targeted lookups you can resolve in one or two calls +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 From 4869a07f521212d5673eddbbdb3d07b2eae342a3 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Tue, 9 Jun 2026 20:39:51 +0500 Subject: [PATCH 30/39] feat(plan): implement plan handoff dialog and add question tool usage --- internal/agent/templates/plan.md.tpl | 14 ++- internal/backend/agent_runcomplete_test.go | 1 + internal/backend/agent_test.go | 1 + internal/config/config.go | 2 +- internal/config/load_test.go | 8 +- internal/server/e2e_agent_test.go | 1 + internal/ui/dialog/actions.go | 3 + internal/ui/dialog/plan_handoff.go | 125 +++++++++++++++++++ internal/ui/model/ui.go | 59 +++++++-- internal/ui/model/ui_test.go | 132 +++++++++++++++++++++ 10 files changed, 322 insertions(+), 24 deletions(-) create mode 100644 internal/ui/dialog/plan_handoff.go diff --git a/internal/agent/templates/plan.md.tpl b/internal/agent/templates/plan.md.tpl index 1fd4169135..73c07a6ec8 100644 --- a/internal/agent/templates/plan.md.tpl +++ b/internal/agent/templates/plan.md.tpl @@ -10,7 +10,8 @@ These rules override everything else. Follow them strictly: 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. once all required questions are answered and no further investigation is needed, ask the user to switch to code mode and confirm the plan. +6. ALWAYS use the `question` tool for every clarifying question — never ask questions as plain chat text. +7. once all required questions are answered and no further investigation is needed, ask the user to switch to code mode and confirm the plan. @@ -20,17 +21,18 @@ These rules override everything else. Follow them strictly: 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 -8. when the plan is ready and complete, explicitly request: - - switch to code mode - - confirmation to execute the 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: + - end with the exact marker on its own line: + - ask the user directly: confirm execution or request plan changes + - keep all intermediate/exploratory responses marker-free - -### Critical Files for Implementation -List 3-5 files most critical for implementing this plan: -- path/to/file1 -- path/to/file2 -- path/to/file3 \ No newline at end of file + \ No newline at end of file From c7bb4f6043b714ff9fc6521a75c826039f82c25c Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Wed, 10 Jun 2026 02:59:18 +0500 Subject: [PATCH 36/39] feat(plan): implement plan-ready marker handling and UI updates --- internal/ui/chat/assistant.go | 35 +++++++-- internal/ui/common/common.go | 16 ++++ internal/ui/common/common_test.go | 27 +++++++ internal/ui/common/markdown.go | 24 +++++- internal/ui/model/status.go | 28 ++++--- internal/ui/model/ui.go | 92 ++++++++++++++++++++++- internal/ui/model/ui_test.go | 121 ++++++++++++++++++++++++++++++ internal/ui/styles/quickstyle.go | 61 +++++++++++++++ internal/ui/styles/styles.go | 1 + 9 files changed, 386 insertions(+), 19 deletions(-) create mode 100644 internal/ui/common/common_test.go diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index ba51070311..64cdc0c76f 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -422,17 +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. - // Wrap that message in a background "card" so the plan stands out from - // regular assistant replies. Mirrors the ThinkingBox treatment. - if common.PlanReadyMarkerPresent(a.message.Content().Text) { - out = a.sty.Messages.PlanBox.Width(width).Render(strings.TrimSpace(out)) + // 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/common/common.go b/internal/ui/common/common.go index 838925d8ea..90ba3cc684 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/common/common.go @@ -35,6 +35,22 @@ func PlanReadyMarkerPresent(text string) bool { 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/model/status.go b/internal/ui/model/status.go index cf95bc48ac..7231b028ff 100644 --- a/internal/ui/model/status.go +++ b/internal/ui/model/status.go @@ -19,12 +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 - planMode bool + 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. @@ -52,6 +53,12 @@ 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 @@ -77,10 +84,13 @@ func (s *Status) SetHideHelp(hideHelp bool) { // 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 s.com.Styles.Status.PlanBadge.Render("plan") + if !s.planMode { + return "" + } + if s.planReady { + return s.com.Styles.Status.PlanBadge.Render("plan ready") } - return "" + return s.com.Styles.Status.PlanBadge.Render("plan") } // Draw draws the status bar onto the screen. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index cac8b3c203..b6b21a6a5a 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -196,6 +196,14 @@ type UI struct { 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 @@ -608,6 +616,15 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -644,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...)) @@ -2083,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 { @@ -2108,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 } @@ -3186,6 +3220,9 @@ func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string { } 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 @@ -3211,14 +3248,22 @@ func (m *UI) setInputMode(target uiInputMode) tea.Cmd { return util.ReportError(err) } + m.modeSwitching = true return func() tea.Msg { - if err := m.com.Workspace.UpdateAgentModel(context.Background()); err != nil { - return util.ReportError(err)() + return modeSwitchedMsg{ + label: label, + err: m.com.Workspace.UpdateAgentModel(context.Background()), } - return util.NewInfoMsg("input mode: " + label) } } +// 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 @@ -3486,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") @@ -3828,13 +3876,48 @@ func (m *UI) handlePlanHandoff(rc notify.RunComplete) tea.Cmd { 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."), @@ -3847,7 +3930,6 @@ func (m *UI) handlePlanHandoff(rc notify.RunComplete) tea.Cmd { if m.status != nil { m.updateLayoutAndSize() } - return nil } // handleAgentNotification translates domain agent events into desktop @@ -3895,6 +3977,7 @@ func (m *UI) newSession() tea.Cmd { return nil } + planCmd := m.resetPlanModeState() m.session = nil m.sessionFiles = nil m.sessionFileReads = nil @@ -3909,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 fae53c8fbd..e4d4f1412d 100644 --- a/internal/ui/model/ui_test.go +++ b/internal/ui/model/ui_test.go @@ -12,6 +12,7 @@ import ( "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" ) @@ -95,6 +96,16 @@ type testWorkspace struct { 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 { @@ -305,3 +316,113 @@ func TestSetInputMode_SwitchesToPlan(t *testing.T) { 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 2a5c6e2cea..e46f44a6bf 100644 --- a/internal/ui/styles/quickstyle.go +++ b/internal/ui/styles/quickstyle.go @@ -333,6 +333,11 @@ 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. + s.PlanMarkdown = withMarkdownBackground(s.Markdown, hex(o.bgLeastVisible)) + // QuietMarkdown style - muted colors on subtle background for thinking content. plainBg := hex(o.bgLeastVisible) plainFg := hex(o.fgMoreSubtle) @@ -1000,3 +1005,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 3cdbdf96cc..6f18bb1047 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -92,6 +92,7 @@ type Styles struct { // Markdown & Chroma Markdown ansi.StyleConfig QuietMarkdown ansi.StyleConfig + PlanMarkdown ansi.StyleConfig // Inputs TextInput textinput.Styles From 5df91fe23c3328a41e2c69a53298718683bf2835 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Wed, 10 Jun 2026 03:01:20 +0500 Subject: [PATCH 37/39] feat(styles): enhance PlanMarkdown styling for headings in quickStyle --- internal/ui/styles/quickstyle.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/ui/styles/quickstyle.go b/internal/ui/styles/quickstyle.go index e46f44a6bf..dc18e6532e 100644 --- a/internal/ui/styles/quickstyle.go +++ b/internal/ui/styles/quickstyle.go @@ -336,7 +336,23 @@ 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. - s.PlanMarkdown = withMarkdownBackground(s.Markdown, hex(o.bgLeastVisible)) + // + // 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.StylePrimitive.Color == nil { + h.StylePrimitive.Color = headingColor + } + if h.StylePrimitive.Bold == nil { + h.StylePrimitive.Bold = headingBold + } + } + s.PlanMarkdown = withMarkdownBackground(planMD, hex(o.bgLeastVisible)) // QuietMarkdown style - muted colors on subtle background for thinking content. plainBg := hex(o.bgLeastVisible) From 5520cf81af7006d508535d9efee6e03a73526519 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Wed, 10 Jun 2026 03:18:06 +0500 Subject: [PATCH 38/39] feat(styles): update PlanMarkdown to replace raw markdown prefixes with clean indentation --- internal/ui/styles/quickstyle.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/ui/styles/quickstyle.go b/internal/ui/styles/quickstyle.go index dc18e6532e..178b2eb2bc 100644 --- a/internal/ui/styles/quickstyle.go +++ b/internal/ui/styles/quickstyle.go @@ -352,6 +352,14 @@ func quickStyle(o quickStyleOpts) Styles { h.StylePrimitive.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.StylePrimitive.Prefix = "" + planMD.H3.StylePrimitive.Prefix = " " + planMD.H4.StylePrimitive.Prefix = " " + planMD.H5.StylePrimitive.Prefix = " " s.PlanMarkdown = withMarkdownBackground(planMD, hex(o.bgLeastVisible)) // QuietMarkdown style - muted colors on subtle background for thinking content. From ba8d66e2861af40ed5e79dfe70f642180f809707 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Mon, 15 Jun 2026 01:54:36 +0500 Subject: [PATCH 39/39] chore: lint --- internal/agent/coordinator.go | 2 +- internal/ui/model/ui.go | 2 +- internal/ui/styles/quickstyle.go | 16 ++++++++-------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index bea4525712..a6330747dd 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -195,7 +195,7 @@ func NewCoordinator(ctx context.Context, opts CoordinatorOptions) (Coordinator, } c.agents[config.AgentCoder] = agent - planCfg, ok := cfg.Config().Agents[config.AgentPlan] + planCfg, ok := c.cfg.Config().Agents[config.AgentPlan] if !ok { return nil, errPlanAgentNotConfigured } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index b6b21a6a5a..9bafc2cf01 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -3876,7 +3876,7 @@ func (m *UI) handlePlanHandoff(rc notify.RunComplete) tea.Cmd { return nil } if !common.PlanReadyMarkerPresent(rc.Text) { - slog.Debug("plan run completed without ready marker", "session_id", rc.SessionID) + slog.Debug("Plan run completed without ready marker", "session_id", rc.SessionID) return nil } m.setPlanReadyPending(rc.SessionID) diff --git a/internal/ui/styles/quickstyle.go b/internal/ui/styles/quickstyle.go index 178b2eb2bc..93217386f8 100644 --- a/internal/ui/styles/quickstyle.go +++ b/internal/ui/styles/quickstyle.go @@ -345,21 +345,21 @@ func quickStyle(o quickStyleOpts) Styles { headingColor := hex(o.info) headingBold := new(true) for _, h := range []*ansi.StyleBlock{&planMD.H2, &planMD.H3, &planMD.H4, &planMD.H5} { - if h.StylePrimitive.Color == nil { - h.StylePrimitive.Color = headingColor + if h.Color == nil { + h.Color = headingColor } - if h.StylePrimitive.Bold == nil { - h.StylePrimitive.Bold = headingBold + 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.StylePrimitive.Prefix = "" - planMD.H3.StylePrimitive.Prefix = " " - planMD.H4.StylePrimitive.Prefix = " " - planMD.H5.StylePrimitive.Prefix = " " + 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.