diff --git a/docs/plan-mode.md b/docs/plan-mode.md index 1bd301d52..b08b3ce6b 100644 --- a/docs/plan-mode.md +++ b/docs/plan-mode.md @@ -134,17 +134,16 @@ $ ls ~/.infer/plans/ When the model calls `RequestPlanApproval`, the chat TUI: -1. Renders the saved plan in a dedicated panel. -2. Shows a status line: `Plan ready - use arrow keys to select and Enter - to confirm`. -3. Offers three options: - - - **Accept** (`Enter`/`y`) - agent switches to Auto-Accept mode (no - per-tool approval prompts) and executes the plan. - - **Reject** (`n`) - chat session ends; you can write a follow-up message - with feedback and the agent re-iterates the plan. - - **Approve Each Step** (`s`) - agent switches to Standard mode and - executes the plan, prompting for approval on each action. +1. Renders the saved plan in the conversation behind an accent rail. +2. Shows a status line: `Plan ready for review`. +3. Offers three options, chosen with `←`/`→` and confirmed with `Enter`: + + - **Accept** - agent switches to Auto-Accept mode (no per-tool approval + prompts) and executes the plan. + - **Reject** (also `Esc`) - chat session ends. You can write a follow-up + message with feedback and the agent re-iterates the plan. + - **Approve each step** - agent switches to Standard mode and executes + the plan, prompting for approval on each action. In all three cases the stored plan remains as an audit trail. Rejecting a plan does **not** delete it. diff --git a/internal/presentation/tui/approvalcoord/coordinator.go b/internal/presentation/tui/approvalcoord/coordinator.go index 59e1c6f5a..9742cbf1c 100644 --- a/internal/presentation/tui/approvalcoord/coordinator.go +++ b/internal/presentation/tui/approvalcoord/coordinator.go @@ -77,7 +77,7 @@ func (s *Service) planApprovalRequestedCmds(_ string) []tea.Cmd { }, func() tea.Msg { return tui.SetStatusEvent{ - Message: "Plan ready - use arrow keys to select and Enter to confirm", + Message: "Plan ready for review", Spinner: false, StatusType: tui.StatusDefault, } @@ -95,7 +95,7 @@ func (s *Service) HandleUserQuestionRequested(msg agentdomain.UserQuestionReques return func() tea.Msg { return tui.SetStatusEvent{ - Message: "Please answer the question(s) - ↑/↓ move, space toggle, enter to continue, esc to cancel", + Message: "Waiting for your answer", Spinner: false, StatusType: tui.StatusDefault, } diff --git a/internal/presentation/tui/components/conversation_view.go b/internal/presentation/tui/components/conversation_view.go index fef4aef66..32a144958 100644 --- a/internal/presentation/tui/components/conversation_view.go +++ b/internal/presentation/tui/components/conversation_view.go @@ -675,7 +675,7 @@ func (cv *ConversationView) tryRenderSpecialEntry(entry convdomain.ConversationE } case "assistant": if entry.IsPlan { - return true, cv.renderPlanEntry(entry, index) + return true, cv.renderPlanEntry(entry) } if entry.PendingToolCall != nil { return true, cv.renderPendingToolEntry(entry) @@ -1336,69 +1336,63 @@ func (cv *ConversationView) renderToolCommandEntry(_ convdomain.ConversationEntr return message + "\n" } -// renderPlanEntry renders the plan body as a regular markdown-rendered -// assistant message under a status-aware header, followed by inline -// approval buttons while approval is pending. -func (cv *ConversationView) renderPlanEntry(entry convdomain.ConversationEntry, index int) string { - var result strings.Builder - - color, role := cv.planRoleAndColor(entry) - roleStyled := cv.styleProvider.RenderWithColor(role+":", color) +// renderPlanEntry renders a plan behind a rail in its approval status color: +// a status chip, the markdown body and the approval actions while pending. +func (cv *ConversationView) renderPlanEntry(entry convdomain.ConversationEntry) string { + color, status := cv.planColorAndStatus(entry) contentStr, err := entry.Message.Content.AsMessageContent0() if err != nil { contentStr = formatting.ExtractTextFromContent(entry.Message.Content, entry.Images) } - wrapWidth := max(cv.width-2, 40) + wrapWidth := max(cv.width-4, 40) - var formattedContent string + var body string switch entry.PlanApprovalStatus { case convdomain.PlanApprovalRejected: plain := formatting.FormatResponsiveMessage(contentStr, wrapWidth) - formattedContent = cv.styleProvider.RenderWithColor(plain, color) + body = cv.styleProvider.RenderWithColor(plain, color) default: - formattedContent = cv.applyMarkdownIfEnabled(contentStr, wrapWidth) + body = cv.applyMarkdownIfEnabled(contentStr, wrapWidth) } - result.WriteString(roleStyled) - result.WriteString("\n\n") - for line := range strings.SplitSeq(formattedContent, "\n") { - if line == "" { - result.WriteString("\n") - continue - } - result.WriteString(" ") - result.WriteString(line) - result.WriteString("\n") + parts := []string{ + cv.styleProvider.RenderChip("Plan", color) + " " + cv.styleProvider.RenderDimText(status), + "", + strings.Trim(body, "\n"), } - if entry.PlanApprovalStatus == convdomain.PlanApprovalPending { - result.WriteString("\n") - result.WriteString(cv.renderInlineApprovalButtons(index)) - result.WriteString("\n") + parts = append(parts, "", cv.renderPlanApprovalActions()) } - return result.String() + "\n" + return cv.styleProvider.RenderRail(strings.Join(parts, "\n"), color) + "\n\n" } -// planRoleAndColor returns the role label + theme color for a plan entry +// planColorAndStatus returns the theme color and status text for a plan entry // based on its approval status. -func (cv *ConversationView) planRoleAndColor(entry convdomain.ConversationEntry) (string, string) { +func (cv *ConversationView) planColorAndStatus(entry convdomain.ConversationEntry) (string, string) { switch entry.PlanApprovalStatus { case convdomain.PlanApprovalPending: - return cv.styleProvider.GetThemeColor("accent"), "Plan (Pending Approval)" + return cv.styleProvider.GetThemeColor("accent"), "pending approval" case convdomain.PlanApprovalAccepted: - return cv.styleProvider.GetThemeColor("success"), "Plan (Accepted)" + return cv.styleProvider.GetThemeColor("success"), "accepted" case convdomain.PlanApprovalRejected: - return cv.styleProvider.GetThemeColor("dim"), "Plan (Rejected)" + return cv.styleProvider.GetThemeColor("dim"), "rejected" default: - return cv.getAssistantColor(), "Plan" + return cv.getAssistantColor(), "" } } -// renderInlineApprovalButtons renders inline approval buttons for a plan -func (cv *ConversationView) renderInlineApprovalButtons(_ int) string { +var planApprovalLabels = [...]string{ + agentdomain.PlanApprovalAccept: "Accept", + agentdomain.PlanApprovalReject: "Reject", + agentdomain.PlanApprovalAcceptStandard: "Approve each step", +} + +// renderPlanApprovalActions renders the plan actions on one row, the selected +// one as an accent chip, above the keys that drive them. +func (cv *ConversationView) renderPlanApprovalActions() string { selectedIndex := 0 if cv.stateManager != nil { if planState := cv.stateManager.GetPlanApprovalUIState(); planState != nil { @@ -1406,47 +1400,18 @@ func (cv *ConversationView) renderInlineApprovalButtons(_ int) string { } } - acceptText := "Accept" - rejectText := "Reject" - standardText := "Approve Each Step" - - successColor := cv.styleProvider.GetThemeColor("success") - errorColor := cv.styleProvider.GetThemeColor("error") accentColor := cv.styleProvider.GetThemeColor("accent") - highlightBg := cv.styleProvider.GetThemeColor("selection_bg") - - var acceptStyled, rejectStyled, standardStyled string - if selectedIndex == int(agentdomain.PlanApprovalAccept) { - acceptStyled = cv.styleProvider.RenderStyledText("[ "+acceptText+" ]", styles.StyleOptions{ - Foreground: successColor, - Background: highlightBg, - Bold: true, - }) - } else { - acceptStyled = cv.styleProvider.RenderWithColor("[ "+acceptText+" ]", successColor) - } - - if selectedIndex == int(agentdomain.PlanApprovalReject) { - rejectStyled = cv.styleProvider.RenderStyledText("[ "+rejectText+" ]", styles.StyleOptions{ - Foreground: errorColor, - Background: highlightBg, - Bold: true, - }) - } else { - rejectStyled = cv.styleProvider.RenderWithColor("[ "+rejectText+" ]", errorColor) - } - - if selectedIndex == int(agentdomain.PlanApprovalAcceptStandard) { - standardStyled = cv.styleProvider.RenderStyledText("[ "+standardText+" ]", styles.StyleOptions{ - Foreground: accentColor, - Background: highlightBg, - Bold: true, - }) - } else { - standardStyled = cv.styleProvider.RenderWithColor("[ "+standardText+" ]", accentColor) + actions := make([]string, len(planApprovalLabels)) + for i, label := range planApprovalLabels { + if i == selectedIndex { + actions[i] = cv.styleProvider.RenderChip(label, accentColor) + continue + } + actions[i] = cv.styleProvider.RenderDimText(" " + label + " ") } - return fmt.Sprintf(" %s %s %s", acceptStyled, rejectStyled, standardStyled) + hint := cv.styleProvider.RenderDimText("←→ choose · enter confirm · esc reject") + return strings.Join(actions, " ") + "\n" + hint } // renderPendingToolEntry renders a pending tool call that requires approval diff --git a/internal/presentation/tui/components/conversation_view_test.go b/internal/presentation/tui/components/conversation_view_test.go index 2f4c7e6e8..db83c3b3d 100644 --- a/internal/presentation/tui/components/conversation_view_test.go +++ b/internal/presentation/tui/components/conversation_view_test.go @@ -613,6 +613,20 @@ func TestConversationView_RenderCache(t *testing.T) { }) } +func TestConversationView_PendingPlanRendersActions(t *testing.T) { + cv := NewConversationView(createMockStyleProvider()) + entry := renderCacheConversation()[1] + entry.IsPlan = true + entry.PlanApprovalStatus = convdomain.PlanApprovalPending + + out := cv.renderPlanEntry(entry) + for _, want := range []string{"Plan", "pending approval", "Accept", "Reject", "Approve each step", "enter confirm"} { + if !strings.Contains(out, want) { + t.Errorf("expected render to contain %q, got:\n%s", want, out) + } + } +} + // heightFormatter renders a tool result as `collapsed` lines when collapsed and // `expanded` lines when expanded, giving scroll-anchoring math a real height delta. type heightFormatter struct{ collapsed, expanded int } diff --git a/internal/presentation/tui/components/huh_theme.go b/internal/presentation/tui/components/huh_theme.go index 8e02c1084..135aa7c37 100644 --- a/internal/presentation/tui/components/huh_theme.go +++ b/internal/presentation/tui/components/huh_theme.go @@ -74,3 +74,24 @@ func approvalHuhTheme(p *styles.Provider) huh.Theme { return t }) } + +// questionHuhTheme is huhTheme without the field border, since the question +// prompt draws its own rail, and with the Charm selector and check glyphs. +func questionHuhTheme(p *styles.Provider) huh.Theme { + base := huhTheme(p) + return huh.ThemeFunc(func(isDark bool) *huh.Styles { + t := base.Theme(isDark) + if p == nil { + return t + } + dim := lipgloss.Color(p.GetThemeColor("dim")) + for _, s := range []*huh.FieldStyles{&t.Focused, &t.Blurred} { + s.Base = lipgloss.NewStyle() + s.SelectSelector = s.SelectSelector.SetString("❯ ") + s.MultiSelectSelector = s.MultiSelectSelector.SetString("❯ ") + s.SelectedPrefix = s.SelectedPrefix.SetString("✓ ") + s.UnselectedPrefix = s.UnselectedPrefix.SetString("• ").Foreground(dim) + } + return t + }) +} diff --git a/internal/presentation/tui/components/input_view.go b/internal/presentation/tui/components/input_view.go index 3b7c5a187..ed0480c4e 100644 --- a/internal/presentation/tui/components/input_view.go +++ b/internal/presentation/tui/components/input_view.go @@ -564,7 +564,7 @@ func (iv *InputView) renderDisabledPlaceholder() string { } if iv.stateManager.GetPlanApprovalUIState() != nil { - return iv.styleProvider.RenderDimText("⏸ Plan approval required - use ←/→ or h/l to navigate, Enter/y to accept (auto), n to reject, s to approve each step") + return iv.styleProvider.RenderDimText("⏸ Plan approval required") } if iv.stateManager.GetApprovalUIState() != nil { diff --git a/internal/presentation/tui/components/question_form_view.go b/internal/presentation/tui/components/question_form_view.go index ab99cef12..f6c4e967d 100644 --- a/internal/presentation/tui/components/question_form_view.go +++ b/internal/presentation/tui/components/question_form_view.go @@ -10,6 +10,7 @@ import ( huh "charm.land/huh/v2" agentdomain "github.com/inference-gateway/cli/internal/agent/domain" + formatting "github.com/inference-gateway/cli/internal/platform/formatting" tui "github.com/inference-gateway/cli/internal/presentation/tui" styles "github.com/inference-gateway/cli/internal/presentation/tui/styles" ) @@ -20,14 +21,14 @@ const minQuestionOptionWidth = 20 // otherOptionLabel is the synthesized free-text choice appended to every // question. It is not one of the model-provided options. -const otherOptionLabel = "Other (type your own)" +const otherOptionLabel = "Other…" // otherSentinel is the option value standing in for the synthesized "Other" // row; real options are their index into question.Options. const otherSentinel = -1 -// QuestionFormView drives the interactive AskUserQuestion form as a bordered -// box floating above the input (mirroring ApprovalBoxView). It owns the +// QuestionFormView drives the interactive AskUserQuestion form behind an accent +// rail floating above the input. It owns the // answer-in-progress state as one huh form per question; the state store only // carries the questions, the overlay-active flag, and the response channel. // The agent loop is blocked in the tool goroutine until the answers are sent @@ -142,21 +143,18 @@ func (qv *QuestionFormView) buildForm() { for i, opt := range question.Options { label := opt.Label if opt.Description != "" { - label = fmt.Sprintf("%s - %s", opt.Label, opt.Description) + label = opt.Label + "\n " + qv.styleProvider.RenderDimText(opt.Description) } options = append(options, huh.NewOption(label, i)) } options = append(options, huh.NewOption(otherOptionLabel, otherSentinel)) - title := fmt.Sprintf("%s (%d/%d)", strings.TrimSpace(question.Header), qv.idx+1, len(qv.active.Questions)) qv.other = "" var choiceField huh.Field if question.MultiSelect { qv.multi = nil choiceField = huh.NewMultiSelect[int](). - Title(title). - Description(question.Question). Options(options...). Validate(func(v []int) error { if len(v) == 0 { @@ -168,14 +166,12 @@ func (qv *QuestionFormView) buildForm() { } else { qv.single = defaultUserQuestionOption(question) choiceField = huh.NewSelect[int](). - Title(title). - Description(question.Question). Options(options...). Value(&qv.single) } otherInput := huh.NewInput(). - Title(otherOptionLabel). + Title("Your answer"). Validate(func(s string) error { if strings.TrimSpace(s) == "" { return fmt.Errorf("answer is required") @@ -194,7 +190,7 @@ func (qv *QuestionFormView) buildForm() { WithShowHelp(true). WithWidth(qv.textBudget()). WithKeyMap(keymap). - WithTheme(huhTheme(qv.styleProvider)) + WithTheme(questionHuhTheme(qv.styleProvider)) } // otherChosen reports whether the synthesized "Other" row is currently chosen. @@ -245,14 +241,30 @@ func (qv *QuestionFormView) Render() string { return "" } accentColor := qv.styleProvider.GetThemeColor("accent") - return qv.styleProvider.RenderBorderedBox(qv.form.View(), accentColor, 0, 1) + question := qv.active.Questions[qv.idx] + parts := []string{ + qv.renderHeader(question.Header, accentColor), + qv.styleProvider.RenderBold(formatting.WrapText(question.Question, qv.textBudget())), + qv.form.View(), + } + return qv.styleProvider.RenderRail(strings.Join(parts, "\n\n"), accentColor) +} + +// renderHeader renders the question's header chip, followed by the progress +// counter when the prompt holds more than one question. +func (qv *QuestionFormView) renderHeader(header, chipColor string) string { + chip := qv.styleProvider.RenderChip(strings.TrimSpace(header), chipColor) + total := len(qv.active.Questions) + if total == 1 { + return chip + } + return chip + " " + qv.styleProvider.RenderDimText(fmt.Sprintf("%d of %d", qv.idx+1, total)) } -// textBudget is the display width available inside the bordered box after -// reserving room for the border (2) and horizontal padding (2), plus slack +// textBudget is the display width available beside the rail (2), plus slack // from the right edge. func (qv *QuestionFormView) textBudget() int { - budget := qv.width - 6 + budget := qv.width - 4 if budget < minQuestionOptionWidth { return minQuestionOptionWidth } diff --git a/internal/presentation/tui/components/question_form_view_test.go b/internal/presentation/tui/components/question_form_view_test.go index 7085068a4..5b704b050 100644 --- a/internal/presentation/tui/components/question_form_view_test.go +++ b/internal/presentation/tui/components/question_form_view_test.go @@ -76,11 +76,27 @@ func TestQuestionFormView_RendersQuestion(t *testing.T) { v, _ := newQuestionFormForTest(questionStateForTest()) out := v.Render() - for _, want := range []string{"Format", "(1/1)", "Which output format?", "JSON", "YAML", "Other"} { + for _, want := range []string{"Format", "Which output format?", "JSON", "machine readable", "YAML", "Other"} { if !strings.Contains(out, want) { t.Errorf("expected render to contain %q, got:\n%s", want, out) } } + if strings.Contains(out, "1 of 1") { + t.Errorf("expected no progress counter for a single question, got:\n%s", out) + } +} + +func TestQuestionFormView_RendersProgressCounter(t *testing.T) { + question := agentdomain.UserQuestion{ + Header: "Fmt", + Question: "q", + Options: []agentdomain.UserQuestionOption{{Label: "A"}, {Label: "B"}}, + } + v, _ := newQuestionFormForTest(questionStateForTest(question, question)) + + if out := v.Render(); !strings.Contains(out, "1 of 2") { + t.Errorf("expected the progress counter, got:\n%s", out) + } } func TestQuestionFormView_SingleSelectDefaultSubmit(t *testing.T) { diff --git a/internal/presentation/tui/components/status_view.go b/internal/presentation/tui/components/status_view.go index fb94a5279..f790f9a7c 100644 --- a/internal/presentation/tui/components/status_view.go +++ b/internal/presentation/tui/components/status_view.go @@ -153,11 +153,10 @@ func (sv *StatusView) endTurn(cancelled bool) { // elapsedExcludingApprovals returns the spinner wall time minus any pause // currently held on a user decision, matching the live counter. func (sv *StatusView) elapsedExcludingApprovals() time.Duration { - elapsed := time.Since(sv.startTime) if !sv.pausedAt.IsZero() { - elapsed -= time.Since(sv.pausedAt) + return sv.pausedAt.Sub(sv.startTime) } - return elapsed + return time.Since(sv.startTime) } // SaveCurrentState saves the current status state for later restoration diff --git a/internal/presentation/tui/styles/provider.go b/internal/presentation/tui/styles/provider.go index f0dcd628c..104d34537 100644 --- a/internal/presentation/tui/styles/provider.go +++ b/internal/presentation/tui/styles/provider.go @@ -17,6 +17,8 @@ var ( boldStyle = lipgloss.NewStyle().Bold(true) reverseStyle = lipgloss.NewStyle().Reverse(true) roundedBox = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true) + railStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).PaddingLeft(1) + chipStyle = lipgloss.NewStyle().Reverse(true).Bold(true).Padding(0, 1) ) // themedStyles holds every style derived from theme colors, pre-baked once per @@ -663,6 +665,18 @@ func (p *Provider) RenderBorderedBox(text, borderColor string, paddingV, padding Render(text) } +// RenderRail renders content behind a thin colored rail on its left edge, the +// borderless frame of the prompts that wait on the user. +func (p *Provider) RenderRail(content, railColor string) string { + return railStyle.BorderForeground(lipgloss.Color(railColor)).Render(content) +} + +// RenderChip renders text as a padded pill in the given color. Reverse video +// keeps the label legible on every theme, since Theme exposes no background. +func (p *Provider) RenderChip(text, hexColor string) string { + return chipStyle.Foreground(lipgloss.Color(hexColor)).Render(text) +} + // RenderTitledCard renders content in a rounded, width-set, padded box with title // spliced into the top border (right-aligned). Reuses the same top-border splice as // RenderInputField. Pass "" for title to get a plain bordered box at the given width.