From 43af9acf6487555e5a91030ab5eb6b7c1730e7c1 Mon Sep 17 00:00:00 2001 From: Eden Reich Date: Mon, 5 Oct 2026 00:43:10 +0200 Subject: [PATCH 1/2] feat(tui): restyle the question and plan approval prompts with a quiet rail AskUserQuestion drops its rounded box for a thin accent rail, a header chip with an N of M counter, a bold question and dim option descriptions on their own line. Plan approval gets the same rail in its status color, a Plan chip and a solid pill for the selected action with the working keys underneath. The status lines no longer repeat the key hints, and the plan approval placeholder stops advertising keys that are not reachable. --- docs/plan-mode.md | 21 ++-- .../tui/approvalcoord/coordinator.go | 4 +- .../tui/components/conversation_view.go | 113 ++++++------------ .../tui/components/conversation_view_test.go | 14 +++ .../presentation/tui/components/huh_theme.go | 21 ++++ .../presentation/tui/components/input_view.go | 2 +- .../tui/components/question_form_view.go | 42 ++++--- .../tui/components/question_form_view_test.go | 18 ++- internal/presentation/tui/styles/provider.go | 14 +++ 9 files changed, 145 insertions(+), 104 deletions(-) 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/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. From d3f2c9122e757491f24250ee2e3cf0bee2ec5daf Mon Sep 17 00:00:00 2001 From: Eden Reich Date: Mon, 5 Oct 2026 00:54:45 +0200 Subject: [PATCH 2/2] fix(tui): compute the paused turn duration from one clock reading A turn that ends while paused on a user decision subtracted two separate time.Since calls, so the summary could land just under the true duration and made TestStatusViewTurnDurationSummary flaky. --- internal/presentation/tui/components/status_view.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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