diff --git a/internal/agent/agenttest/coordinator.go b/internal/agent/agenttest/coordinator.go index fdacb7e129..618e8dfec5 100644 --- a/internal/agent/agenttest/coordinator.go +++ b/internal/agent/agenttest/coordinator.go @@ -64,17 +64,10 @@ func NewCoordinator( coderCfg.AllowedTools = nil cfg.Config().Agents[config.AgentCoder] = coderCfg - return agent.NewCoordinator( - ctx, - cfg, - sessions, - messages, - permission.NewPermissionService(workingDir, true, nil), - nil, - nil, - nil, - nil, - nil, - nil, - ) + return agent.NewCoordinator(ctx, agent.CoordinatorOptions{ + Config: cfg, + Sessions: sessions, + Messages: messages, + Permissions: permission.NewPermissionService(workingDir, true, nil), + }) } diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 8858134099..2ac3b8f8a6 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 @@ -124,49 +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, - history history.Service, - filetracker filetracker.Service, - lspManager *lsp.Manager, - notify pubsub.Publisher[notify.Notification], - runComplete pubsub.Publisher[notify.RunComplete], - skillsMgr *skills.Manager, -) (Coordinator, error) { +// CoordinatorOptions holds the dependencies for NewCoordinator. Using a +// struct keeps the constructor self-documenting and avoids a long +// positional parameter list. +type CoordinatorOptions struct { + Config *config.ConfigStore + Sessions session.Service + Messages message.Service + Permissions permission.Service + Questions question.Service + History history.Service + FileTracker filetracker.Service + LSPManager *lsp.Manager + Notify pubsub.Publisher[notify.Notification] + RunComplete pubsub.Publisher[notify.RunComplete] + Skills *skills.Manager + Interactive bool +} + +func NewCoordinator(ctx context.Context, opts CoordinatorOptions) (Coordinator, error) { // Skills are pre-discovered by the caller (see app.New / // backend.CreateWorkspace) and passed in via the manager. If no // manager was provided (legacy callers), fall back to an in-line // discovery so the coordinator still works. var allSkills, activeSkills []*skills.Skill - if skillsMgr != nil { - allSkills = skillsMgr.AllSkills() - activeSkills = skillsMgr.ActiveSkills() + if opts.Skills != nil { + allSkills = opts.Skills.AllSkills() + activeSkills = opts.Skills.ActiveSkills() } else { - allSkills, activeSkills = discoverSkills(cfg) + allSkills, activeSkills = discoverSkills(opts.Config) } skillTracker := skills.NewTracker(activeSkills) c := &coordinator{ - cfg: cfg, - sessions: sessions, - messages: messages, - permissions: permissions, - history: history, - filetracker: filetracker, - lspManager: lspManager, - notify: notify, - runComplete: runComplete, + cfg: opts.Config, + sessions: opts.Sessions, + messages: opts.Messages, + permissions: opts.Permissions, + questions: opts.Questions, + history: opts.History, + filetracker: opts.FileTracker, + lspManager: opts.LSPManager, + notify: opts.Notify, + runComplete: opts.RunComplete, agents: make(map[string]SessionAgent), allSkills: allSkills, activeSkills: activeSkills, skillTracker: skillTracker, + interactive: opts.Interactive, } - agentCfg, ok := cfg.Config().Agents[config.AgentCoder] + agentCfg, ok := opts.Config.Config().Agents[config.AgentCoder] if !ok { return nil, errCoderAgentNotConfigured } @@ -623,6 +634,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..5918796fc5 --- /dev/null +++ b/internal/agent/tools/question.go @@ -0,0 +1,188 @@ +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 per batch (got %d). Split into multiple batches and tell the user there will be follow-up questions", 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 { + label := item.Label + if label == "" { + label = item.Question + } + return fantasy.NewTextErrorResponse(fmt.Sprintf("question %d [%s]: invalid type %q (must be yes_no, single_choice, multi_choice, or free_text)", i+1, label, 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(err.Error()), 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..3da52fd7e9 --- /dev/null +++ b/internal/agent/tools/question.md @@ -0,0 +1,100 @@ +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. If you need more, split into multiple + batches and tell the user there will be follow-up questions. + +## 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`: a short question like "Ready to go?" or "Sound good?" +- `confirm_description`: summarize what will happen based on the expected + answers. Write it as if you already know what they'll pick. This gives + the user context for their confirmation decision. + +## Multiple questions + +When providing multiple questions, each item can include an optional +`label` (3 words max) used as the tab header. If omitted, the first 3 +words of `question` are used. + +Example — single question: +```json +{ + "questions": [ + {"type": "yes_no", "question": "Enable caching?", "description": "Reduces latency for repeated queries but adds invalidation complexity."} + ] +} +``` + +Example — multiple questions with confirmation: +```json +{ + "questions": [ + {"label": "Database", "type": "single_choice", "question": "Which database?", "description": "PostgreSQL for relational data, MongoDB for documents.", "choices": [{"id": "pg", "label": "PostgreSQL"}, {"id": "mongo", "label": "MongoDB"}]}, + {"label": "Caching", "type": "yes_no", "question": "Enable caching?", "description": "Reduces latency for repeated queries but adds invalidation complexity."}, + {"label": "Concerns", "type": "free_text", "question": "Any concerns about this approach?", "description": "Share any reservations or edge cases we should consider."} + ], + "confirm_title": "Ready to configure?", + "confirm_description": "We'll set up PostgreSQL with query caching enabled." +} +``` + +## When to use + +- Confirm destructive or ambiguous actions +- User's request has multiple valid interpretations +- Need the user to pick from options +- Gather multiple related answers at once + +## When NOT to use + +- Questions answerable by reading code or docs +- Information obtainable via other tools +- Asking permission (use the permission system) diff --git a/internal/agent/tools/question_test.go b/internal/agent/tools/question_test.go new file mode 100644 index 0000000000..55e7f0e13c --- /dev/null +++ b/internal/agent/tools/question_test.go @@ -0,0 +1,49 @@ +package tools + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestQuestionParamsUnmarshalJSON_NativeArray(t *testing.T) { + t.Parallel() + input := `{"questions": [{"type": "yes_no", "question": "OK?", "description": "test"}]}` + var p QuestionParams + require.NoError(t, json.Unmarshal([]byte(input), &p)) + require.Len(t, p.Questions, 1) + require.Equal(t, "OK?", p.Questions[0].Question) +} + +func TestQuestionParamsUnmarshalJSON_StringEncodedArray(t *testing.T) { + t.Parallel() + // Simulates a model that double-serializes the questions field. + inner := `[{"type":"yes_no","question":"OK?","description":"test"}]` + encoded, _ := json.Marshal(inner) + input := `{"questions": ` + string(encoded) + `}` + var p QuestionParams + require.NoError(t, json.Unmarshal([]byte(input), &p)) + require.Len(t, p.Questions, 1) + require.Equal(t, "OK?", p.Questions[0].Question) +} + +func TestQuestionParamsUnmarshalJSON_StringEncodedWithWhitespace(t *testing.T) { + t.Parallel() + inner := ` [{"type":"single_choice","question":"Pick","description":"d","choices":[{"id":"a","label":"A"}]}] ` + encoded, _ := json.Marshal(inner) + input := `{"questions": ` + string(encoded) + `, "confirm_title": "Go?"}` + var p QuestionParams + require.NoError(t, json.Unmarshal([]byte(input), &p)) + require.Len(t, p.Questions, 1) + require.Equal(t, "Pick", p.Questions[0].Question) + require.Equal(t, "Go?", p.ConfirmTitle) +} + +func TestQuestionParamsUnmarshalJSON_InvalidString(t *testing.T) { + t.Parallel() + encoded, _ := json.Marshal("not valid json") + input := `{"questions": ` + string(encoded) + `}` + var p QuestionParams + require.Error(t, json.Unmarshal([]byte(input), &p)) +} diff --git a/internal/app/app.go b/internal/app/app.go index d8a3abc63b..b75523663e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -32,6 +32,7 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/pubsub" + "github.com/charmbracelet/crush/internal/question" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/shell" "github.com/charmbracelet/crush/internal/skills" @@ -56,6 +57,7 @@ type App struct { Messages message.Service History history.Service Permissions permission.Service + Questions question.Service FileTracker filetracker.Service AgentCoordinator agent.Coordinator @@ -105,6 +107,7 @@ func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore, skillsMgr Messages: messages, History: files, Permissions: permission.NewPermissionService(store.WorkingDir(), skipPermissionsRequests, allowedTools), + Questions: question.NewService(), FileTracker: filetracker.NewService(q), LSPManager: lsp.NewManager(store), Skills: skillsMgr, @@ -229,6 +232,11 @@ func (app *App) resolveSession(ctx context.Context, continueSessionID string, us func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool, continueSessionID string, useLast bool) error { slog.Info("Running in non-interactive mode") + // Re-initialize the coder agent without interactive-only tools. + if err := app.InitCoderAgentNonInteractive(ctx); err != nil { + return fmt.Errorf("failed to reinitialize agent for non-interactive mode: %w", err) + } + ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -499,6 +507,8 @@ func (app *App) setupEvents() { setupSubscriber(ctx, app.serviceEventsWG, "messages", app.Messages.Subscribe, app.events) setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events) setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events) + setupSubscriber(ctx, app.serviceEventsWG, "question-batches", app.Questions.Subscribe, app.events) + setupSubscriber(ctx, app.serviceEventsWG, "question-notifications", app.Questions.SubscribeNotifications, app.events) setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events) setupSubscriber(ctx, app.serviceEventsWG, "agent-notifications", app.agentNotifications.Subscribe, app.events) setupSubscriberMustDeliver(ctx, app.serviceEventsWG, "run-completions", app.runCompletions.Subscribe, app.events) @@ -574,24 +584,35 @@ func setupSubscriberMustDeliver[T any]( } func (app *App) InitCoderAgent(ctx context.Context) error { + return app.initCoderAgent(ctx, true) +} + +// InitCoderAgentNonInteractive initializes the coder agent without +// interactive-only tools (e.g. question). +func (app *App) InitCoderAgentNonInteractive(ctx context.Context) error { + return app.initCoderAgent(ctx, false) +} + +func (app *App) initCoderAgent(ctx context.Context, interactive bool) error { coderAgentCfg := app.config.Config().Agents[config.AgentCoder] if coderAgentCfg.ID == "" { return fmt.Errorf("coder agent configuration is missing") } var err error - app.AgentCoordinator, err = agent.NewCoordinator( - ctx, - app.config, - app.Sessions, - app.Messages, - app.Permissions, - app.History, - app.FileTracker, - app.LSPManager, - app.agentNotifications, - app.runCompletions, - app.Skills, - ) + app.AgentCoordinator, err = agent.NewCoordinator(ctx, agent.CoordinatorOptions{ + Config: app.config, + Sessions: app.Sessions, + Messages: app.Messages, + Permissions: app.Permissions, + Questions: app.Questions, + History: app.History, + FileTracker: app.FileTracker, + LSPManager: app.LSPManager, + Notify: app.agentNotifications, + RunComplete: app.runCompletions, + Skills: app.Skills, + Interactive: interactive, + }) if err != nil { slog.Error("Failed to create coder agent", "err", err) return err diff --git a/internal/app/testing.go b/internal/app/testing.go index 1722e2b154..a90c36e073 100644 --- a/internal/app/testing.go +++ b/internal/app/testing.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/crush/internal/agent/notify" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/pubsub" + "github.com/charmbracelet/crush/internal/question" ) // NewForTest constructs a minimal [App] suitable for in-process tests @@ -29,6 +30,7 @@ import ( func NewForTest(ctx context.Context) *App { app := &App{ Permissions: permission.NewPermissionService("", false, nil), + Questions: question.NewService(), globalCtx: ctx, events: pubsub.NewBroker[tea.Msg](), serviceEventsWG: &sync.WaitGroup{}, @@ -43,6 +45,10 @@ func NewForTest(ctx context.Context) *App { app.Permissions.Subscribe, app.events) setupSubscriber(eventsCtx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events) + setupSubscriber(eventsCtx, app.serviceEventsWG, "question-batches", + app.Questions.Subscribe, app.events) + setupSubscriber(eventsCtx, app.serviceEventsWG, "question-notifications", + app.Questions.SubscribeNotifications, app.events) setupSubscriber(eventsCtx, app.serviceEventsWG, "agent-notifications", app.agentNotifications.Subscribe, app.events) setupSubscriber(eventsCtx, app.serviceEventsWG, "run-completions", diff --git a/internal/backend/agent.go b/internal/backend/agent.go index 3d08746ed3..e9e97de6fa 100644 --- a/internal/backend/agent.go +++ b/internal/backend/agent.go @@ -140,13 +140,16 @@ func (b *Backend) GetAgentInfo(workspaceID string) (proto.AgentInfo, error) { } // InitAgent initializes the coder agent for the workspace. -func (b *Backend) InitAgent(ctx context.Context, workspaceID string) error { +func (b *Backend) InitAgent(ctx context.Context, workspaceID string, interactive bool) error { ws, err := b.GetWorkspace(workspaceID) if err != nil { return err } - return ws.InitCoderAgent(ctx) + if interactive { + return ws.InitCoderAgent(ctx) + } + return ws.InitCoderAgentNonInteractive(ctx) } // UpdateAgent reloads the agent model configuration. diff --git a/internal/backend/question.go b/internal/backend/question.go new file mode 100644 index 0000000000..55b2efe7bd --- /dev/null +++ b/internal/backend/question.go @@ -0,0 +1,29 @@ +package backend + +import ( + "github.com/charmbracelet/crush/internal/proto" + "github.com/charmbracelet/crush/internal/question" +) + +// AnswerQuestion submits answers for a question. The returned bool +// reports whether this call resolved the pending request (true) or +// found it already resolved by a previous caller (false). +func (b *Backend) AnswerQuestion(workspaceID string, req proto.QuestionAnswer) (bool, error) { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return false, err + } + + responses := make([]question.Answer, len(req.Responses)) + for i, r := range req.Responses { + responses[i] = question.Answer{ + QuestionID: r.QuestionID, + SelectedIDs: r.SelectedIDs, + FillInText: r.FillInText, + Yes: r.Yes, + Notes: r.Notes, + } + } + + return ws.Questions.Answer(responses), nil +} diff --git a/internal/client/proto.go b/internal/client/proto.go index d07e46dc84..3e85ada813 100644 --- a/internal/client/proto.go +++ b/internal/client/proto.go @@ -195,6 +195,18 @@ func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, er if !sendEvent(ctx, events, e) { return } + case pubsub.PayloadTypeQuestionRequest: + var e pubsub.Event[proto.QuestionRequest] + _ = json.Unmarshal(p.Payload, &e) + if !sendEvent(ctx, events, e) { + return + } + case pubsub.PayloadTypeQuestionNotification: + var e pubsub.Event[proto.QuestionNotification] + _ = json.Unmarshal(p.Payload, &e) + if !sendEvent(ctx, events, e) { + return + } case pubsub.PayloadTypeMessage: var e pubsub.Event[proto.Message] _ = json.Unmarshal(p.Payload, &e) @@ -476,8 +488,9 @@ func (c *Client) AgentSummarizeSession(ctx context.Context, id string, sessionID } // InitiateAgentProcessing triggers agent initialization on the server. -func (c *Client) InitiateAgentProcessing(ctx context.Context, id string) error { - rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/init", id), nil, nil, nil) +func (c *Client) InitiateAgentProcessing(ctx context.Context, id string, interactive bool) error { + body := jsonBody(proto.AgentInitRequest{Interactive: interactive}) + rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/init", id), nil, body, http.Header{"Content-Type": []string{"application/json"}}) if err != nil { return fmt.Errorf("failed to initiate session agent processing: %w", err) } @@ -594,6 +607,25 @@ func (c *Client) GrantPermission(ctx context.Context, id string, req proto.Permi return resp.Resolved, nil } +// AnswerQuestionBatch submits answers for a batch question on a +// workspace. Returns true if this call resolved the pending +// request, false if already resolved by another caller. +func (c *Client) AnswerQuestionBatch(ctx context.Context, id string, req proto.QuestionAnswer) (bool, error) { + rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/questions/answer", id), nil, jsonBody(req), http.Header{"Content-Type": []string{"application/json"}}) + if err != nil { + return false, fmt.Errorf("failed to answer question batch: %w", err) + } + defer rsp.Body.Close() + if rsp.StatusCode != http.StatusOK { + return false, fmt.Errorf("failed to answer question batch: status code %d", rsp.StatusCode) + } + var resp proto.QuestionAnswerResponse + if err := json.NewDecoder(rsp.Body).Decode(&resp); err != nil { + return false, fmt.Errorf("failed to decode answer question batch response: %w", err) + } + return resp.Resolved, nil +} + // SetPermissionsSkipRequests sets the skip-requests flag for a workspace. func (c *Client) SetPermissionsSkipRequests(ctx context.Context, id string, skip bool) error { rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/permissions/skip", id), nil, jsonBody(proto.PermissionSkipRequest{Skip: skip}), http.Header{"Content-Type": []string{"application/json"}}) diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 2feeba78e6..5d2742764c 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -105,6 +105,15 @@ crush run --continue "Follow up on your last response" event.AppInitialized() + if !ws.Config.IsConfigured() { + return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively") + } + + clientWs := workspace.NewClientWorkspace(c, *ws) + if err := clientWs.InitCoderAgentNonInteractive(ctx); err != nil { + return fmt.Errorf("failed to initialize agent: %w", err) + } + if sessionID != "" { sess, err := resolveSessionByID(ctx, c, ws.ID, sessionID) if err != nil { @@ -113,10 +122,6 @@ crush run --continue "Follow up on your last response" sessionID = sess.ID } - if !ws.Config.IsConfigured() { - return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively") - } - if verbose { slog.SetDefault(slog.New(log.New(os.Stderr))) } diff --git a/internal/config/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/proto/proto.go b/internal/proto/proto.go index 2e2cebc6ca..6d436167d7 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -181,6 +181,62 @@ type PermissionGrantResponse struct { Resolved bool `json:"resolved"` } +// QuestionRequest is the wire format for a batch question +// sent from server to client over SSE. +type QuestionRequest struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + ToolCallID string `json:"tool_call_id"` + Questions []QuestionItem `json:"questions"` + ConfirmTitle string `json:"confirm_title,omitempty"` + ConfirmDescription string `json:"confirm_description,omitempty"` +} + +// QuestionItem is a single question within a batch. +type QuestionItem struct { + ID string `json:"id"` + Type string `json:"type"` + Label string `json:"label,omitempty"` + Question string `json:"question"` + Description string `json:"description,omitempty"` + Choices []QuestionChoice `json:"choices,omitempty"` +} + +// QuestionChoice is a selectable option. +type QuestionChoice struct { + ID string `json:"id"` + Label string `json:"label"` + Description string `json:"description,omitempty"` +} + +// QuestionAnswer is the wire format for answering a batch +// question, sent from client to server via REST. +type QuestionAnswer struct { + BatchRequestID string `json:"batch_request_id"` + Responses []QuestionResponse `json:"responses"` +} + +// QuestionResponse is a single answer within a batch response. +type QuestionResponse struct { + QuestionID string `json:"request_id"` + SelectedIDs []string `json:"selected_ids,omitempty"` + FillInText string `json:"fill_in_text,omitempty"` + Yes *bool `json:"yes,omitempty"` + Notes map[string]string `json:"notes,omitempty"` +} + +// QuestionAnswerResponse is the server's response to a +// question batch answer call. +type QuestionAnswerResponse struct { + Resolved bool `json:"resolved"` +} + +// QuestionNotification is published when a question batch is +// resolved so non-answering clients can dismiss their forms. +type QuestionNotification struct { + BatchID string `json:"batch_id"` +} + // PermissionSkipRequest represents a request to skip permission prompts. type PermissionSkipRequest struct { Skip bool `json:"skip"` diff --git a/internal/proto/requests.go b/internal/proto/requests.go index e66807def9..e2a772ad6f 100644 --- a/internal/proto/requests.go +++ b/internal/proto/requests.go @@ -102,6 +102,11 @@ type ProjectInitPromptResponse struct { Prompt string `json:"prompt"` } +// AgentInitRequest represents a request to initialize the agent. +type AgentInitRequest struct { + Interactive bool `json:"interactive"` +} + // LSPStartRequest represents a request to start an LSP for a path. type LSPStartRequest struct { Path string `json:"path"` diff --git a/internal/pubsub/events.go b/internal/pubsub/events.go index 682672dfb2..dfe8b4c3a2 100644 --- a/internal/pubsub/events.go +++ b/internal/pubsub/events.go @@ -27,6 +27,8 @@ const ( PayloadTypeConfigChanged PayloadType = "config_changed" PayloadTypeSkillsEvent PayloadType = "skills_event" PayloadTypeRunComplete PayloadType = "run_complete" + PayloadTypeQuestionRequest PayloadType = "question_batch_request" + PayloadTypeQuestionNotification PayloadType = "question_batch_notification" ) // Payload wraps a discriminated JSON payload with a type tag. diff --git a/internal/question/question.go b/internal/question/question.go new file mode 100644 index 0000000000..40b5f6e5d4 --- /dev/null +++ b/internal/question/question.go @@ -0,0 +1,281 @@ +// Package question provides services for asking the user questions +// via the TUI and blocking until an answer is received. It mirrors +// the permission service pattern: publish a request over pubsub, +// block on a channel, and resolve when the UI sends back answers. +// +// Only one question can be pending at a time (the tool blocks until +// answered), so no correlation IDs are needed in the domain model. +package question + +import ( + "context" + "fmt" + "sync" + + "github.com/charmbracelet/crush/internal/pubsub" + "github.com/google/uuid" +) + +// Type identifies the kind of question to present. +type Type string + +const ( + TypeYesNo Type = "yes_no" + TypeSingleChoice Type = "single_choice" + TypeMultiChoice Type = "multi_choice" + TypeFreeText Type = "free_text" +) + +// Choice represents a single selectable option. +type Choice struct { + ID string `json:"id"` + Label string `json:"label"` + Description string `json:"description,omitempty"` +} + +// Question is a single question definition within a Request. +type Question struct { + ID string `json:"id"` + Type Type `json:"type"` + Label string `json:"label,omitempty"` + Text string `json:"question"` + Description string `json:"description,omitempty"` + Choices []Choice `json:"choices,omitempty"` +} + +// Answer carries the user's response to a single Question. +type Answer struct { + QuestionID string `json:"question_id"` + SelectedIDs []string `json:"selected_ids,omitempty"` + FillInText string `json:"fill_in_text,omitempty"` + Yes *bool `json:"yes,omitempty"` + Notes map[string]string `json:"notes,omitempty"` +} + +// HasNotes reports whether any notes were attached. +func (a Answer) HasNotes() bool { return len(a.Notes) > 0 } + +// Request is the service envelope published to the UI. It contains +// one or more Questions. A single question renders without tabs; +// multiple questions render as a tabbed form with confirmation. +type Request struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + ToolCallID string `json:"tool_call_id"` + Questions []Question `json:"questions"` + ConfirmTitle string `json:"confirm_title,omitempty"` + ConfirmDescription string `json:"confirm_description,omitempty"` +} + +// Validate checks that a Request has valid fields. For multiple +// questions, ConfirmTitle and ConfirmDescription are required. +func (r Request) Validate() error { + if len(r.Questions) == 0 { + return fmt.Errorf("at least one question is required") + } + if len(r.Questions) > MaxQuestions { + return fmt.Errorf("questions exceed maximum of %d (got %d)", MaxQuestions, len(r.Questions)) + } + for i, q := range r.Questions { + if err := q.Validate(); err != nil { + return fmt.Errorf("question %d: %w", i+1, 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 { + label := q.identifier() + if q.Text == "" { + return fmt.Errorf("%s: question text is required", label) + } + if len(q.Text) > MaxQuestionLength { + return fmt.Errorf("%s: text exceeds %d characters (got %d)", label, MaxQuestionLength, len(q.Text)) + } + if q.Description == "" { + return fmt.Errorf("%s: description is required", label) + } + if len(q.Description) > MaxDescriptionLength { + return fmt.Errorf("%s: description exceeds %d characters (got %d)", label, 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: %s requires at least 2 choices in the \"choices\" array (got %d). Use \"choices\", not \"options\"", label, q.Type, len(q.Choices)) + } + if len(q.Choices) > MaxChoices { + return fmt.Errorf("%s: choices exceed maximum of %d (got %d)", label, MaxChoices, len(q.Choices)) + } + seen := make(map[string]bool, len(q.Choices)) + for i, c := range q.Choices { + if c.ID == "" { + return fmt.Errorf("%s: choice %d must have an \"id\" field", label, i+1) + } + if seen[c.ID] { + return fmt.Errorf("%s: choice %d has duplicate id %q", label, i+1, c.ID) + } + seen[c.ID] = true + if c.Label == "" { + return fmt.Errorf("%s: choice %d (%s) must have a \"label\" field", label, i+1, c.ID) + } + if len(c.Label) > MaxChoiceLabelLength { + return fmt.Errorf("%s: choice %d label exceeds %d characters (got %d)", label, i+1, MaxChoiceLabelLength, len(c.Label)) + } + if len(c.Description) > MaxChoiceDescriptionLength { + return fmt.Errorf("%s: choice %d description exceeds %d characters (got %d)", label, i+1, MaxChoiceDescriptionLength, len(c.Description)) + } + } + default: + return fmt.Errorf("%s: unknown type %q (must be yes_no, single_choice, multi_choice, or free_text)", label, q.Type) + } + return nil +} + +// identifier returns a human-readable label for error messages. +// Uses the question label, text excerpt, or a fallback. +func (q Question) identifier() string { + if q.Label != "" { + return fmt.Sprintf("[%s]", q.Label) + } + if q.Text != "" { + t := q.Text + if len(t) > 40 { + t = t[:40] + "…" + } + return fmt.Sprintf("[%s]", t) + } + return "[unnamed question]" +} + +const ( + MaxQuestionLength = 120 + MaxDescriptionLength = 300 + MaxChoiceLabelLength = 120 + MaxChoiceDescriptionLength = 100 + MaxChoices = 5 + MaxQuestions = 5 +) + +// Notification is published when a question batch is resolved so +// that non-answering clients can dismiss their open forms. +type Notification struct { + BatchID string `json:"batch_id"` +} + +// Service manages the lifecycle of question requests. Only one +// question can be pending at a time. +type Service interface { + pubsub.Subscriber[Request] + + // SubscribeNotifications returns a channel for question + // resolution notifications. + SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[Notification] + + // Ask publishes questions and blocks until the user answers + // or the context is cancelled. + Ask(ctx context.Context, req Request) ([]Answer, error) + + // Answer resolves the pending question with the given answers. + Answer(answers []Answer) bool +} + +type questionService struct { + broker *pubsub.Broker[Request] + notificationBroker *pubsub.Broker[Notification] + mu sync.Mutex + pending chan []Answer + pendingID string +} + +// NewService creates a new question service. +func NewService() *questionService { + return &questionService{ + broker: pubsub.NewBroker[Request](), + notificationBroker: pubsub.NewBroker[Notification](), + } +} + +// Subscribe returns a channel for question events. +func (s *questionService) Subscribe(ctx context.Context) <-chan pubsub.Event[Request] { + return s.broker.Subscribe(ctx) +} + +// SubscribeNotifications returns a channel for question resolution +// notifications. +func (s *questionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[Notification] { + return s.notificationBroker.Subscribe(ctx) +} + +// Ask publishes a request and blocks until the user answers. +func (s *questionService) Ask(ctx context.Context, req Request) ([]Answer, error) { + if req.ID == "" { + req.ID = uuid.New().String() + } + for i := range req.Questions { + if req.Questions[i].ID == "" { + req.Questions[i].ID = uuid.New().String() + } + } + + // Apply defaults for multi-question confirm fields. + if len(req.Questions) >= 2 { + if req.ConfirmTitle == "" { + req.ConfirmTitle = "Ready to go?" + } + if req.ConfirmDescription == "" { + req.ConfirmDescription = "Review your answers above and confirm." + } + } + + if err := req.Validate(); err != nil { + return nil, err + } + + s.mu.Lock() + s.pending = make(chan []Answer, 1) + s.pendingID = req.ID + s.mu.Unlock() + + defer func() { + s.mu.Lock() + s.pending = nil + s.pendingID = "" + s.mu.Unlock() + }() + + s.broker.Publish(pubsub.CreatedEvent, req) + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case answers := <-s.pending: + return answers, nil + } +} + +// Answer resolves the pending question. Returns false if no +// question is pending (already answered or cancelled). +func (s *questionService) Answer(answers []Answer) bool { + s.mu.Lock() + batchID := s.pendingID + ch := s.pending + s.mu.Unlock() + + if ch == nil { + return false + } + ch <- answers + + // Publish a notification so non-answering clients can dismiss + // their open question forms. + if batchID != "" { + s.notificationBroker.Publish(pubsub.CreatedEvent, Notification{ + BatchID: batchID, + }) + } + return true +} diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go index 565a989136..887d33c695 100644 --- a/internal/server/e2e_test.go +++ b/internal/server/e2e_test.go @@ -234,6 +234,12 @@ func decodeSSEEnvelope(p pubsub.Payload) (any, bool) { return nil, false } return e, true + case pubsub.PayloadTypeQuestionNotification: + var e pubsub.Event[proto.QuestionNotification] + if err := json.Unmarshal(p.Payload, &e); err != nil { + return nil, false + } + return e, true case pubsub.PayloadTypeMessage: var e pubsub.Event[proto.Message] if err := json.Unmarshal(p.Payload, &e); err != nil { diff --git a/internal/server/events.go b/internal/server/events.go index 526f9e1950..c6f09c13cf 100644 --- a/internal/server/events.go +++ b/internal/server/events.go @@ -15,6 +15,7 @@ import ( "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/proto" "github.com/charmbracelet/crush/internal/pubsub" + "github.com/charmbracelet/crush/internal/question" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/skills" ) @@ -70,6 +71,26 @@ func wrapEvent(ev any) *pubsub.Payload { Denied: e.Payload.Denied, }, }) + case pubsub.Event[question.Request]: + slog.Info("Wrapping question batch event for SSE", "id", e.Payload.ID, "questions", len(e.Payload.Questions)) + return envelope(pubsub.PayloadTypeQuestionRequest, pubsub.Event[proto.QuestionRequest]{ + Type: e.Type, + Payload: proto.QuestionRequest{ + ID: e.Payload.ID, + SessionID: e.Payload.SessionID, + ToolCallID: e.Payload.ToolCallID, + Questions: questionsToProto(e.Payload.Questions), + ConfirmTitle: e.Payload.ConfirmTitle, + ConfirmDescription: e.Payload.ConfirmDescription, + }, + }) + case pubsub.Event[question.Notification]: + return envelope(pubsub.PayloadTypeQuestionNotification, pubsub.Event[proto.QuestionNotification]{ + Type: e.Type, + Payload: proto.QuestionNotification{ + BatchID: e.Payload.BatchID, + }, + }) case pubsub.Event[message.Message]: return envelope(pubsub.PayloadTypeMessage, pubsub.Event[proto.Message]{ Type: e.Type, @@ -303,3 +324,29 @@ func messagesToProto(msgs []message.Message) []proto.Message { } return out } + +func questionsToProto(qs []question.Question) []proto.QuestionItem { + if len(qs) == 0 { + return nil + } + out := make([]proto.QuestionItem, len(qs)) + for i, q := range qs { + choices := make([]proto.QuestionChoice, len(q.Choices)) + for j, c := range q.Choices { + choices[j] = proto.QuestionChoice{ + ID: c.ID, + Label: c.Label, + Description: c.Description, + } + } + out[i] = proto.QuestionItem{ + ID: q.ID, + Type: string(q.Type), + Label: q.Label, + Question: q.Text, + Description: q.Description, + Choices: choices, + } + } + return out +} diff --git a/internal/server/proto.go b/internal/server/proto.go index 1d0dddece0..767fb58b45 100644 --- a/internal/server/proto.go +++ b/internal/server/proto.go @@ -786,7 +786,17 @@ func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.R // @Router /workspaces/{id}/agent/init [post] func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if err := c.backend.InitAgent(r.Context(), id); err != nil { + + var req proto.AgentInitRequest + if r.Body != nil && r.ContentLength > 0 { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + c.server.logError(r, "Failed to decode agent init request", "error", err) + jsonError(w, http.StatusBadRequest, "failed to decode request") + return + } + } + + if err := c.backend.InitAgent(r.Context(), id, req.Interactive); err != nil { c.handleError(w, r, err) return } @@ -989,6 +999,36 @@ func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter jsonEncode(w, proto.PermissionGrantResponse{Resolved: resolved}) } +// handlePostWorkspaceQuestionsAnswer submits answers for a batch question. +// +// @Summary Answer question batch +// @Tags questions +// @Accept json +// @Param id path string true "Workspace ID" +// @Param request body proto.QuestionAnswer true "Question batch answer" +// @Success 200 {object} proto.QuestionAnswerResponse +// @Failure 400 {object} proto.Error +// @Failure 404 {object} proto.Error +// @Failure 500 {object} proto.Error +// @Router /workspaces/{id}/questions/answer [post] +func (c *controllerV1) handlePostWorkspaceQuestionsAnswer(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + var req proto.QuestionAnswer + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + c.server.logError(r, "Failed to decode request", "error", err) + jsonError(w, http.StatusBadRequest, "failed to decode request") + return + } + + resolved, err := c.backend.AnswerQuestion(id, req) + if err != nil { + c.handleError(w, r, err) + return + } + jsonEncode(w, proto.QuestionAnswerResponse{Resolved: resolved}) +} + // handlePostWorkspacePermissionsSkip sets whether to skip permission prompts. // // @Summary Set skip permissions diff --git a/internal/server/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/chat/question.go b/internal/ui/chat/question.go new file mode 100644 index 0000000000..9d930b0484 --- /dev/null +++ b/internal/ui/chat/question.go @@ -0,0 +1,220 @@ +package chat + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// QuestionToolMessageItem renders question tool calls in the chat. +type QuestionToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*QuestionToolMessageItem)(nil) + +// NewQuestionToolMessageItem creates a new [QuestionToolMessageItem]. +func NewQuestionToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &QuestionToolRenderContext{}, canceled) +} + +// QuestionToolRenderContext renders question tool messages. +type QuestionToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (q *QuestionToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Question", opts.Anim, opts.Compact) + } + + var params tools.QuestionParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + headerText := questionSummary(params) + header := toolHeader(sty, opts.Status, "Question", cappedWidth, opts.Compact, headerText) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + body := formatQuestionAnswers(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal) + if body == "" { + return header + } + + return joinToolParts(header, sty.Tool.Body.Render(body)) +} + +// questionSummary builds a short header summary from the question params. +func questionSummary(params tools.QuestionParams) string { + n := len(params.Questions) + if n == 0 { + return "" + } + if n == 1 { + text := params.Questions[0].Question + if len(text) > 60 { + text = text[:59] + "…" + } + return text + } + first := params.Questions[0].Question + if len(first) > 40 { + first = first[:39] + "…" + } + return fmt.Sprintf("%s (+%d more)", first, n-1) +} + +// questionBlock holds a parsed Q&A block from the tool result. +type questionBlock struct { + question string + answer string + notes []string +} + +// parseQuestionBlocks splits the tool result into per-question blocks, +// correctly handling the Notes subsection within each block. +func parseQuestionBlocks(content string) []questionBlock { + // Split on "QN: " boundaries rather than \n\n since notes + // introduce extra \n\n within a single question block. + var rawBlocks []string + lines := strings.Split(content, "\n") + var current []string + for _, line := range lines { + if strings.HasPrefix(line, "Q") && len(current) > 0 { + rest := strings.TrimPrefix(line, "Q") + if idx := strings.IndexByte(rest, ':'); idx > 0 { + allDigits := true + for _, c := range rest[:idx] { + if c < '0' || c > '9' { + allDigits = false + break + } + } + if allDigits { + rawBlocks = append(rawBlocks, strings.Join(current, "\n")) + current = nil + } + } + } + current = append(current, line) + } + if len(current) > 0 { + rawBlocks = append(rawBlocks, strings.Join(current, "\n")) + } + + blocks := make([]questionBlock, 0, len(rawBlocks)) + for _, raw := range rawBlocks { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + + var b questionBlock + + // Strip the "QN: \n" prefix. + if nlIdx := strings.IndexByte(raw, '\n'); nlIdx >= 0 { + b.question = strings.TrimSpace(raw[:nlIdx]) + // Remove the "QN: " prefix from the question text. + if colonIdx := strings.Index(b.question, ": "); colonIdx >= 0 { + b.question = b.question[colonIdx+2:] + } + raw = raw[nlIdx+1:] + } + + // Split off notes section if present. + if notesIdx := strings.Index(raw, "\n\nNotes:"); notesIdx >= 0 { + b.answer = strings.TrimSpace(raw[:notesIdx]) + notesRaw := raw[notesIdx+len("\n\nNotes:"):] + for _, noteLine := range strings.Split(notesRaw, "\n") { + noteLine = strings.TrimSpace(noteLine) + if strings.HasPrefix(noteLine, "- ") { + b.notes = append(b.notes, strings.TrimPrefix(noteLine, "- ")) + } + } + } else { + b.answer = strings.TrimSpace(raw) + } + + blocks = append(blocks, b) + } + + return blocks +} + +// formatQuestionAnswers parses the tool result and formats answers with +// styling for display in the chat body. +func formatQuestionAnswers(sty *styles.Styles, content string, width int) string { + if content == "" { + return "" + } + + blocks := parseQuestionBlocks(content) + if len(blocks) == 0 { + return "" + } + + var lines []string + for _, b := range blocks { + icon := sty.Tool.IconSuccess.Render() + answer := styleAnswer(sty, b.answer) + + // Show question text in subtle style, answer on same line. + qText := sty.Tool.TodoStatusNote.Render(b.question) + line := fmt.Sprintf("%s %s %s", icon, qText, answer) + line = ansi.Truncate(line, width, "…") + lines = append(lines, line) + + for _, note := range b.notes { + noteLine := sty.Tool.TodoStatusNote.Render(" ╰ " + note) + noteLine = ansi.Truncate(noteLine, width, "…") + lines = append(lines, noteLine) + } + } + + return strings.Join(lines, "\n") +} + +// styleAnswer extracts the meaningful part of an answer string and styles it. +func styleAnswer(sty *styles.Styles, answer string) string { + answer = strings.TrimSpace(answer) + + switch { + case answer == "User answered: yes": + return sty.Tool.TodoCompletedIcon.Render("Yes") + case answer == "User answered: no": + return sty.Tool.StateCancelled.Render("No") + case strings.HasPrefix(answer, "User selected:"): + selected := strings.TrimPrefix(answer, "User selected: ") + selected = strings.Trim(selected, "[]\"") + selected = strings.ReplaceAll(selected, "\",\"", ", ") + return sty.Tool.ParamMain.Render(selected) + case strings.HasPrefix(answer, "User provided:"): + text := strings.TrimPrefix(answer, "User provided: ") + return sty.Tool.ParamMain.Render(text) + case answer == "User skipped this question": + return sty.Tool.StateCancelled.Render("Skipped") + default: + return answer + } +} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 961173f30b..6b0a6dfaa2 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -255,6 +255,8 @@ func NewToolMessageItem( item = NewWebSearchToolMessageItem(sty, toolCall, result, canceled) case tools.TodosToolName: item = NewTodosToolMessageItem(sty, toolCall, result, canceled) + case tools.QuestionToolName: + item = NewQuestionToolMessageItem(sty, toolCall, result, canceled) case tools.ReferencesToolName: item = NewReferencesToolMessageItem(sty, toolCall, result, canceled) case tools.LSPRestartToolName: diff --git a/internal/ui/common/button.go b/internal/ui/common/button.go index 73524c2b3f..c245fa063b 100644 --- a/internal/ui/common/button.go +++ b/internal/ui/common/button.go @@ -1,6 +1,7 @@ package common import ( + "fmt" "strings" "charm.land/lipgloss/v2" @@ -15,15 +16,21 @@ type ButtonOpts struct { UnderlineIndex int // Selected indicates whether this button is currently selected Selected bool + // Hovered indicates whether the mouse is hovering over the button + Hovered bool // Padding inner horizontal padding defaults to 2 if this is 0 Padding int } // Button creates a button with an underlined character and selection state func Button(t *styles.Styles, opts ButtonOpts) string { - // Select style based on selection state + // Select style based on selection/hover state. style := t.Button.Blurred - if opts.Selected { + if opts.Selected && opts.Hovered { + style = t.Button.Focused.Bold(true) + } else if opts.Hovered { + style = t.Button.Hovered.Bold(true) + } else if opts.Selected { style = t.Button.Focused } @@ -67,3 +74,44 @@ func ButtonGroup(t *styles.Styles, buttons []ButtonOpts, spacing string) string return strings.Join(parts, spacing) } + +// ButtonHitCompositor builds a lipgloss Compositor with one hit +// layer per button, positioned horizontally at (x, y). Layer IDs +// are "btn_0", "btn_1", etc. The spacing parameter must match +// what was passed to ButtonGroup when rendering. +func ButtonHitCompositor(sty *styles.Styles, opts []ButtonOpts, spacing string, x, y int) *lipgloss.Compositor { + if len(opts) == 0 { + return nil + } + if spacing == "" { + spacing = " " + } + spacingWidth := lipgloss.Width(spacing) + var layers []*lipgloss.Layer + bx := x + for i, o := range opts { + b := Button(sty, o) + w := lipgloss.Width(b) + hitStr := strings.Repeat(" ", w) + layers = append(layers, lipgloss.NewLayer(hitStr).X(bx).Y(y).ID(fmt.Sprintf("btn_%d", i))) + bx += w + spacingWidth + } + return lipgloss.NewCompositor(layers...) +} + +// HitButtonIndex checks a compositor for a button hit and returns +// the button index, or -1 if no button was hit. +func HitButtonIndex(c *lipgloss.Compositor, x, y int) int { + if c == nil { + return -1 + } + hit := c.Hit(x, y) + if hit.Empty() { + return -1 + } + var idx int + if _, err := fmt.Sscanf(hit.ID(), "btn_%d", &idx); err != nil { + return -1 + } + return idx +} diff --git a/internal/ui/dialog/inline_editor.go b/internal/ui/dialog/inline_editor.go new file mode 100644 index 0000000000..999b66dcbc --- /dev/null +++ b/internal/ui/dialog/inline_editor.go @@ -0,0 +1,62 @@ +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) +} + +// MouseClickableEditor is an optional interface for inline editors +// that handle mouse clicks and hover highlighting. The UI +// type-asserts for this before routing click and motion events. +type MouseClickableEditor interface { + InlineEditor + // HandleMouseClick processes a mouse click at the given screen + // coordinates. Returns done=true when the editor has completed + // (answer submitted or dismissed), and handled=true if the click + // was consumed (even if not done). + HandleMouseClick(x, y int) (done bool, handled bool) + // SetHover updates the current mouse position for hover + // highlighting. Called on every MouseMotionMsg while the + // editor is active. + SetHover(x, y int) +} + +// PasteableEditor is an optional interface for inline editors +// that contain text areas and can receive paste events. The UI +// type-asserts for this before routing tea.PasteMsg. +type PasteableEditor interface { + // HandlePaste processes a paste message. Returns an optional + // tea.Cmd for side effects (e.g., focus commands). + HandlePaste(msg tea.PasteMsg) tea.Cmd +} diff --git a/internal/ui/dialog/question_choice_base.go b/internal/ui/dialog/question_choice_base.go new file mode 100644 index 0000000000..a31b9f0f15 --- /dev/null +++ b/internal/ui/dialog/question_choice_base.go @@ -0,0 +1,614 @@ +package dialog + +import ( + "fmt" + "image" + "strconv" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/question" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" +) + +// choiceListMaxWidth is the maximum content width for choice +// question components. +const choiceListMaxWidth = 120 + +// questionIconPrompt returns the themed question icon based on +// focus state. Shared by all question component types. +func questionIconPrompt(sty *styles.Styles, focused bool) string { + if focused { + return sty.Editor.PromptQuestionIconFocused.Render() + } + return sty.Editor.PromptQuestionIconBlurred.Render() +} + +// choiceList is the shared base for single-choice and multi-choice +// question components. It embeds questionEditor for fill-in, notes, +// and editor handling. Concrete types embed it and only implement +// selection semantics. +type choiceList struct { + questionEditor + Request question.Question + + cursorIdx int + scrollOffset int // lines scrolled past the top of the viewport + focused bool + lastWidth int + choiceCompositor *lipgloss.Compositor + suppressScroll bool // skip scroll clamping after mouse click + hoverX, hoverY int // current mouse position for hover highlight + hoveredChoice int // choice index under mouse, or -1 + mouseActive bool // true when last interaction was mouse (hover mode) + + // styleFillInAsSelected controls whether non-empty fill-in text + // gets the selected (pink) style. True for single-choice where + // the fill-in IS the answer; false for multi-choice. + styleFillInAsSelected bool + + keyUp key.Binding + keyDown key.Binding + keyClose key.Binding +} + +// numberKeyIndex returns the zero-based choice index for a number +// key press (1-9), or -1 if the key is not a valid shortcut for +// the current choices. +func (c *choiceList) numberKeyIndex(msg tea.KeyPressMsg) int { + if len(msg.Text) != 1 { + return -1 + } + n, err := strconv.Atoi(msg.Text) + if err != nil || n < 1 || n > len(c.Request.Choices) { + return -1 + } + return n - 1 +} + +// newChoiceList creates a choiceList with a configured fill-in +// textarea and navigation bindings. +func newChoiceList(sty *styles.Styles, req question.Question) choiceList { + return choiceList{ + questionEditor: newQuestionEditor(sty), + Request: req, + hoveredChoice: -1, + hoverX: -1, + hoverY: -1, + keyUp: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑", "up")), + keyDown: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓", "down")), + keyClose: CloseKey, + } +} + +func (c *choiceList) itemCount() int { + return len(c.Request.Choices) + 1 // +1 for fill-in +} + +func (c *choiceList) isFillIn() bool { + return c.cursorIdx == len(c.Request.Choices) +} + +// moveUp moves the cursor up, wrapping around. Closes any active +// note editor since the note context changes with the cursor. +func (c *choiceList) moveUp() { + if c.mouseActive { + if c.hoveredChoice >= 0 { + c.cursorIdx = c.hoveredChoice + } else { + c.cursorIdx = 0 + } + } + c.mouseActive = false + c.fillIn.Blur() + if c.activeNoteKey != "" { + c.closeNote(c.noteKey()) + } + c.cursorIdx-- + if c.cursorIdx < 0 { + c.cursorIdx = c.itemCount() - 1 + } +} + +// moveDown moves the cursor down, wrapping around. Closes any +// active note editor since the note context changes with the cursor. +func (c *choiceList) moveDown() { + if c.mouseActive { + if c.hoveredChoice >= 0 { + c.cursorIdx = c.hoveredChoice - 1 // will become hoveredChoice after increment + } else { + c.cursorIdx = -1 // will become 0 after increment + } + } + c.mouseActive = false + c.fillIn.Blur() + if c.activeNoteKey != "" { + c.closeNote(c.noteKey()) + } + c.cursorIdx++ + if c.cursorIdx >= c.itemCount() { + c.cursorIdx = 0 + } +} + +// handleFillInKey processes keys when the fill-in textarea is +// focused. Returns (cmd, handled). When handled is true the +// caller should not process the key further. +func (c *choiceList) handleFillInKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { + switch { + case key.Matches(msg, c.keyClose): + c.fillIn.Blur() + return nil, true + case key.Matches(msg, c.navUp): + c.moveUp() + if c.isFillIn() { + c.fillIn.Focus() + return c.fillIn.Focus(), true + } + return nil, true + case key.Matches(msg, c.navDown): + c.moveDown() + if c.isFillIn() { + c.fillIn.Focus() + return c.fillIn.Focus(), true + } + return nil, true + default: + var cmd tea.Cmd + c.fillIn, cmd = c.fillIn.Update(msg) + return cmd, true + } +} + +// handleNavKey processes up/down navigation keys when the +// fill-in is NOT focused. Returns true if the key was consumed. +func (c *choiceList) handleNavKey(msg tea.KeyPressMsg) bool { + switch { + case key.Matches(msg, c.keyUp): + c.moveUp() + if c.isFillIn() { + c.fillIn.Focus() + } + return true + case key.Matches(msg, c.keyDown): + c.moveDown() + if c.isFillIn() { + c.fillIn.Focus() + } + return true + } + return false +} + +// noteKey returns the map key for the currently focused item's +// note. Choices use their ID; the question itself uses "_question". +func (c *choiceList) noteKey() string { + if c.isFillIn() || c.cursorIdx >= len(c.Request.Choices) { + return "_question" + } + return c.Request.Choices[c.cursorIdx].ID +} + +// contentLine is one visual row of the choice list. text is the +// pre-rendered, pre-styled string for the row. fillInRow marks the +// first row of the focused fill-in textarea, where the hardware +// cursor is placed. noteRow marks the first row of the focused +// note editor. +type contentLine struct { + text string + fillInRow bool + noteRow bool + cursorItem bool // belongs to the currently selected item + choiceIdx int // zero-based choice index, or -1 if not a choice row +} + +// newContentLine creates a contentLine with choiceIdx initialized +// to -1 so non-choice rows don't accidentally match choice_0. +func newContentLine(text string) contentLine { + return contentLine{text: text, choiceIdx: -1} +} + +// sectionHeight returns the visual line count of a text block +// wrapped at width. +func sectionHeight(text string, width int) int { + if text == "" { + return 0 + } + return strings.Count(ansi.Wrap(text, width, ""), "\n") + 1 +} + +// wrapIndent wraps text at width and prefixes every continuation +// line with indent so multi-line content aligns under the first +// line's content rather than flush left. +func wrapIndent(text string, width int, indent string) string { + wrapped := ansi.Wrap(text, width, "") + lines := strings.Split(wrapped, "\n") + for i := 1; i < len(lines); i++ { + lines[i] = indent + lines[i] + } + return strings.Join(lines, "\n") +} + +// drawStyledText blits an ANSI-styled string into area and returns +// the number of visual lines it occupies. +func drawStyledText(scr uv.Screen, area uv.Rectangle, text string) int { + if text == "" { + return 0 + } + uv.NewStyledString(text).Draw(scr, area) + return strings.Count(text, "\n") + 1 +} + +// buildLines renders the entire choice list into a flat slice of +// rows. This is the single source of truth: height is len(lines), +// scrolling is index math over the slice, and drawing blits a +// window of it. itemFn renders a choice's label row(s) as a string. +// +// The final row is always a blank line, giving the list one line of +// bottom padding as real content rather than a phantom offset. +func (c *choiceList) buildLines(innerWidth int, fillInPrefix string, itemFn choiceItemRenderer) []contentLine { + bodyStyle := c.Styles.Editor.QuestionBody + barActive := c.Styles.Editor.QuestionCursorBar.Render("┃ ") + const barInactive = " " + + var lines []contentLine + push := func(text string, flags ...bool) { + cl := newContentLine(text) + if len(flags) > 0 { + cl.fillInRow = flags[0] + } + if len(flags) > 1 { + cl.cursorItem = flags[1] + } + // Split multi-line strings into one row each. + for ln := range strings.SplitSeq(text, "\n") { + row := cl + row.text = ln + lines = append(lines, row) + } + } + + // Question header + blank separator. + icon := c.iconPrompt() + iconWidth := lipgloss.Width(icon) + qIndent := strings.Repeat(" ", iconWidth) + push(icon + c.Styles.Editor.QuestionUnselected.Render(wrapIndent(c.Request.Text, innerWidth-iconWidth, qIndent))) + push("") + + // Optional markdown description + blank separator. + if c.Request.Description != "" { + push(c.renderDescription(innerWidth)) + push("") + } + + // Choices: label row(s), optional wrapped description, note, blank. + for i, ch := range c.Request.Choices { + active := i == c.cursorIdx && !c.mouseActive + hovered := i == c.hoveredChoice && c.mouseActive + bar := barInactive + if active || hovered { + bar = barActive + } + content := itemFn(i, ch, active, innerWidth) + // Prepend bar to every line so continuation lines also + // show the selection indicator. + for j, ln := range strings.Split(content, "\n") { + b := bar + if j > 0 && !active { + b = barInactive + } + lines = append(lines, contentLine{text: b + ln, cursorItem: active, choiceIdx: i}) + } + + if ch.Description != "" { + descContent := bodyStyle.Render(wrapIndent(ch.Description, innerWidth-lipgloss.Width(bar), "")) + for j, ln := range strings.Split(descContent, "\n") { + b := bar + if j > 0 && !active { + b = barInactive + } + lines = append(lines, contentLine{text: b + ln, cursorItem: active, choiceIdx: i}) + } + } + + // Inline note editor or saved note for this choice. + c.drawNote(&lines, innerWidth, bar, barInactive, ch.ID, active) + + // Blank separator — tag with current choice index so it's + // part of the clickable/hoverable zone. + lines = append(lines, contentLine{text: "", choiceIdx: i}) + } + + // Fill-in: live textarea when focused, otherwise placeholder. + // Show active gutter only when focused or has content. + hasFillInText := strings.TrimSpace(c.fillIn.Value()) != "" + fillActive := c.isFillIn() && (c.fillIn.Focused() || hasFillInText) + fillPrefix := c.Styles.Editor.QuestionBody.Render("> ") + if c.styleFillInAsSelected && hasFillInText { + fillPrefix = c.Styles.Editor.QuestionSelected.Render("> ") + } + fillBar := barInactive + if fillActive { + fillBar = barActive + } + linesBeforeFillIn := len(lines) + c.drawFillIn(&lines, innerWidth, fillBar, barInactive, fillPrefix, c.isFillIn(), false) + + // Tag fill-in rows with the fill-in item index so clicks can + // navigate to it. + fillInIdx := len(c.Request.Choices) + for i := linesBeforeFillIn; i < len(lines); i++ { + lines[i].choiceIdx = fillInIdx + } + + // Trailing blank line for bottom padding. + push("") + + return lines +} + +// renderDescription renders the markdown description at width. +func (c *choiceList) renderDescription(width int) string { + r := common.MarkdownRenderer(c.Styles, width) + mu := common.LockMarkdownRenderer(r) + mu.Lock() + out, err := r.Render(c.Request.Description) + mu.Unlock() + if err != nil { + return c.Request.Description + } + return strings.TrimSuffix(out, "\n") +} + +// choiceItemRenderer renders a choice's label content as a string. +// The bar prefix is applied by buildLines so that continuation +// lines also receive it. innerWidth is the available content +// width for this particular render pass (may differ between +// overflow-test and final render). +type choiceItemRenderer func(index int, choice question.Choice, active bool, innerWidth int) string + +// height returns the total visual height at the given width. It is +// len(buildLines), the single source of truth for layout. +func (c *choiceList) height(width int) int { + w := c.lastWidth + if w <= 0 { + w = width + } + innerWidth := min(w-4, choiceListMaxWidth) + return len(c.buildLines(innerWidth, "> ", func(int, question.Choice, bool, int) string { + return "x" // single-line placeholder; only count matters + })) +} + +func (c *choiceList) heightChanged() bool { + return false // height is deterministic +} + +func (c *choiceList) setFocused(focused bool) { + c.focused = focused +} + +// setHover updates the hover position and resolves which choice +// is under the cursor using the compositor. +func (c *choiceList) setHover(x, y int) { + c.hoverX = x + c.hoverY = y + c.mouseActive = true + c.hoveredChoice = -1 + if c.choiceCompositor == nil { + return + } + hit := c.choiceCompositor.Hit(x, y) + if !hit.Empty() { + var idx int + if _, err := fmt.Sscanf(hit.ID(), "choice_%d", &idx); err == nil { + c.hoveredChoice = idx + } + } +} + +// iconPrompt returns the themed question icon based on focus. +func (c *choiceList) iconPrompt() string { + return questionIconPrompt(c.Styles, c.focused) +} + +// drawContent renders the choice list with scroll support. It +// builds the full line list, clamps the scroll offset to keep the +// cursor visible, then blits the visible window. Returns the +// hardware cursor position, or nil. +func (c *choiceList) drawContent(scr uv.Screen, area uv.Rectangle, fillInPrefix string, itemFn choiceItemRenderer) *tea.Cursor { + c.lastWidth = area.Dx() + viewport := area.Dy() + + // Build lines at the narrow width first to test overflow. + // If content fits without a scrollbar, rebuild at the wider + // width so text uses the full available space. + contentWidth := area.Dx() + innerNarrow := min(contentWidth-1-4, choiceListMaxWidth) + innerWide := min(contentWidth-4, choiceListMaxWidth) + + lines := c.buildLines(innerNarrow, fillInPrefix, itemFn) + overflow := viewport > 0 && len(lines) > viewport + if !overflow && innerWide != innerNarrow { + lines = c.buildLines(innerWide, fillInPrefix, itemFn) + overflow = viewport > 0 && len(lines) > viewport + if overflow { + // Adding the scrollbar column caused wrapping that + // created overflow. Stick with the narrow width. + lines = c.buildLines(innerNarrow, fillInPrefix, itemFn) + } + } + + if overflow { + contentWidth-- + } + c.clampScroll(lines, viewport) + + // Blit the visible window. + var cur *tea.Cursor + for screenRow := range viewport { + idx := c.scrollOffset + screenRow + if idx >= len(lines) { + break + } + ln := lines[idx] + y := area.Min.Y + screenRow + if ln.text != "" { + uv.NewStyledString(ln.text).Draw(scr, image.Rect(area.Min.X, y, area.Min.X+contentWidth, y+1)) + } + if ln.fillInRow { + fillPrefix := c.Styles.Editor.QuestionBody.Render("> ") + if tc := c.fillInCursor(screenRow, area.Min.X, lipgloss.Width(fillPrefix)); tc != nil { + cur = tc + } + } + if ln.noteRow { + const notePrefix = "> " + if tc := c.noteCursor(screenRow, area.Min.X, lipgloss.Width(notePrefix)); tc != nil { + cur = tc + } + } + } + + // Scrollbar. + if overflow { + sb := common.Scrollbar(c.Styles, viewport, len(lines), viewport, c.scrollOffset) + if sb != "" { + x := area.Max.X - 1 + uv.NewStyledString(sb).Draw(scr, image.Rect(x, area.Min.Y, x+1, area.Min.Y+viewport)) + } + } + + // Build hit layers for choice rows. + c.buildChoiceCompositor(lines, area, contentWidth) + + return cur +} + +// buildChoiceCompositor creates hit layers for each visible choice +// row so that mouse clicks can select choices directly. Each choice +// gets a single layer spanning all its visible rows. +func (c *choiceList) buildChoiceCompositor(lines []contentLine, area uv.Rectangle, contentWidth int) { + // Collect the screen-row range for each choice index. + type rowRange struct{ min, max int } + ranges := make(map[int]*rowRange) + for screenRow := range area.Dy() { + idx := c.scrollOffset + screenRow + if idx >= len(lines) { + break + } + ln := lines[idx] + if ln.choiceIdx < 0 { + continue + } + r, ok := ranges[ln.choiceIdx] + if !ok { + r = &rowRange{min: screenRow, max: screenRow} + ranges[ln.choiceIdx] = r + } else { + if screenRow < r.min { + r.min = screenRow + } + if screenRow > r.max { + r.max = screenRow + } + } + } + + var layers []*lipgloss.Layer + for choiceIdx, r := range ranges { + height := r.max - r.min + 1 + hitStr := strings.Repeat(strings.Repeat(" ", contentWidth)+"\n", height-1) + strings.Repeat(" ", contentWidth) + y := area.Min.Y + r.min + layers = append(layers, lipgloss.NewLayer(hitStr).X(area.Min.X).Y(y).ID(fmt.Sprintf("choice_%d", choiceIdx))) + } + if len(layers) > 0 { + c.choiceCompositor = lipgloss.NewCompositor(layers...) + } else { + c.choiceCompositor = nil + } +} + +// clampScroll keeps the cursor item visible using a sliding +// window: the cursor moves freely within the visible region and +// only pushes the window when it reaches an edge. Going down pushes +// the bottom; going up pushes the top until the start (header and +// description) comes back into view. +func (c *choiceList) clampScroll(lines []contentLine, viewport int) { + if c.suppressScroll { + c.suppressScroll = false + return + } + limit := max(0, len(lines)-viewport) + if limit == 0 { + c.scrollOffset = 0 + return + } + + // Row range of the cursor item. + cursorTop, cursorBottom := -1, -1 + for i, ln := range lines { + if ln.cursorItem { + if cursorTop < 0 { + cursorTop = i + } + cursorBottom = i + } + } + if cursorTop < 0 { + c.scrollOffset = min(max(0, c.scrollOffset), limit) + return + } + + // Keep one line below the cursor visible (trailing pad on the + // last item, a separator otherwise) so the selection is never + // flush against the bottom edge. + below := min(cursorBottom+1, len(lines)-1) + + // On the first selectable item, prefer the top so the header + // and description (nothing selectable sits above them) come + // into view. + if c.cursorIdx == 0 { + c.scrollOffset = 0 + } + // Push the window down if the cursor's bottom fell below it. + if below >= c.scrollOffset+viewport { + c.scrollOffset = below - viewport + 1 + } + // Push the window up if the cursor's top rose above it. + if cursorTop < c.scrollOffset { + c.scrollOffset = cursorTop + } + + c.scrollOffset = min(max(0, c.scrollOffset), limit) +} + +// handleFillInFocused processes keys when the fill-in textarea is +// focused. onClose is called for the close key, onDone for the +// done key. Returns (done, cmd, handled). When handled is false +// the caller should process the key itself. +func (c *choiceList) handleFillInFocused( + msg tea.KeyPressMsg, + doneKey key.Binding, + onClose func() (bool, tea.Cmd), + onDone func() (bool, tea.Cmd), +) (bool, tea.Cmd, bool) { + if !c.isFillIn() || !c.fillIn.Focused() { + return false, nil, false + } + if key.Matches(msg, c.keyClose) { + done, cmd := onClose() + return done, cmd, true + } + if key.Matches(msg, doneKey) { + done, cmd := onDone() + return done, cmd, true + } + cmd, handled := c.handleFillInKey(msg) + return false, cmd, handled +} diff --git a/internal/ui/dialog/question_confirm.go b/internal/ui/dialog/question_confirm.go new file mode 100644 index 0000000000..e41fdb750c --- /dev/null +++ b/internal/ui/dialog/question_confirm.go @@ -0,0 +1,290 @@ +package dialog + +import ( + "fmt" + "image" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/question" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" +) + +// ConfirmComponent is the confirmation tab shown at the end of a +// multi-question batch. It displays an answer summary and lets the +// user confirm or go back to editing. Implements questionResponder +// so QuestionForm treats it like any other tab. +type ConfirmComponent struct { + Styles *styles.Styles + Title string + Description string + QuestionLabels []string + QuestionRequests []question.Question + Answers []*question.Answer + confirmYes bool + + keyLeft key.Binding + keyRight key.Binding + keyEnter key.Binding + keyClose key.Binding + + focused bool + lastWidth int + compositor *lipgloss.Compositor + hoverX int + hoverY int + + // OnConfirm is called when the user confirms. + OnConfirm func() + // OnReject is called when the user says "not yet". + OnReject func() +} + +// NewConfirmComponent creates a new confirmation component. +func NewConfirmComponent(sty *styles.Styles, title, description string, labels []string, requests []question.Question, answers []*question.Answer) *ConfirmComponent { + if title == "" || title == "Confirm" { + title = "Ready to go?" + } + return &ConfirmComponent{ + Styles: sty, + Title: title, + Description: description, + QuestionLabels: labels, + QuestionRequests: requests, + Answers: answers, + confirmYes: true, + keyLeft: key.NewBinding(key.WithKeys("left"), key.WithHelp("←/→", "switch")), + keyRight: key.NewBinding(key.WithKeys("right"), key.WithHelp("←/→", "switch")), + keyEnter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), + keyClose: CloseKey, + } +} + +// HandleKey processes input on the confirm tab. Returns true when +// the user has confirmed submission. Tab/shift+tab are NOT handled +// here; QuestionForm intercepts them for tab navigation. +func (c *ConfirmComponent) HandleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { + switch { + case key.Matches(msg, c.keyLeft), key.Matches(msg, c.keyRight): + c.confirmYes = !c.confirmYes + return false, nil + case key.Matches(msg, c.keyEnter): + if c.confirmYes { + if c.OnConfirm != nil { + c.OnConfirm() + } + return true, nil + } + if c.OnReject != nil { + c.OnReject() + } + return false, nil + case key.Matches(msg, c.keyClose): + if c.OnReject != nil { + c.OnReject() + } + return false, nil + } + return false, nil +} + +// Response returns an empty response. The confirm tab doesn't +// produce a question answer; it controls form submission. +func (c *ConfirmComponent) Response() question.Answer { + return question.Answer{} +} + +// ShortHelp returns key bindings for the status bar. +func (c *ConfirmComponent) ShortHelp() []key.Binding { + return []key.Binding{c.keyLeft, c.keyEnter, c.keyClose} +} + +// unansweredCount returns how many questions have no meaningful answer. +func (c *ConfirmComponent) unansweredCount() int { + n := 0 + for _, ans := range c.Answers { + if ans == nil || (len(ans.SelectedIDs) == 0 && ans.FillInText == "" && ans.Yes == nil) { + n++ + } + } + return n +} + +// Height returns the visual height of the confirm content. +func (c *ConfirmComponent) Height() int { + w := c.lastWidth + if w <= 0 { + w = choiceListMaxWidth + } + iconPrompt := questionIconPrompt(c.Styles, c.focused) + h := sectionHeight(c.Title, w-lipgloss.Width(iconPrompt)) // title + h++ // blank + if c.Description != "" { + r := common.MarkdownRenderer(c.Styles, w) + mu := common.LockMarkdownRenderer(r) + mu.Lock() + out, err := r.Render(c.Description) + mu.Unlock() + if err == nil { + out = strings.TrimSuffix(out, "\n") + h += strings.Count(out, "\n") + 1 + } else { + h += sectionHeight(c.Description, w) + } + h++ // blank + } + h += len(c.QuestionLabels) // one bullet per question + h++ // blank + if c.unansweredCount() > 0 { + h++ // warning line + h++ // blank after warning + } + h++ // buttons + h++ // bottom margin + return h +} + +// Draw renders the confirmation content. +func (c *ConfirmComponent) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + c.lastWidth = area.Dx() + y := area.Min.Y + + // Title with ? icon prompt, using confirm style. + iconPrompt := questionIconPrompt(c.Styles, c.focused) + qText := iconPrompt + c.Styles.Editor.QuestionConfirm.Render( + ansi.Wrap(c.Title, area.Dx()-lipgloss.Width(iconPrompt), ""), + ) + y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), qText) + y++ // blank + + // Description. + if c.Description != "" { + r := common.MarkdownRenderer(c.Styles, area.Dx()) + mu := common.LockMarkdownRenderer(r) + mu.Lock() + desc, err := r.Render(c.Description) + mu.Unlock() + if err == nil { + desc = strings.TrimSuffix(desc, "\n") + y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), desc) + } else { + y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), c.Description) + } + y++ // blank + } + + // Answer summary bullets in description/body style. + bulletStyle := c.Styles.Editor.QuestionBody + for i, label := range c.QuestionLabels { + summary := c.answerSummary(i) + bullet := bulletStyle.Render("• " + label + ": " + summary) + y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), bullet) + } + y++ // blank + + // Warning if some questions are unanswered. + if missed := c.unansweredCount(); missed > 0 { + warnStyle := c.Styles.Tool.WarnTag + msgStyle := c.Styles.Tool.WarnMessage + word := "question" + if missed > 1 { + word = "questions" + } + warn := warnStyle.Render("WARN") + " " + msgStyle.Render(fmt.Sprintf("%d %s unanswered", missed, word)) + y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), warn) + y++ // blank + } + + // Buttons. Build compositor first so hover uses current geometry. + confirmButtonOpts := []common.ButtonOpts{ + {Text: "Yup!", Selected: c.confirmYes, Padding: 3, UnderlineIndex: -1}, + {Text: "Not yet", Selected: !c.confirmYes, Padding: 3, UnderlineIndex: -1}, + } + c.compositor = common.ButtonHitCompositor(c.Styles, confirmButtonOpts, " ", area.Min.X, y) + hoveredBtn := common.HitButtonIndex(c.compositor, c.hoverX, c.hoverY) + confirmButtonOpts[0].Hovered = hoveredBtn == 0 + confirmButtonOpts[1].Hovered = hoveredBtn == 1 + buttons := common.ButtonGroup(c.Styles, confirmButtonOpts, " ") + drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), buttons) + + return nil +} + +// HeightChanged always returns false. +func (c *ConfirmComponent) HeightChanged() bool { return false } + +// SetFocused updates focus state. +func (c *ConfirmComponent) SetFocused(focused bool) { c.focused = focused } + +// SetHover updates the hover position for button highlighting. +func (c *ConfirmComponent) SetHover(x, y int) { c.hoverX = x; c.hoverY = y } + +// HandleMouseClick checks if the click landed on a button and +// triggers the corresponding action. Returns done=true for Yup!, +// done=false for Not yet (goes back to editing). +func (c *ConfirmComponent) HandleMouseClick(x, y int) (bool, bool) { + switch common.HitButtonIndex(c.compositor, x, y) { + case 0: // Yup! + c.confirmYes = true + if c.OnConfirm != nil { + c.OnConfirm() + } + return true, true + case 1: // Not yet + c.confirmYes = false + if c.OnReject != nil { + c.OnReject() + } + return false, true + } + return false, false +} + +// UpdateAnswers replaces the answer slice. Called by QuestionForm +// when tabbing away from a question so the summary stays current. +func (c *ConfirmComponent) UpdateAnswers(answers []*question.Answer) { + c.Answers = answers +} + +// answerSummary returns a human-readable summary of an answer. +// Choice IDs are resolved to display labels when possible. +func (c *ConfirmComponent) answerSummary(idx int) string { + if idx >= len(c.Answers) || c.Answers[idx] == nil { + return "(not answered)" + } + resp := c.Answers[idx] + if resp.FillInText != "" { + return resp.FillInText + } + if resp.Yes != nil { + if *resp.Yes { + return "Yes" + } + return "No" + } + if len(resp.SelectedIDs) > 0 { + labels := make([]string, 0, len(resp.SelectedIDs)) + for _, id := range resp.SelectedIDs { + labels = append(labels, c.choiceLabel(idx, id)) + } + return strings.Join(labels, ", ") + } + return "(not answered)" +} + +// choiceLabel resolves a choice ID to its display label. +func (c *ConfirmComponent) choiceLabel(qIdx int, choiceID string) string { + if qIdx < len(c.QuestionRequests) { + for _, ch := range c.QuestionRequests[qIdx].Choices { + if ch.ID == choiceID { + return ch.Label + } + } + } + return choiceID +} diff --git a/internal/ui/dialog/question_editor.go b/internal/ui/dialog/question_editor.go new file mode 100644 index 0000000000..927dfb7a6c --- /dev/null +++ b/internal/ui/dialog/question_editor.go @@ -0,0 +1,272 @@ +package dialog + +import ( + "image" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" +) + +// newQuestionTextarea creates a configured textarea for question +// input. All question textareas share the same base configuration; +// only placeholder and char limit vary. +func newQuestionTextarea(sty *styles.Styles, placeholder string, charLimit int) textarea.Model { + ta := textarea.New() + taStyles := sty.Editor.Textarea + taStyles.Cursor.Color = sty.Editor.PromptYoloDotsFocused.GetForeground() + ta.SetStyles(taStyles) + ta.Placeholder = placeholder + ta.ShowLineNumbers = false + ta.CharLimit = charLimit + ta.MaxWidth = choiceListMaxWidth + ta.SetVirtualCursor(false) + ta.DynamicHeight = true + ta.MinHeight = 1 + ta.MaxHeight = 3 + ta.SetHeight(1) + ta.SetPromptFunc(0, func(textarea.PromptInfo) string { return "" }) + ta.KeyMap.InsertNewline = key.NewBinding(key.WithDisabled()) + ta.Blur() + return ta +} + +// questionEditor owns the fill-in textarea, note editor, and notes +// map shared across all question component types. Components embed +// this struct and call its methods instead of reimplementing editor +// logic. +type questionEditor struct { + Styles *styles.Styles + + fillIn textarea.Model + noteEditor textarea.Model + activeNoteKey string // non-empty when a note editor is open + notes map[string]string + + keyNote key.Binding + navUp key.Binding + navDown key.Binding +} + +// newQuestionEditor creates a questionEditor with configured +// fill-in and note textareas. +func newQuestionEditor(sty *styles.Styles) questionEditor { + return questionEditor{ + Styles: sty, + fillIn: newQuestionTextarea(sty, "Something else?", 500), + noteEditor: newQuestionTextarea(sty, "Add a note...", 300), + notes: make(map[string]string), + keyNote: key.NewBinding(key.WithKeys("alt+n"), key.WithHelp("alt+n", "note")), + navUp: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")), + navDown: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")), + } +} + +// openNote opens the note editor for the given key, pre-populating +// it with any existing note text. +func (e *questionEditor) openNote(noteKey string) tea.Cmd { + e.activeNoteKey = noteKey + if existing, ok := e.notes[noteKey]; ok { + e.noteEditor.SetValue(existing) + } else { + e.noteEditor.Reset() + } + return e.noteEditor.Focus() +} + +// closeNote saves the current note text and closes the editor. +func (e *questionEditor) closeNote(noteKey string) { + e.activeNoteKey = "" + val := strings.TrimSpace(e.noteEditor.Value()) + if val != "" { + e.notes[noteKey] = val + } else { + delete(e.notes, noteKey) + } + e.noteEditor.Blur() +} + +// handleNoteKey processes keys when the note editor is focused. +// Returns (cmd, handled). When handled is true the caller should +// not process the key further. onClose is called for the close +// key so the caller can control what happens after closing. +func (e *questionEditor) handleNoteKey(msg tea.KeyPressMsg, closeKey key.Binding, onClose func()) (tea.Cmd, bool) { + switch { + case key.Matches(msg, closeKey): + onClose() + return nil, true + case key.Matches(msg, e.navUp), key.Matches(msg, e.navDown): + onClose() + return nil, false + default: + if key.Matches(msg, key.NewBinding(key.WithKeys("enter"))) { + onClose() + return nil, true + } + var cmd tea.Cmd + e.noteEditor, cmd = e.noteEditor.Update(msg) + return cmd, true + } +} + +// handlePaste forwards a paste message to the currently focused +// textarea (note editor or fill-in). Returns nil if no textarea +// is focused. +func (e *questionEditor) handlePaste(msg tea.PasteMsg) tea.Cmd { + if e.activeNoteKey != "" && e.noteEditor.Focused() { + var cmd tea.Cmd + e.noteEditor, cmd = e.noteEditor.Update(msg) + return cmd + } + if e.fillIn.Focused() { + var cmd tea.Cmd + e.fillIn, cmd = e.fillIn.Update(msg) + return cmd + } + return nil +} + +// drawFillIn appends fill-in rows to lines. When focused, renders +// the live textarea; otherwise shows saved text or placeholder. +// styleFilled controls whether non-empty fill-in text gets the +// selected (pink) style. Pass true for single-choice where the +// fill-in IS the answer; false for multi-choice where it's supplementary. +func (e *questionEditor) drawFillIn(lines *[]contentLine, innerWidth int, bar, barInactive, fillPrefix string, isActive bool, styleFilled bool) { + bodyStyle := e.Styles.Editor.QuestionBody + prefixWidth := lipgloss.Width(fillPrefix) + + if isActive && e.fillIn.Focused() { + e.fillIn.SetWidth(innerWidth - 2 - prefixWidth) + indent := strings.Repeat(" ", prefixWidth) + for j, tl := range strings.Split(e.fillIn.View(), "\n") { + text := bar + fillPrefix + tl + if j > 0 { + text = barInactive + indent + tl + } + *lines = append(*lines, contentLine{text: text, fillInRow: j == 0, cursorItem: true, choiceIdx: -1}) + } + return + } + + val := strings.TrimSpace(e.fillIn.Value()) + if val != "" { + rendered := e.Styles.Editor.QuestionUnselected.Render(val) + if styleFilled { + rendered = e.Styles.Editor.QuestionSelected.Render(val) + } + *lines = append(*lines, contentLine{text: bar + fillPrefix + rendered, cursorItem: isActive, choiceIdx: -1}) + return + } + *lines = append(*lines, contentLine{text: bar + fillPrefix + bodyStyle.Render("Something else?"), cursorItem: isActive, choiceIdx: -1}) +} + +// drawNote appends note rows to lines for the given key. When the +// note editor is active, renders the live textarea; otherwise shows +// saved note text or nothing. +func (e *questionEditor) drawNote(lines *[]contentLine, innerWidth int, bar, barInactive, noteKey string, isActive bool) { + noteStyle := e.Styles.Editor.QuestionNote + isEditing := e.activeNoteKey == noteKey && e.noteEditor.Focused() + const notePrefix = "> " + + if isEditing && e.noteEditor.Focused() { + prefixWidth := lipgloss.Width(notePrefix) + e.noteEditor.SetWidth(innerWidth - 2 - prefixWidth) + indent := strings.Repeat(" ", prefixWidth) + for j, tl := range strings.Split(e.noteEditor.View(), "\n") { + text := bar + notePrefix + tl + if j > 0 { + text = barInactive + indent + tl + } + *lines = append(*lines, contentLine{text: text, noteRow: j == 0, cursorItem: true, choiceIdx: -1}) + } + return + } + + if saved, ok := e.notes[noteKey]; ok && saved != "" { + dimmed := noteStyle.Render(saved) + for _, ln := range strings.Split(dimmed, "\n") { + *lines = append(*lines, contentLine{text: bar + notePrefix + ln, cursorItem: isActive, choiceIdx: -1}) + } + } +} + +// fillInCursor returns the hardware cursor position for the fill-in +// textarea when it's focused. areaMinX is the left edge of the +// content area; prefixWidth is the visual width of the "> " prompt. +func (e *questionEditor) fillInCursor(screenRow, areaMinX, prefixWidth int) *tea.Cursor { + if !e.fillIn.Focused() { + return nil + } + tc := e.fillIn.Cursor() + if tc == nil { + return nil + } + tc.X += areaMinX + 1 + prefixWidth + tc.Y += screenRow + return tc +} + +// noteCursor returns the hardware cursor position for the note +// editor when it's focused. +func (e *questionEditor) noteCursor(screenRow, areaMinX, prefixWidth int) *tea.Cursor { + if !e.noteEditor.Focused() { + return nil + } + tc := e.noteEditor.Cursor() + if tc == nil { + return nil + } + tc.X += areaMinX + 1 + prefixWidth + tc.Y += screenRow + return tc +} + +// hasNote reports whether a note exists for the given key. +func (e *questionEditor) hasNote(key string) bool { + _, ok := e.notes[key] + return ok +} + +// drawStandaloneNote draws a note editor or saved note directly +// onto the screen (not via line list). Used by YesNo which doesn't +// use the line-list model. Returns the cursor or nil. +func (e *questionEditor) drawStandaloneNote(scr uv.Screen, area uv.Rectangle, y int, noteKey string) (*tea.Cursor, int) { + const notePrefix = "> " + + if e.activeNoteKey != "" && e.noteEditor.Focused() { + y++ + prefixWidth := lipgloss.Width(notePrefix) + e.noteEditor.SetWidth(area.Dx() - 2 - prefixWidth) + noteView := e.noteEditor.View() + var cur *tea.Cursor + for j, ln := range strings.Split(noteView, "\n") { + text := notePrefix + ln + if j > 0 { + text = strings.Repeat(" ", prefixWidth) + ln + } + lines := drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, y+1), text) + if j == 0 { + if tc := e.noteEditor.Cursor(); tc != nil { + tc.X += prefixWidth + tc.Y += y - area.Min.Y + cur = tc + } + } + y += lines + } + return cur, y + } + + if saved, ok := e.notes[noteKey]; ok && saved != "" { + y++ + noteStyle := e.Styles.Editor.QuestionNote + drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, y+1), notePrefix+noteStyle.Render(saved)) + y++ + } + + return nil, y +} diff --git a/internal/ui/dialog/question_form.go b/internal/ui/dialog/question_form.go new file mode 100644 index 0000000000..07d89e9abf --- /dev/null +++ b/internal/ui/dialog/question_form.go @@ -0,0 +1,749 @@ +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" + "github.com/charmbracelet/x/ansi" +) + +// questionResponder extends InlineEditor with access to the last +// response. Used internally by QuestionForm to collect answers +// from child components. +type questionResponder interface { + InlineEditor + Response() question.Answer + SetHover(x, y int) + HandleMouseClick(x, y int) (done bool, handled bool) +} + +// QuestionForm presents multiple questions as a tabbed form. +// Tab/shift+tab switches between questions; each question keeps +// its own internal keybindings. For multi-question batches, a +// Confirm tab is appended automatically. +type QuestionForm struct { + Styles *styles.Styles + BatchID string + questions []questionResponder // includes ConfirmComponent as last item for batches + labels []string // includes "Confirm" for batches + requestIDs []string + answers []*question.Answer // nil until answered; only covers real questions + activeIdx int + focused bool + hasConfirm bool // whether a confirm tab exists + showTabs bool // whether to render tab chrome + numQuestions int // real question count (excludes confirm tab) + confirmComp *ConfirmComponent // nil when no confirm tab + + keyPrevTab key.Binding + keyNextTab key.Binding + keyClose key.Binding + + // Compositor for tab hit detection. Built during Draw() from + // tab layers positioned at their screen coordinates. + compositor *lipgloss.Compositor + + // Hover position for highlighting interactive elements. + hoverX, hoverY int + + // OnAnswer is called when the form is submitted. The UI sets + // this to wire up workspace submission. + OnAnswer func(responses []question.Answer) +} + +// NewQuestionForm creates a tabbed multi-question form from a +// batch request. Each question is wrapped in its existing +// component type (YesNo, SingleChoice, MultiChoice, FreeText). +// A Confirm tab is appended for multi-question batches. +func NewQuestionForm(sty *styles.Styles, batch question.Request) *QuestionForm { + comps := make([]questionResponder, len(batch.Questions)) + labels := make([]string, len(batch.Questions)) + ids := make([]string, len(batch.Questions)) + for i, req := range batch.Questions { + switch req.Type { + case question.TypeYesNo: + comps[i] = NewYesNo(sty, req) + case question.TypeSingleChoice: + comps[i] = NewSingleChoice(sty, req) + case question.TypeMultiChoice: + comps[i] = NewMultiChoice(sty, req) + case question.TypeFreeText: + comps[i] = NewFreeText(sty, req) + } + if req.Label != "" { + labels[i] = req.Label + } else { + labels[i] = shortLabel(req.Text) + } + ids[i] = req.ID + } + + numQuestions := len(comps) + // Confirm tab only for multi-question batches. + hasConfirm := numQuestions > 1 + answers := make([]*question.Answer, numQuestions) + + var confirmComp *ConfirmComponent + allLabels := labels + if hasConfirm { + confirmTitle := batch.ConfirmTitle + if confirmTitle == "" { + confirmTitle = "Confirm" + } + confirmComp = NewConfirmComponent( + sty, + confirmTitle, + batch.ConfirmDescription, + labels, + batch.Questions, + answers, + ) + allLabels = make([]string, len(labels)+1) + copy(allLabels, labels) + allLabels[len(labels)] = "Confirm" + } + showTabs := numQuestions > 1 + + f := &QuestionForm{ + Styles: sty, + BatchID: batch.ID, + questions: comps, + labels: allLabels, + requestIDs: ids, + answers: answers, + hasConfirm: hasConfirm, + showTabs: showTabs, + numQuestions: numQuestions, + confirmComp: confirmComp, + keyPrevTab: key.NewBinding( + key.WithKeys("ctrl+left"), + key.WithHelp("ctrl+←", "prev tab"), + ), + keyNextTab: key.NewBinding( + key.WithKeys("ctrl+right"), + key.WithHelp("ctrl+→", "next tab"), + ), + keyClose: CloseKey, + } + + // Wire confirm callbacks. + if confirmComp != nil { + confirmComp.OnConfirm = f.submit + confirmComp.OnReject = func() { + if idx := f.firstUnanswered(); idx >= 0 { + f.switchTab(idx) + } else if numQuestions > 0 { + f.switchTab(numQuestions - 1) + } + } + } + + if len(comps) > 0 { + comps[0].SetFocused(true) + } + return f +} + +// shortLabel truncates a question to at most three words for use +// as a tab header. +func shortLabel(q string) string { + q = strings.ReplaceAll(q, "\n", " ") + words := strings.Fields(q) + if len(words) > 3 { + words = words[:3] + } + return strings.Join(words, " ") +} + +// isConfirmTab reports whether the active tab is the confirm tab. +func (f *QuestionForm) isConfirmTab() bool { + return f.hasConfirm && f.activeIdx == f.numQuestions +} + +// isAnswered reports whether a question has a meaningful answer. +func (f *QuestionForm) isAnswered(idx int) bool { + if idx >= len(f.answers) || f.answers[idx] == nil { + return false + } + resp := f.answers[idx] + return len(resp.SelectedIDs) > 0 || resp.FillInText != "" || resp.Yes != nil +} + +// firstUnanswered returns the index of the first unanswered +// question, or -1 if all are answered. +func (f *QuestionForm) firstUnanswered() int { + for i, ans := range f.answers { + if ans == nil { + return i + } + if len(ans.SelectedIDs) == 0 && ans.FillInText == "" && ans.Yes == nil { + return i + } + } + return -1 +} + +// HandleKey routes keys to the active tab. Returns true when the +// entire batch is submitted. +func (f *QuestionForm) HandleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { + // Tab navigation works on all tabs including confirm. + switch { + case key.Matches(msg, f.keyNextTab): + f.switchTab(f.activeIdx + 1) + return false, nil + case key.Matches(msg, f.keyPrevTab): + f.switchTab(f.activeIdx - 1) + return false, nil + } + + // Confirm tab delegates to ConfirmComponent. + if f.isConfirmTab() { + done, cmd := f.confirmComp.HandleKey(msg) + if done { + return true, cmd + } + return false, cmd + } + + // Global keys for question tabs. + if key.Matches(msg, f.keyClose) { + f.submit() + return true, nil + } + + // Route to active question. + if f.activeIdx < f.numQuestions { + done, cmd := f.questions[f.activeIdx].HandleKey(msg) + if done { + resp := f.questions[f.activeIdx].Response() + f.answers[f.activeIdx] = &resp + f.syncConfirmAnswers() + if f.activeIdx < len(f.labels)-1 { + f.switchTab(f.activeIdx + 1) + } else if !f.hasConfirm { + f.submit() + return true, cmd + } + return false, cmd + } + return false, cmd + } + return false, nil +} + +// switchTab moves focus to the given tab index, wrapping around. +// Snapshots the current question's response before leaving. +func (f *QuestionForm) switchTab(idx int) { + totalTabs := len(f.labels) + if totalTabs == 0 { + return + } + // Snapshot and unfocus current. + if !f.isConfirmTab() && f.activeIdx < f.numQuestions { + resp := f.questions[f.activeIdx].Response() + f.answers[f.activeIdx] = &resp + f.questions[f.activeIdx].SetFocused(false) + } else if f.isConfirmTab() { + f.confirmComp.SetFocused(false) + } + // Wrap. + if idx < 0 { + idx = totalTabs - 1 + } else if idx >= totalTabs { + idx = 0 + } + f.activeIdx = idx + // Focus new. + if f.isConfirmTab() { + f.syncConfirmAnswers() + f.confirmComp.SetFocused(f.focused) + } else if f.activeIdx < f.numQuestions { + f.questions[f.activeIdx].SetFocused(f.focused) + } +} + +// syncConfirmAnswers pushes the latest answers to the confirm +// component so its summary stays current. +func (f *QuestionForm) syncConfirmAnswers() { + if f.confirmComp != nil { + f.confirmComp.UpdateAnswers(f.answers) + } +} + +// submit collects stored responses and calls OnAnswer. +func (f *QuestionForm) submit() { + responses := make([]question.Answer, f.numQuestions) + for i, ans := range f.answers { + if ans != nil { + responses[i] = *ans + } else { + responses[i] = question.Answer{ + QuestionID: f.requestIDs[i], + } + } + } + if f.OnAnswer != nil { + f.OnAnswer(responses) + } +} + +// ShortHelp returns key bindings for the status bar. +func (f *QuestionForm) ShortHelp() []key.Binding { + if f.isConfirmTab() { + return f.confirmComp.ShortHelp() + } + bindings := []key.Binding{f.keyPrevTab, f.keyNextTab} + if f.activeIdx < f.numQuestions { + bindings = append(bindings, f.questions[f.activeIdx].ShortHelp()...) + } + return bindings +} + +// Height returns the total height using the max tab height so +// switching tabs doesn't cause layout jumps. +func (f *QuestionForm) Height() int { + h := 0 + if f.showTabs { + h = 4 // bordered tab row (top + label + bottom) + blank line + } + maxQ := 0 + for _, q := range f.questions { + if qh := q.Height(); qh > maxQ { + maxQ = qh + } + } + if f.confirmComp != nil { + if ch := f.confirmComp.Height(); ch > maxQ { + maxQ = ch + } + } + h += maxQ + return h +} + +// CollapsedHeight returns the height of the collapsed summary +// line shown when the editor area is not focused. +func (f *QuestionForm) CollapsedHeight() int { return 1 } + +// DrawCollapsed renders a compact one-line summary of the form +// when the user has tabbed away to the chat. For multi-question +// batches it shows the active question text and answered count; +// for single questions it shows just the question text. +func (f *QuestionForm) DrawCollapsed(scr uv.Screen, area uv.Rectangle) { + icon := f.Styles.Editor.PromptQuestionIconBlurred.Render() + iconWidth := lipgloss.Width(icon) + textStyle := f.Styles.Messages.AssistantInfoModel + countStyle := f.Styles.Messages.AssistantInfoProvider + lineStyle := f.Styles.Section.Line + + var plainText string + var confirmRendered string + if f.numQuestions > 1 { + answered := 0 + for i := 0; i < f.numQuestions; i++ { + if f.isAnswered(i) { + answered++ + } + } + if f.isConfirmTab() && f.confirmComp != nil { + plainText = f.confirmComp.Title + confirmRendered = f.Styles.Editor.QuestionConfirm.Render(f.confirmComp.Title) + } else if f.activeIdx < len(f.questions) { + plainText = f.getQuestionText(f.activeIdx) + } + count := fmt.Sprintf("(%d/%d answered)", answered, f.numQuestions) + plainLabel := plainText + " " + count + textWidth := iconWidth + 1 + lipgloss.Width(plainLabel) + remaining := area.Dx() - textWidth - 1 + + var rendered string + if confirmRendered != "" { + rendered = fmt.Sprintf("%s%s %s", icon, confirmRendered, countStyle.Render(count)) + } else { + rendered = fmt.Sprintf("%s%s %s", icon, textStyle.Render(plainText), countStyle.Render(count)) + } + if remaining > 0 { + rendered = rendered + " " + lineStyle.Render(strings.Repeat(styles.SectionSeparator, remaining)) + } + drawStyledText(scr, area, rendered) + } else if f.numQuestions == 1 { + plainText = f.getQuestionText(0) + textWidth := iconWidth + 1 + lipgloss.Width(plainText) + remaining := area.Dx() - textWidth - 1 + rendered := fmt.Sprintf("%s%s", icon, textStyle.Render(plainText)) + if remaining > 0 { + rendered = rendered + " " + lineStyle.Render(strings.Repeat(styles.SectionSeparator, remaining)) + } + drawStyledText(scr, area, rendered) + } +} + +// getQuestionText returns the question text for the given index. +func (f *QuestionForm) getQuestionText(idx int) string { + type hasRequest interface { + GetRequest() question.Question + } + if idx < len(f.questions) { + if hr, ok := f.questions[idx].(hasRequest); ok { + return hr.GetRequest().Text + } + } + if idx < len(f.labels) { + return f.labels[idx] + } + return "" +} + +// Draw renders the tab bar and the active tab content. When +// showTabs is false (single question), renders content directly +// without tab chrome. +func (f *QuestionForm) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + contentY := area.Min.Y + + if f.showTabs { + const tabPadX = 1 + tabHeight := 3 + + // Compute display labels. + labels := make([]string, len(f.labels)) + copy(labels, f.labels) + + // Truncate if tabs exceed width. Distribute the available + // space fairly: short labels keep their natural width and + // the deficit is shared proportionally among longer ones, + // with remainder cells distributed left-to-right so the + // layout resizes smoothly pixel-by-pixel. + tabWidths := make([]int, len(labels)) + naturalWidths := make([]int, len(labels)) + totalWidth := 0 + for i, l := range labels { + w := ansi.StringWidth(l) + tabPadX*2 + 2 + tabWidths[i] = w + naturalWidths[i] = w + totalWidth += w + } + avail := area.Dx() + if totalWidth > avail && len(labels) > 0 { + const minLabelW = 1 + minTabW := minLabelW + tabPadX*2 + 2 + n := len(labels) + + // Check if there's enough room to show all tabs with + // at least a useful label. If each tab can't fit at + // least 5 cells of label, switch to single-tab mode + // with a "N of M" counter. + usefulMinTabW := 5 + tabPadX*2 + 2 + if avail/n < usefulMinTabW { + // Single-tab mode: show only the active tab + // label plus a counter. + counter := fmt.Sprintf("%d/%d", f.activeIdx+1, n) + activeLabel := labels[f.activeIdx] + combined := activeLabel + " · " + counter + maxLabel := avail - tabPadX*2 - 2 + if maxLabel < 3 { + maxLabel = 3 + } + if ansi.StringWidth(combined) > maxLabel { + // Truncate the label part to fit. + counterPart := " · " + counter + labelBudget := maxLabel - ansi.StringWidth(counterPart) + if labelBudget < 1 { + labelBudget = 1 + } + combined = ansi.Truncate(activeLabel, labelBudget, "…") + counterPart + } + for i := range labels { + if i == f.activeIdx { + labels[i] = combined + } else { + labels[i] = "" + } + } + // Recalculate widths for single visible tab. + totalWidth = 0 + for i := range labels { + if labels[i] == "" { + tabWidths[i] = 0 + } else { + w := ansi.StringWidth(labels[i]) + tabPadX*2 + 2 + tabWidths[i] = w + totalWidth += w + } + } + } else { + // Normal truncation: distribute space fairly. + capped := make([]bool, n) + for { + freeCount := 0 + freeTotal := 0 + for i := range n { + if capped[i] { + continue + } + freeCount++ + freeTotal += naturalWidths[i] + } + if freeCount == 0 { + break + } + budget := avail + for i := range n { + if capped[i] { + budget -= tabWidths[i] + } + } + share := budget / freeCount + changed := false + for i := range n { + if !capped[i] && naturalWidths[i] <= share { + capped[i] = true + tabWidths[i] = naturalWidths[i] + changed = true + } + } + if !changed { + for i := range n { + if !capped[i] { + tabWidths[i] = max(share, minTabW) + } + } + remainder := budget - share*freeCount + for i := range n { + if remainder <= 0 { + break + } + if !capped[i] && tabWidths[i] < naturalWidths[i] { + tabWidths[i]++ + remainder-- + } + } + break + } + } + + // Apply truncation based on final widths. + for i, l := range labels { + labelAvail := max(tabWidths[i]-tabPadX*2-2, minLabelW) + if ansi.StringWidth(l) > labelAvail { + labels[i] = ansi.Truncate(l, labelAvail, "…") + } + } + } + } + + // 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 := range labels { + tw := tabWidths[i] + if f.hoverX >= tx && f.hoverX < tx+tw { + hoveredTab = i + break + } + tx += tw + } + } + + firstVisible := -1 + for i := range labels { + if tabWidths[i] > 0 { + firstVisible = i + break + } + } + + for i, label := range labels { + // Skip hidden tabs (single-tab mode). + if tabWidths[i] == 0 { + continue + } + isActive := i == f.activeIdx + isHovered := i == hoveredTab && !isActive + labelWidth := ansi.StringWidth(label) + tabWidth := tabWidths[i] + + tabArea := image.Rect(x, area.Min.Y, x+tabWidth, area.Min.Y+tabHeight) + + border := f.Styles.Tab.InactiveBorder + textStyle := f.Styles.Tab.InactiveStyle + if !f.focused { + border = f.Styles.Tab.InactiveBorderBlurred + } + if isActive { + border = f.Styles.Tab.ActiveBorder + textStyle = f.Styles.Tab.ActiveStyle + if !f.focused { + border = f.Styles.Tab.ActiveBorderBlurred + } + } else if i < f.numQuestions && f.isAnswered(i) { + textStyle = f.Styles.Tab.ActiveStyle + } + if isHovered { + hovered := textStyle + hovered.Attrs |= uv.AttrBold + textStyle = hovered + } + + if i == firstVisible { + if isActive { + border.BottomLeft = uv.Side{Content: "┘", Style: border.BottomLeft.Style} + } else { + border.BottomLeft = uv.Side{Content: "┴", Style: border.BottomLeft.Style} + } + } + + border.Draw(scr, tabArea) + + innerWidth := tabWidth - 2 + xOff := (innerWidth - labelWidth) / 2 + innerArea := image.Rect( + tabArea.Min.X+1+xOff, tabArea.Min.Y+1, + tabArea.Max.X-1, tabArea.Max.Y-1, + ) + uv.NewStyledString(textStyle.Styled(label)).Draw(scr, innerArea) + + // Create an invisible hit layer for this tab. + hitStr := strings.Repeat(strings.Repeat(" ", tabWidth)+"\n", tabHeight-1) + strings.Repeat(" ", tabWidth) + layers = append(layers, lipgloss.NewLayer(hitStr).X(x).Y(area.Min.Y).ID(fmt.Sprintf("tab_%d", i))) + + x += tabWidth + } + + f.compositor = lipgloss.NewCompositor(layers...) + + lineY := area.Min.Y + tabHeight - 1 + lineSide := f.Styles.Tab.InactiveBorder.Bottom + if !f.focused { + lineSide = f.Styles.Tab.InactiveBorderBlurred.Bottom + } + for lx := x; lx < area.Max.X; lx++ { + c := uv.NewCell(scr.WidthMethod(), lineSide.Content) + if c != nil { + c.Style = lineSide.Style + } + scr.SetCell(lx, lineY, c) + } + + contentY = area.Min.Y + tabHeight + 1 + } else { + f.compositor = nil + } + + contentArea := image.Rect(area.Min.X, contentY, area.Max.X, area.Max.Y) + + if f.isConfirmTab() { + return f.confirmComp.Draw(scr, contentArea) + } + if f.activeIdx < f.numQuestions { + cur := f.questions[f.activeIdx].Draw(scr, contentArea) + if cur != nil { + cur.Y += contentY - area.Min.Y + } + return cur + } + return nil +} + +// HeightChanged reports whether any component's height changed. +func (f *QuestionForm) HeightChanged() bool { + for _, q := range f.questions { + if q.HeightChanged() { + return true + } + } + if f.confirmComp != nil && f.confirmComp.HeightChanged() { + return true + } + return false +} + +// SetFocused updates focus state for the active tab. +func (f *QuestionForm) SetFocused(focused bool) { + f.focused = focused + if f.isConfirmTab() { + f.confirmComp.SetFocused(focused) + } else if f.activeIdx < f.numQuestions { + f.questions[f.activeIdx].SetFocused(focused) + } +} + +// SetHover implements MouseClickableEditor. Stores the hover +// position and propagates it to the active component. +func (f *QuestionForm) SetHover(x, y int) { + f.hoverX = x + f.hoverY = y + if f.isConfirmTab() && f.confirmComp != nil { + f.confirmComp.SetHover(x, y) + } else if f.activeIdx < len(f.questions) { + f.questions[f.activeIdx].SetHover(x, y) + } +} + +// HandlePaste implements PasteableEditor. Forwards paste events +// to the active question component if it supports pasting. +func (f *QuestionForm) HandlePaste(msg tea.PasteMsg) tea.Cmd { + if f.isConfirmTab() { + return nil + } + if f.activeIdx < f.numQuestions { + if p, ok := f.questions[f.activeIdx].(PasteableEditor); ok { + return p.HandlePaste(msg) + } + } + return nil +} + +// HandleMouseClick implements MouseClickableEditor. It checks if +// the click landed on a tab and switches to it, or delegates to +// the active component for content-area clicks. +func (f *QuestionForm) HandleMouseClick(x, y int) (bool, bool) { + // Check tabs first. + if f.showTabs && f.compositor != nil { + hit := f.compositor.Hit(x, y) + if !hit.Empty() { + var idx int + if _, err := fmt.Sscanf(hit.ID(), "tab_%d", &idx); err == nil { + if idx >= 0 && idx < len(f.labels) && idx != f.activeIdx { + f.switchTab(idx) + } + return false, true + } + } + } + + // Delegate to active component. + if f.isConfirmTab() && f.confirmComp != nil { + return f.confirmComp.HandleMouseClick(x, y) + } + if f.activeIdx < len(f.questions) { + done, handled := f.questions[f.activeIdx].HandleMouseClick(x, y) + if handled { + resp := f.questions[f.activeIdx].Response() + f.answers[f.activeIdx] = &resp + f.syncConfirmAnswers() + if done { + if f.activeIdx < len(f.labels)-1 { + f.switchTab(f.activeIdx + 1) + return false, true + } else if !f.hasConfirm { + f.submit() + return true, true + } + } + return false, true + } + } + return false, false +} diff --git a/internal/ui/dialog/question_freetext.go b/internal/ui/dialog/question_freetext.go new file mode 100644 index 0000000000..0807225e8a --- /dev/null +++ b/internal/ui/dialog/question_freetext.go @@ -0,0 +1,201 @@ +package dialog + +import ( + "image" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/question" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" +) + +// FreeText is an open-ended text input component for questions +// that need a narrative answer rather than a selection. +type FreeText struct { + Styles *styles.Styles + Request question.Question + focused bool + + editor textarea.Model + keyEnter key.Binding + keyClose key.Binding + + lastResponse question.Answer + lastWidth int +} + +// NewFreeText creates a new free-text question component. +func NewFreeText(sty *styles.Styles, req question.Question) *FreeText { + ta := newQuestionTextarea(sty, "Type your answer...", 1000) + ta.MinHeight = 3 + ta.MaxHeight = 8 + ta.SetHeight(3) + + return &FreeText{ + Styles: sty, + Request: req, + editor: ta, + keyEnter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), + keyClose: CloseKey, + } +} + +// HandleKey processes a key press. Returns true when the user has +// submitted or dismissed the question. +func (d *FreeText) HandleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { + switch { + case key.Matches(msg, d.keyClose): + d.answer(question.Answer{QuestionID: d.Request.ID}) + return true, nil + case key.Matches(msg, d.keyEnter): + val := strings.TrimSpace(d.editor.Value()) + if val != "" { + d.answer(question.Answer{ + QuestionID: d.Request.ID, + FillInText: val, + }) + return true, nil + } + return false, nil + default: + var cmd tea.Cmd + d.editor, cmd = d.editor.Update(msg) + return false, cmd + } +} + +func (d *FreeText) answer(resp question.Answer) { + d.lastResponse = resp +} + +// Response returns the current answer, including any unsaved +// editor content so that tabbing away preserves typed text. +func (d *FreeText) Response() question.Answer { + if val := strings.TrimSpace(d.editor.Value()); val != "" { + return question.Answer{QuestionID: d.Request.ID, FillInText: val} + } + return d.lastResponse +} + +// GetRequest returns the underlying question request. +func (d *FreeText) GetRequest() question.Question { return d.Request } + +// ShortHelp returns key bindings for the status bar. +func (d *FreeText) ShortHelp() []key.Binding { + return []key.Binding{d.keyEnter, d.keyClose} +} + +// Height returns the visual height at the default max width. +func (d *FreeText) Height() int { + w := d.lastWidth + if w <= 0 { + w = choiceListMaxWidth + } + iconPrompt := questionIconPrompt(d.Styles, d.focused) + h := sectionHeight(d.Request.Text, w-lipgloss.Width(iconPrompt)) // question + h++ // blank + if d.Request.Description != "" { + r := common.MarkdownRenderer(d.Styles, w) + mu := common.LockMarkdownRenderer(r) + mu.Lock() + out, err := r.Render(d.Request.Description) + mu.Unlock() + if err == nil { + out = strings.TrimSuffix(out, "\n") + h += strings.Count(out, "\n") + 1 + } else { + h += sectionHeight(d.Request.Description, w) + } + h++ // blank + } + h += d.editor.Height() // textarea + h++ // trailing blank for bottom padding + return h +} + +// Draw renders the free-text question directly to screen. +// Returns the cursor position, or nil. +func (d *FreeText) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + d.lastWidth = area.Dx() + y := area.Min.Y + + // Draw question header. + iconPrompt := questionIconPrompt(d.Styles, d.focused) + qText := iconPrompt + d.Styles.Editor.QuestionUnselected.Render( + ansi.Wrap(d.Request.Text, area.Dx()-lipgloss.Width(iconPrompt), ""), + ) + y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), qText) + y++ // blank + + // Draw optional description. + if d.Request.Description != "" { + r := common.MarkdownRenderer(d.Styles, area.Dx()) + mu := common.LockMarkdownRenderer(r) + mu.Lock() + desc, err := r.Render(d.Request.Description) + mu.Unlock() + if err == nil { + desc = strings.TrimSuffix(desc, "\n") + y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), desc) + } else { + y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), d.Request.Description) + } + y++ // blank + } + + // Draw textarea with > prompt prefix. + promptPrefix := d.Styles.Editor.QuestionBody.Render("> ") + prefixWidth := lipgloss.Width(promptPrefix) + d.editor.SetWidth(min(area.Dx()-2-prefixWidth, choiceListMaxWidth)) + view := d.editor.View() + var cur *tea.Cursor + for j, ln := range strings.Split(view, "\n") { + text := promptPrefix + ln + if j > 0 { + text = strings.Repeat(" ", prefixWidth) + ln + } + drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, y+1), text) + if j == 0 { + if tc := d.editor.Cursor(); tc != nil { + tc.X += prefixWidth + tc.Y += y - area.Min.Y + cur = tc + } + } + y++ + } + + return cur +} + +// HeightChanged reports whether the textarea height changed. +func (d *FreeText) HeightChanged() bool { return false } + +// SetFocused updates focus state. +func (d *FreeText) SetFocused(focused bool) { + d.focused = focused + if focused { + d.editor.Focus() + } else { + d.editor.Blur() + } +} + +// SetHover is a no-op for free text questions. +func (d *FreeText) SetHover(x, y int) {} + +// HandleMouseClick is a no-op for free text questions. +func (d *FreeText) HandleMouseClick(x, y int) (bool, bool) { return false, false } + +// HandlePaste forwards paste events to the editor textarea. +func (d *FreeText) HandlePaste(msg tea.PasteMsg) tea.Cmd { + var cmd tea.Cmd + d.editor, cmd = d.editor.Update(msg) + return cmd +} diff --git a/internal/ui/dialog/question_multi.go b/internal/ui/dialog/question_multi.go new file mode 100644 index 0000000000..1a49204a59 --- /dev/null +++ b/internal/ui/dialog/question_multi.go @@ -0,0 +1,224 @@ +package dialog + +import ( + "fmt" + "maps" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/question" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" +) + +// MultiChoice is an inline multi-choice question component. +// It embeds choiceList for shared navigation, fill-in, and +// rendering scaffold. +type MultiChoice struct { + choiceList + + selected map[int]bool + keyToggle key.Binding + keyDone key.Binding + + lastResponse question.Answer +} + +// NewMultiChoice creates a new inline multi-choice component. +func NewMultiChoice(sty *styles.Styles, req question.Question) *MultiChoice { + cl := newChoiceList(sty, req) + cl.styleFillInAsSelected = true + return &MultiChoice{ + choiceList: cl, + selected: make(map[int]bool), + keyToggle: key.NewBinding(key.WithKeys(" ", "space"), key.WithHelp("space", "toggle")), + keyDone: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "done")), + } +} + +// HandleKey processes key events. Returns true when the user has +// submitted or dismissed the question. +func (d *MultiChoice) HandleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { + // Note editor takes priority when active. + if d.activeNoteKey != "" && d.noteEditor.Focused() { + cmd, handled := d.handleNoteKey(msg, d.keyClose, func() { d.closeNote(d.noteKey()) }) + if handled { + return false, cmd + } + } + + if !d.fillIn.Focused() && d.activeNoteKey == "" { + if idx := d.numberKeyIndex(msg); idx >= 0 { + d.mouseActive = false + d.cursorIdx = idx + d.selected[idx] = !d.selected[idx] + if !d.selected[idx] { + delete(d.selected, idx) + } + return false, nil + } + } + + if done, cmd, handled := d.handleFillInFocused(msg, d.keyDone, func() (bool, tea.Cmd) { + d.fillIn.Blur() + return false, nil + }, func() (bool, tea.Cmd) { + val := strings.TrimSpace(d.fillIn.Value()) + if val != "" { + d.answer(d.respond()) + return true, nil + } + return false, nil + }); handled { + return done, cmd + } + + switch { + case key.Matches(msg, d.keyClose): + d.answer(question.Answer{QuestionID: d.Request.ID}) + return true, nil + case key.Matches(msg, d.keyDone): + if d.isFillIn() && !d.fillIn.Focused() { + d.fillIn.Focus() + return false, d.fillIn.Focus() + } + d.answer(d.respond()) + return true, nil + case key.Matches(msg, d.keyToggle): + if d.isFillIn() { + if !d.fillIn.Focused() { + d.fillIn.Focus() + return false, d.fillIn.Focus() + } + var cmd tea.Cmd + d.fillIn, cmd = d.fillIn.Update(msg) + return false, cmd + } + d.selected[d.cursorIdx] = !d.selected[d.cursorIdx] + if !d.selected[d.cursorIdx] { + delete(d.selected, d.cursorIdx) + } + case key.Matches(msg, d.keyNote) && !d.fillIn.Focused(): + return false, d.openNote(d.noteKey()) + } + if d.handleNavKey(msg) { + return false, nil + } + return false, nil +} + +func (d *MultiChoice) answer(resp question.Answer) { + d.lastResponse = resp +} + +// Response returns the last response. Used by QuestionForm to +// collect answers from child components. +// Response returns the current answer, reflecting live toggle +// state so that tabbing away preserves selections. +func (d *MultiChoice) Response() question.Answer { return d.respond() } + +// GetRequest returns the underlying question request. +func (d *MultiChoice) GetRequest() question.Question { return d.Request } + +func (d *MultiChoice) respond() question.Answer { + resp := question.Answer{QuestionID: d.Request.ID} + for i := range d.Request.Choices { + if d.selected[i] { + resp.SelectedIDs = append(resp.SelectedIDs, d.Request.Choices[i].ID) + } + } + val := strings.TrimSpace(d.fillIn.Value()) + if val != "" { + resp.FillInText = val + } + if len(d.notes) > 0 { + resp.Notes = make(map[string]string, len(d.notes)) + maps.Copy(resp.Notes, d.notes) + } + return resp +} + +// ShortHelp returns key bindings for the status bar. +func (d *MultiChoice) ShortHelp() []key.Binding { + if d.activeNoteKey != "" && d.noteEditor.Focused() { + return []key.Binding{d.keyClose, key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "save note"))} + } + if d.isFillIn() && d.fillIn.Focused() { + return []key.Binding{d.navUp, d.keyDone, d.keyClose} + } + return []key.Binding{d.keyUp, d.keyDown, d.keyToggle, numKeyBinding(len(d.Request.Choices)), d.keyNote, d.keyDone, d.keyClose} +} + +func (d *MultiChoice) Height() int { return d.height(choiceListMaxWidth + 4) } +func (d *MultiChoice) HeightChanged() bool { return d.heightChanged() } +func (d *MultiChoice) SetFocused(f bool) { d.setFocused(f) } +func (d *MultiChoice) SetHover(x, y int) { d.setHover(x, y) } +func (d *MultiChoice) HandlePaste(msg tea.PasteMsg) tea.Cmd { + return d.handlePaste(msg) +} + +// HandleMouseClick checks if the click landed on a choice item and +// toggles it, or focuses the fill-in. Returns done=false since +// multi-choice requires explicit submission. +func (d *MultiChoice) HandleMouseClick(x, y int) (bool, bool) { + if d.choiceCompositor == nil { + return false, false + } + hit := d.choiceCompositor.Hit(x, y) + if hit.Empty() { + return false, false + } + var idx int + if _, err := fmt.Sscanf(hit.ID(), "choice_%d", &idx); err != nil { + return false, false + } + if idx == len(d.Request.Choices) { + // Fill-in item. + d.cursorIdx = idx + d.mouseActive = false + d.suppressScroll = true + d.fillIn.Focus() + return false, true + } + if idx >= 0 && idx < len(d.Request.Choices) { + d.cursorIdx = idx + d.mouseActive = false + d.suppressScroll = true + d.fillIn.Blur() + d.selected[idx] = !d.selected[idx] + if !d.selected[idx] { + delete(d.selected, idx) + } + return false, true + } + return false, false +} + +// Draw renders the multi-choice question directly to screen. +// Returns the cursor position relative to area, or nil. +func (d *MultiChoice) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + fillPrefix := d.Styles.Editor.QuestionBody.Render("> ") + if strings.TrimSpace(d.fillIn.Value()) != "" { + fillPrefix = d.Styles.Editor.QuestionSelected.Render("> ") + } + + unselectedHeader := d.Styles.Editor.QuestionUnselected + selectedStyle := d.Styles.Editor.QuestionSelected + + return d.drawContent(scr, area, fillPrefix, func(i int, ch question.Choice, active bool, innerWidth int) string { + style := unselectedHeader + if active { + style = selectedStyle + } + check := d.Styles.Editor.QuestionCheckOff.Render() + " " + if d.selected[i] { + check = d.Styles.Editor.QuestionCheckOn.Render() + " " + } + checkWidth := lipgloss.Width(check) + barWidth := 2 // "┃ " or " ", applied by buildLines + labelIndent := strings.Repeat(" ", checkWidth) + return check + style.Render(wrapIndent(ch.Label, innerWidth-barWidth-checkWidth, labelIndent)) + }) +} diff --git a/internal/ui/dialog/question_single.go b/internal/ui/dialog/question_single.go new file mode 100644 index 0000000000..d336016414 --- /dev/null +++ b/internal/ui/dialog/question_single.go @@ -0,0 +1,206 @@ +package dialog + +import ( + "fmt" + "maps" + "strconv" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/question" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" +) + +// SingleChoice is an inline single-choice question component. +// It embeds choiceList for shared navigation, fill-in, and +// rendering scaffold. +type SingleChoice struct { + choiceList + + keyEnter key.Binding + + lastResponse question.Answer +} + +// NewSingleChoice creates a new inline single-choice component. +func NewSingleChoice(sty *styles.Styles, req question.Question) *SingleChoice { + return &SingleChoice{ + choiceList: newChoiceList(sty, req), + keyEnter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), + } +} + +// HandleKey processes key events. Returns true when the user has +// made a selection or dismissed the question. +func (d *SingleChoice) HandleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { + // Note editor takes priority when active. + if d.activeNoteKey != "" && d.noteEditor.Focused() { + cmd, handled := d.handleNoteKey(msg, d.keyClose, func() { d.closeNote(d.noteKey()) }) + if handled { + return false, cmd + } + } + + if !d.fillIn.Focused() && d.activeNoteKey == "" { + if idx := d.numberKeyIndex(msg); idx >= 0 { + d.mouseActive = false + d.cursorIdx = idx + return false, nil + } + } + + if done, cmd, handled := d.handleFillInFocused(msg, d.keyEnter, func() (bool, tea.Cmd) { + d.fillIn.Blur() + d.answer(d.respond()) + return true, nil + }, func() (bool, tea.Cmd) { + val := strings.TrimSpace(d.fillIn.Value()) + if val != "" { + d.answer(d.respondFillIn(val)) + return true, nil + } + return false, nil + }); handled { + return done, cmd + } + + switch { + case key.Matches(msg, d.keyClose): + d.answer(question.Answer{QuestionID: d.Request.ID}) + return true, nil + case key.Matches(msg, d.keyEnter): + d.answer(d.respond()) + return true, nil + case key.Matches(msg, d.keyNote) && !d.fillIn.Focused(): + return false, d.openNote(d.noteKey()) + } + if d.handleNavKey(msg) { + return false, nil + } + return false, nil +} + +func (d *SingleChoice) answer(resp question.Answer) { + d.lastResponse = resp +} + +// Response returns the last response. Used by QuestionForm to +// collect answers from child components. +// Response returns the current answer, reflecting live cursor +// and fill-in state so that tabbing away preserves selections. +func (d *SingleChoice) Response() question.Answer { return d.respond() } + +// GetRequest returns the underlying question request. +func (d *SingleChoice) GetRequest() question.Question { return d.Request } + +func (d *SingleChoice) respond() question.Answer { + resp := question.Answer{QuestionID: d.Request.ID} + if !d.isFillIn() && len(d.Request.Choices) > 0 { + resp.SelectedIDs = []string{d.Request.Choices[d.cursorIdx].ID} + } + if val := strings.TrimSpace(d.fillIn.Value()); val != "" { + resp.FillInText = val + } + if len(d.notes) > 0 { + resp.Notes = make(map[string]string, len(d.notes)) + maps.Copy(resp.Notes, d.notes) + } + return resp +} + +func (d *SingleChoice) respondFillIn(text string) question.Answer { + return question.Answer{QuestionID: d.Request.ID, FillInText: text} +} + +// numKeyBinding returns a display-only binding showing the valid +// number shortcut range for the given choice count. +func numKeyBinding(n int) key.Binding { + if n <= 0 { + n = 1 + } + if n > 9 { + n = 9 + } + label := "1-" + strconv.Itoa(n) + return key.NewBinding(key.WithKeys("1-9"), key.WithHelp(label, "quick select")) +} + +// ShortHelp returns key bindings for the status bar. +func (d *SingleChoice) ShortHelp() []key.Binding { + if d.activeNoteKey != "" && d.noteEditor.Focused() { + return []key.Binding{d.keyClose, key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "save note"))} + } + if d.isFillIn() && d.fillIn.Focused() { + return []key.Binding{d.navUp, d.keyEnter, d.keyClose} + } + return []key.Binding{d.keyUp, d.keyDown, d.keyEnter, numKeyBinding(len(d.Request.Choices)), d.keyNote, d.keyClose} +} + +func (d *SingleChoice) Height() int { return d.height(choiceListMaxWidth + 4) } +func (d *SingleChoice) HeightChanged() bool { return d.heightChanged() } +func (d *SingleChoice) SetFocused(f bool) { d.setFocused(f) } +func (d *SingleChoice) SetHover(x, y int) { d.setHover(x, y) } +func (d *SingleChoice) HandlePaste(msg tea.PasteMsg) tea.Cmd { + return d.handlePaste(msg) +} + +// HandleMouseClick checks if the click landed on a choice item and +// selects it. Does not advance — user can change their selection +// before pressing Enter or clicking another option. +func (d *SingleChoice) HandleMouseClick(x, y int) (bool, bool) { + if d.choiceCompositor == nil { + return false, false + } + hit := d.choiceCompositor.Hit(x, y) + if hit.Empty() { + return false, false + } + var idx int + if _, err := fmt.Sscanf(hit.ID(), "choice_%d", &idx); err != nil { + return false, false + } + if idx >= 0 && idx < len(d.Request.Choices) { + d.cursorIdx = idx + d.mouseActive = false + d.suppressScroll = true + d.fillIn.Blur() + d.answer(d.respond()) + return false, true + } + if idx == len(d.Request.Choices) { + // Fill-in: focus but don't submit. + d.cursorIdx = idx + d.mouseActive = false + d.suppressScroll = true + d.fillIn.Focus() + return false, true + } + return false, false +} + +// Draw renders the single-choice question directly to screen. +// Returns the cursor position relative to area, or nil. +func (d *SingleChoice) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + fillPrefix := d.Styles.Editor.QuestionBody.Render("> ") + if d.isFillIn() && strings.TrimSpace(d.fillIn.Value()) != "" { + fillPrefix = d.Styles.Editor.QuestionSelected.Render("> ") + } + + unselectedHeader := d.Styles.Editor.QuestionUnselected + selectedStyle := d.Styles.Editor.QuestionSelected + + return d.drawContent(scr, area, fillPrefix, func(i int, ch question.Choice, active bool, innerWidth int) string { + isSelected := false + if len(d.lastResponse.SelectedIDs) > 0 { + isSelected = d.lastResponse.SelectedIDs[0] == ch.ID + } + style := unselectedHeader + if active || (isSelected && d.mouseActive) { + style = selectedStyle + } + barWidth := 2 // "┃ " or " ", applied by buildLines + return style.Render(wrapIndent(ch.Label, innerWidth-barWidth, "")) + }) +} diff --git a/internal/ui/dialog/question_yesno.go b/internal/ui/dialog/question_yesno.go new file mode 100644 index 0000000000..1a48065b25 --- /dev/null +++ b/internal/ui/dialog/question_yesno.go @@ -0,0 +1,230 @@ +package dialog + +import ( + "image" + "maps" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/question" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" +) + +// YesNo is an inline yes/no confirmation component. For open-ended +// responses, use FreeText instead. Notes can be added via alt+n. +type YesNo struct { + questionEditor + Request question.Question + selectedNo bool + focused bool + compositor *lipgloss.Compositor + hoverX int + hoverY int + + keyLeftRight key.Binding + keyEnter key.Binding + keyYes key.Binding + keyNo key.Binding + keyClose key.Binding + + lastResponse question.Answer + lastWidth int +} + +// NewYesNo creates a new inline yes/no question component. +func NewYesNo(sty *styles.Styles, req question.Question) *YesNo { + return &YesNo{ + questionEditor: newQuestionEditor(sty), + Request: req, + selectedNo: true, // Default to "No" for safety. + keyLeftRight: key.NewBinding(key.WithKeys("left", "right"), key.WithHelp("←/→", "switch")), + keyEnter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), + keyYes: key.NewBinding(key.WithKeys("y", "Y"), key.WithHelp("y", "yes")), + keyNo: key.NewBinding(key.WithKeys("n", "N"), key.WithHelp("n", "no")), + keyClose: CloseKey, + } +} + +// HandleKey processes a key press. Returns true when the user has +// made a choice or dismissed the question. +func (d *YesNo) HandleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { + // Note editor takes priority when active. + if d.activeNoteKey != "" && d.noteEditor.Focused() { + cmd, handled := d.handleNoteKey(msg, d.keyClose, func() { d.closeNote("_question") }) + if handled { + return false, cmd + } + } + + switch { + case key.Matches(msg, CloseKey): + d.answer(question.Answer{QuestionID: d.Request.ID}) + return true, nil + case key.Matches(msg, d.keyLeftRight): + d.selectedNo = !d.selectedNo + return false, nil + case key.Matches(msg, d.keyEnter): + d.answer(d.respond(!d.selectedNo)) + return true, nil + case key.Matches(msg, d.keyYes): + d.answer(d.respond(true)) + return true, nil + case key.Matches(msg, d.keyNo): + d.answer(d.respond(false)) + return true, nil + case key.Matches(msg, d.keyNote): + return false, d.openNote("_question") + } + return false, nil +} + +func (d *YesNo) answer(resp question.Answer) { + d.lastResponse = resp +} + +// Response returns the last response. Used by QuestionForm to +// collect answers from child components. +// Response returns the current answer, reflecting live selection +// state so that tabbing away preserves the choice. +func (d *YesNo) Response() question.Answer { return d.respond(!d.selectedNo) } + +// GetRequest returns the underlying question request. +func (d *YesNo) GetRequest() question.Question { return d.Request } + +// ShortHelp returns key bindings for the status bar help display. +func (d *YesNo) ShortHelp() []key.Binding { + if d.activeNoteKey != "" && d.noteEditor.Focused() { + return []key.Binding{d.keyClose, key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "save note"))} + } + return []key.Binding{d.keyLeftRight, d.keyEnter, d.keyYes, d.keyNo, d.keyNote} +} + +func (d *YesNo) respond(yes bool) question.Answer { + resp := question.Answer{ + QuestionID: d.Request.ID, + Yes: &yes, + } + if len(d.notes) > 0 { + resp.Notes = make(map[string]string, len(d.notes)) + maps.Copy(resp.Notes, d.notes) + } + return resp +} + +// Height returns the visual height at the default max width. +// Pure function — no render-time state. +func (d *YesNo) Height() int { + w := d.lastWidth + if w <= 0 { + w = choiceListMaxWidth + } + iconPrompt := questionIconPrompt(d.Styles, d.focused) + h := sectionHeight(d.Request.Text, w-lipgloss.Width(iconPrompt)) // question + h++ // blank + if d.Request.Description != "" { + r := common.MarkdownRenderer(d.Styles, w) + mu := common.LockMarkdownRenderer(r) + mu.Lock() + out, err := r.Render(d.Request.Description) + mu.Unlock() + if err == nil { + out = strings.TrimSuffix(out, "\n") + h += strings.Count(out, "\n") + 1 + } else { + h += sectionHeight(d.Request.Description, w) + } + h++ // blank + } + h++ // buttons + // Note height if present. + if d.activeNoteKey != "" && d.noteEditor.Focused() { + h++ // blank separator before note editor + h += d.noteEditor.Height() + } else if len(d.notes) > 0 { + h++ // blank separator + h++ // saved note text (single line) + } + h++ // trailing blank for bottom padding + return h +} + +// Draw renders the yes/no question directly to screen. +// Returns the cursor position when the note editor is active, or nil. +func (d *YesNo) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + d.lastWidth = area.Dx() + y := area.Min.Y + + // Draw question header. + iconPrompt := questionIconPrompt(d.Styles, d.focused) + qText := iconPrompt + d.Styles.Editor.QuestionUnselected.Render( + ansi.Wrap(d.Request.Text, area.Dx()-lipgloss.Width(iconPrompt), ""), + ) + y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), qText) + y++ // blank + + // Draw optional description. + if d.Request.Description != "" { + r := common.MarkdownRenderer(d.Styles, area.Dx()) + mu := common.LockMarkdownRenderer(r) + mu.Lock() + desc, err := r.Render(d.Request.Description) + mu.Unlock() + if err == nil { + desc = strings.TrimSuffix(desc, "\n") + y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), desc) + } else { + y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), d.Request.Description) + } + y++ // blank + } + + // Draw buttons. Build compositor first so hover uses current geometry. + buttonOptsList := []common.ButtonOpts{ + {Text: "Yes", Selected: !d.selectedNo, Padding: 3, UnderlineIndex: -1}, + {Text: "No", Selected: d.selectedNo, Padding: 3, UnderlineIndex: -1}, + } + d.compositor = common.ButtonHitCompositor(d.Styles, buttonOptsList, " ", area.Min.X, y) + hoveredBtn := common.HitButtonIndex(d.compositor, d.hoverX, d.hoverY) + buttonOptsList[0].Hovered = hoveredBtn == 0 + buttonOptsList[1].Hovered = hoveredBtn == 1 + buttons := common.ButtonGroup(d.Styles, buttonOptsList, " ") + y += drawStyledText(scr, image.Rect(area.Min.X, y, area.Max.X, area.Max.Y), buttons) + + // Draw note editor or saved note. + cur, _ := d.drawStandaloneNote(scr, area, y, "_question") + return cur +} + +// HeightChanged always returns false — Height is now pure. +func (d *YesNo) HeightChanged() bool { return false } + +// SetFocused updates the icon style based on whether the editor +// area is focused. +func (d *YesNo) SetFocused(focused bool) { d.focused = focused } + +// SetHover updates the hover position for button highlighting. +func (d *YesNo) SetHover(x, y int) { d.hoverX = x; d.hoverY = y } + +// HandlePaste forwards paste events to the note editor textarea. +func (d *YesNo) HandlePaste(msg tea.PasteMsg) tea.Cmd { return d.handlePaste(msg) } + +// HandleMouseClick checks if the click landed on a button and +// triggers the corresponding answer. +func (d *YesNo) HandleMouseClick(x, y int) (bool, bool) { + switch common.HitButtonIndex(d.compositor, x, y) { + case 0: // Yes + d.selectedNo = false + d.answer(d.respond(true)) + return false, true + case 1: // No + d.selectedNo = true + d.answer(d.respond(false)) + return false, true + } + return false, false +} diff --git a/internal/ui/model/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 ab6e1779d6..2b616aa94c 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" @@ -205,6 +206,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 @@ -268,6 +275,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 @@ -736,6 +745,19 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case pubsub.Event[permission.PermissionNotification]: m.handlePermissionNotification(msg.Payload) + case pubsub.Event[question.Request]: + m.openBatchFormDialog(msg.Payload) + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + if cmd := m.sendNotification(notification.Notification{ + Title: "Crush is waiting...", + Message: fmt.Sprintf("%d questions need your input", len(msg.Payload.Questions)), + }); cmd != nil { + cmds = append(cmds, cmd) + } + case pubsub.Event[question.Notification]: + m.handleQuestionNotification(msg.Payload) case cancelTimerExpiredMsg: m.isCanceling = false case tea.TerminalVersionMsg: @@ -771,6 +793,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) } @@ -798,6 +834,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 { @@ -916,6 +963,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) } @@ -1181,7 +1236,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 @@ -1888,6 +1947,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() { @@ -2227,8 +2321,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 { @@ -2242,12 +2349,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 { @@ -2309,6 +2429,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 @@ -2326,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()) @@ -2356,6 +2489,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() == "" { @@ -2432,6 +2571,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 @@ -2689,7 +2833,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 @@ -2939,8 +3093,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) @@ -3515,6 +3669,44 @@ func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd { return nil } +// openBatchFormDialog activates a tabbed multi-question form in +// the editor area. Single questions render without tabs or confirm. +func (m *UI) openBatchFormDialog(batch question.Request) { + // Close any existing question form first to prevent stacking. + if qf, ok := m.activeInline.(*dialog.QuestionForm); ok && qf != nil { + m.activeInline = nil + } + + form := dialog.NewQuestionForm(m.com.Styles, batch) + form.OnAnswer = func(responses []question.Answer) { + m.com.Workspace.QuestionAnswer(responses) + } + m.activeInline = form + m.textarea.Blur() + m.focus = uiFocusEditor + m.activeInline.SetFocused(true) + m.updateLayoutAndSize() +} + +// handleQuestionNotification dismisses an open question form when +// any client resolved the pending batch. Only one question can be +// pending at a time, so any notification means the current form +// is stale regardless of BatchID. +func (m *UI) handleQuestionNotification(_ question.Notification) { + if _, ok := m.activeInline.(*dialog.QuestionForm); ok { + m.activeInline = nil + m.textarea.Focus() + m.updateLayoutAndSize() + } +} + +// shouldCollapseQuestion reports whether a question form should render +// in its collapsed one-line view. This is true only when the form is +// unfocused and would consume more than half the terminal height. +func (m *UI) shouldCollapseQuestion(qf *dialog.QuestionForm) bool { + return m.focus != uiFocusEditor && m.height > 0 && qf.Height() > m.height*2/5 +} + // handlePermissionNotification updates tool items when permission state changes. func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) { if toolItem := m.chat.MessageItem(notification.ToolCallID); toolItem != nil { diff --git a/internal/ui/styles/quickstyle.go b/internal/ui/styles/quickstyle.go index 465916db8f..701157b482 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" ) @@ -691,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("::: ") @@ -699,11 +702,53 @@ func quickStyle(o quickStyleOpts) Styles { s.Editor.PromptYoloIconBlurred = s.Editor.PromptYoloIconFocused.Foreground(o.bgBase).Background(o.fgMoreSubtle) s.Editor.PromptYoloDotsFocused = lipgloss.NewStyle().MarginRight(1).Foreground(o.warningSubtle).SetString(":::") s.Editor.PromptYoloDotsBlurred = s.Editor.PromptYoloDotsFocused.Foreground(o.fgMoreSubtle) + s.Editor.PromptQuestionIconFocused = lipgloss.NewStyle().MarginRight(1).Foreground(o.fgBase).Background(o.primary).Bold(true).SetString(" ? ") + s.Editor.PromptQuestionIconBlurred = s.Editor.PromptQuestionIconFocused.Foreground(o.bgBase).Background(o.fgMoreSubtle) + s.Editor.QuestionSelected = lipgloss.NewStyle().Foreground(o.secondary).Bold(true) + s.Editor.QuestionUnselected = lipgloss.NewStyle().Foreground(o.fgBase) + s.Editor.QuestionBody = lipgloss.NewStyle().Foreground(o.fgMoreSubtle) + s.Editor.QuestionConfirm = lipgloss.NewStyle().Foreground(o.primary).Bold(true) + s.Editor.QuestionNote = lipgloss.NewStyle().Foreground(o.fgMostSubtle) + s.Editor.QuestionCursorBar = lipgloss.NewStyle().Foreground(o.secondary) + s.Editor.QuestionRadioOn = lipgloss.NewStyle().Foreground(o.secondary).SetString(RadioOn) + s.Editor.QuestionRadioOff = lipgloss.NewStyle().Foreground(o.fgSubtle).SetString(RadioOff) + s.Editor.QuestionCheckOn = lipgloss.NewStyle().Foreground(o.secondary).SetString(RadioOn) + s.Editor.QuestionCheckOff = lipgloss.NewStyle().Foreground(o.fgSubtle).SetString(RadioOff) s.Radio.On = lipgloss.NewStyle().Foreground(o.fgSubtle).SetString(RadioOn) s.Radio.Off = lipgloss.NewStyle().Foreground(o.fgSubtle).SetString(RadioOff) s.Radio.Label = lipgloss.NewStyle().Foreground(o.fgSubtle) + // Tabs for batch question forms. All borders use charple + // (primary). Active tab has an open bottom that merges with + // the content area; inactive tabs have a closed bottom. First + // tab gets a right-angle bottom-left corner at draw time. + borderColor := uv.Style{Fg: o.primary} + inactiveBorder := uv.RoundedBorder().Style(borderColor) + inactiveBorder.BottomLeft = uv.Side{Content: "┴", Style: borderColor} + inactiveBorder.BottomRight = uv.Side{Content: "┴", Style: borderColor} + activeBorder := uv.RoundedBorder().Style(borderColor) + activeBorder.Bottom = uv.Side{Content: " ", Style: borderColor} + activeBorder.BottomLeft = uv.Side{Content: "┘", Style: borderColor} + activeBorder.BottomRight = uv.Side{Content: "└", Style: borderColor} + + s.Tab.ActiveBorder = activeBorder + s.Tab.InactiveBorder = inactiveBorder + + blurredBorderColor := uv.Style{Fg: o.fgMoreSubtle} + inactiveBorderBlurred := uv.RoundedBorder().Style(blurredBorderColor) + inactiveBorderBlurred.BottomLeft = uv.Side{Content: "┴", Style: blurredBorderColor} + inactiveBorderBlurred.BottomRight = uv.Side{Content: "┴", Style: blurredBorderColor} + activeBorderBlurred := uv.RoundedBorder().Style(blurredBorderColor) + activeBorderBlurred.Bottom = uv.Side{Content: " ", Style: blurredBorderColor} + activeBorderBlurred.BottomLeft = uv.Side{Content: "┘", Style: blurredBorderColor} + activeBorderBlurred.BottomRight = uv.Side{Content: "└", Style: blurredBorderColor} + s.Tab.ActiveBorderBlurred = activeBorderBlurred + s.Tab.InactiveBorderBlurred = inactiveBorderBlurred + + s.Tab.ActiveStyle = uv.Style{Fg: o.fgBase} + s.Tab.InactiveStyle = uv.Style{Fg: o.fgMoreSubtle} + // Logo s.Logo.FieldColor = o.primary s.Logo.TitleColorA = o.secondary diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index c2ca9824ba..5055ec3a2f 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 ( @@ -106,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 @@ -123,6 +126,22 @@ type Styles struct { PromptYoloIconBlurred lipgloss.Style PromptYoloDotsFocused lipgloss.Style PromptYoloDotsBlurred lipgloss.Style + + // Question mode prompt (" ? " icon + ":::" dots). + PromptQuestionIconFocused lipgloss.Style + PromptQuestionIconBlurred lipgloss.Style + + // Question choice styling. + QuestionSelected lipgloss.Style // Active choice text (Dolly). + QuestionUnselected lipgloss.Style // Inactive header text (Sash). + QuestionBody lipgloss.Style // Description/body text. + QuestionConfirm lipgloss.Style // Confirm tab title (primary). + QuestionNote lipgloss.Style // Saved note text (dimmer than body). + QuestionCursorBar lipgloss.Style // Active cursor indicator bar. + QuestionRadioOn lipgloss.Style // Selected single-choice radio. + QuestionRadioOff lipgloss.Style // Unselected single-choice radio. + QuestionCheckOn lipgloss.Style // Checked multi-choice indicator. + QuestionCheckOff lipgloss.Style // Unchecked multi-choice indicator. } // Radio @@ -132,6 +151,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..ef2f9e4a42 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" ) @@ -175,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) } @@ -201,6 +206,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..f54f4c99b4 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" @@ -254,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 { @@ -330,6 +335,30 @@ func (w *ClientWorkspace) PermissionSetSkipRequests(skip bool) { _ = w.client.SetPermissionsSkipRequests(context.Background(), w.workspaceID(), skip) } +// -- Questions -- + +// QuestionAnswer submits answers for a question via the client SDK. +func (w *ClientWorkspace) QuestionAnswer(responses []question.Answer) bool { + protoResp := proto.QuestionAnswer{ + Responses: make([]proto.QuestionResponse, len(responses)), + } + for i, r := range responses { + protoResp.Responses[i] = proto.QuestionResponse{ + QuestionID: r.QuestionID, + SelectedIDs: r.SelectedIDs, + FillInText: r.FillInText, + Yes: r.Yes, + Notes: r.Notes, + } + } + resolved, err := w.client.AnswerQuestionBatch(context.Background(), w.workspaceID(), protoResp) + if err != nil { + slog.Error("Failed to answer question", "error", err) + return false + } + return resolved +} + // -- FileTracker -- func (w *ClientWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) { @@ -688,6 +717,25 @@ func (w *ClientWorkspace) translateEvent(ev any) tea.Msg { Denied: e.Payload.Denied, }, } + case pubsub.Event[proto.QuestionRequest]: + return pubsub.Event[question.Request]{ + Type: e.Type, + Payload: question.Request{ + ID: e.Payload.ID, + SessionID: e.Payload.SessionID, + ToolCallID: e.Payload.ToolCallID, + Questions: protoQuestionsToDomain(e.Payload.Questions), + ConfirmTitle: e.Payload.ConfirmTitle, + ConfirmDescription: e.Payload.ConfirmDescription, + }, + } + case pubsub.Event[proto.QuestionNotification]: + return pubsub.Event[question.Notification]{ + Type: e.Type, + Payload: question.Notification{ + BatchID: e.Payload.BatchID, + }, + } case pubsub.Event[proto.Message]: return pubsub.Event[message.Message]{ Type: e.Type, @@ -939,3 +987,29 @@ func todosToProto(todos []session.Todo) []proto.Todo { } return out } + +func protoQuestionsToDomain(qs []proto.QuestionItem) []question.Question { + if len(qs) == 0 { + return nil + } + out := make([]question.Question, len(qs)) + for i, q := range qs { + choices := make([]question.Choice, len(q.Choices)) + for j, c := range q.Choices { + choices[j] = question.Choice{ + ID: c.ID, + Label: c.Label, + Description: c.Description, + } + } + out[i] = question.Question{ + ID: q.ID, + Type: question.Type(q.Type), + Label: q.Label, + Text: q.Question, + Description: q.Description, + Choices: choices, + } + } + return out +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 9049b7bc68..e27162e7da 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" ) @@ -93,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 @@ -110,6 +112,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