Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 10 additions & 11 deletions docs/plan-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions internal/presentation/tui/approvalcoord/coordinator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -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,
}
Expand Down
113 changes: 39 additions & 74 deletions internal/presentation/tui/components/conversation_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1336,117 +1336,82 @@ 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 {
selectedIndex = planState.SelectedIndex
}
}

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
Expand Down
14 changes: 14 additions & 0 deletions internal/presentation/tui/components/conversation_view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
21 changes: 21 additions & 0 deletions internal/presentation/tui/components/huh_theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
2 changes: 1 addition & 1 deletion internal/presentation/tui/components/input_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
42 changes: 27 additions & 15 deletions internal/presentation/tui/components/question_form_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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")
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading