diff --git a/internal/tui/model.go b/internal/tui/model.go index 754a559f6..f23124bab 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -512,6 +512,15 @@ func (m *model) stopPRWatcher() { m.prWatcherStop = nil } +// noBlockingModal reports that no modal surface (permission prompt, ask_user, +// spec review, provider/MCP wizard, MCP manager, or picker) is up, so a global +// shortcut may act instead of falling through to a modal's own handler. Shared +// by every shortcut that should defer to whichever modal is focused. +func (m model) noBlockingModal() bool { + return m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil && + m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil +} + func (m model) quit() (tea.Model, tea.Cmd) { m.stopPRWatcher() return m, tea.Quit @@ -736,10 +745,23 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // nextPermissionMode), but only when nothing modal is up: a permission // prompt, ask_user questionnaire, or open picker all take precedence // and let the key fall through to their own handlers below. - if m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil && m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil { + if m.noBlockingModal() { m.permissionMode = nextPermissionMode(m.permissionMode) return m, nil } + case keyCtrl(msg, 't'): + if m.transcriptDetailed { + return m, nil + } + // Ctrl+T cycles reasoning effort opencode-style (auto -> low -> + // medium -> high -> auto), but only when nothing modal is up — the + // same gate shift+tab uses above. Not gated on m.pending: cycling + // mid-run is allowed and takes effect on the next turn, matching + // /effort. cycleReasoningEffort is a silent no-op on models with no + // effort controls. + if m.noBlockingModal() { + return m.cycleReasoningEffort() + } case keyCtrl(msg, 'f'): if m.picker != nil && m.picker.kind == pickerModel { if m.modelPickerIsLoading() { @@ -1326,6 +1348,10 @@ func (m model) footerView(width int) string { footer.WriteString("\n") } footer.WriteString(m.composerBox(width)) + if hint := m.composerDescriptionHint(width); hint != "" { + footer.WriteString("\n") + footer.WriteString(hint) + } if queued := renderQueuedMessagePreview(m.queuedMessage, width); queued != "" { footer.WriteString("\n") footer.WriteString(queued) @@ -1917,6 +1943,37 @@ func (m model) composerBox(width int) string { return strings.Join(rendered, "\n") } +// composerDescriptionHint returns the description line that sits below the +// composer box, claude-code style, when the input is a single unambiguous +// slash command. Returns "" when the user is mid-prompt, the palette is closed, +// or more than one command matches. Slash commands only; the @file palette +// already shows its rows. The inline argument hint ([low|medium|...]) is +// unchanged and continues to render inside the composer box. +func (m model) composerDescriptionHint(width int) string { + if width < 8 { + return "" + } + if m.suggestionsAreFiles { + return "" + } + if !m.commandPaletteOpen || len(m.suggestions) != 1 { + return "" + } + if m.suggestionIdx != 0 { + return "" + } + value := strings.TrimSpace(m.input.Value()) + if !strings.HasPrefix(value, "/") || strings.ContainsAny(value, " \t\n") { + return "" + } + suggestion := m.suggestions[0] + desc := strings.TrimSpace(suggestion.Desc) + if desc == "" { + return "" + } + return fitStyledLine(zeroTheme.muted.Render(desc), width) +} + // startsTurn reports whether a row begins a new conversational turn and therefore // gets a blank line of separation above it (tool rows stay grouped together). func startsTurn(kind rowKind) bool { diff --git a/internal/tui/picker.go b/internal/tui/picker.go index f2350a269..f69c4af2d 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -568,15 +568,18 @@ func (m *model) selectPickerValue(value string) { } // newEffortPicker lists the reasoning efforts the active model supports plus an -// "auto" option, preselecting the current preference. Returns nil when the model -// exposes no effort controls so the caller falls back to status text. +// "auto" option, preselecting the current preference. When the model exposes no +// effort controls, still returns a single "auto" picker so the user gets the +// popup affordance on /effort instead of a static status card; handleEffortCommand +// reports "Active model does not expose reasoning effort controls" if they pick +// anything other than auto. func (m model) newEffortPicker() *commandPicker { efforts := m.availableReasoningEfforts() - if len(efforts) == 0 { - return nil - } items := []pickerItem{{Label: "auto", Value: "auto"}} selected := 0 + if m.reasoningEffort == "" { + selected = 0 + } for _, effort := range efforts { items = append(items, pickerItem{Label: string(effort), Value: string(effort)}) if m.reasoningEffort != "" && effort == m.reasoningEffort { diff --git a/internal/tui/picker_test.go b/internal/tui/picker_test.go index c39b3f58c..eea9e7a38 100644 --- a/internal/tui/picker_test.go +++ b/internal/tui/picker_test.go @@ -13,6 +13,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/modelregistry" "github.com/Gitlawb/zero/internal/providermodeldiscovery" "github.com/Gitlawb/zero/internal/zeroruntime" ) @@ -676,3 +677,46 @@ func readTUIConfigFixture(t *testing.T, path string) config.FileConfig { } return cfg } + +func TestEffortPickerOpensForModelWithoutEffortControls(t *testing.T) { + // glm-5.1 is not in the hard-coded registry, so availableReasoningEfforts is + // empty. /effort should still open a picker (offering auto only) instead of + // rendering a static "Effort / available: none for active model" status card. + m := newModel(context.Background(), Options{ModelName: "glm-5.1"}) + m.input.SetValue("/effort") + + updated, _ := m.Update(testKey(tea.KeyEnter)) + m = updated.(model) + if m.picker == nil || m.picker.kind != pickerEffort { + t.Fatalf("expected an open effort picker, got %#v", m.picker) + } + if len(m.picker.items) != 1 || m.picker.items[0].Value != "auto" { + t.Fatalf("expected [auto] as the only effort option on an unsupported model, got %#v", m.picker.items) + } + if m.picker.title != "select reasoning effort" { + t.Fatalf("picker title = %q, want %q", m.picker.title, "select reasoning effort") + } +} + +func TestEffortPickerAutoSelectionKeepsEffortUnset(t *testing.T) { + // Picking "auto" on a model without effort controls clears any stale + // preference and emits the success status text (handleEffortCommand("auto")). + m := newModel(context.Background(), Options{ModelName: "glm-5.1"}) + m.reasoningEffort = modelregistry.ReasoningEffortHigh + m.input.SetValue("/effort") + + updated, _ := m.Update(testKey(tea.KeyEnter)) + m = updated.(model) + if m.picker == nil { + t.Fatal("expected the effort picker to open") + } + + updated, _ = m.Update(testKey(tea.KeyEnter)) + m = updated.(model) + if m.picker != nil { + t.Fatal("enter should close the picker") + } + if m.reasoningEffort != "" { + t.Fatalf("auto selection should clear reasoning effort, got %q", m.reasoningEffort) + } +} diff --git a/internal/tui/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index a8652ce48..1b3301dc1 100644 --- a/internal/tui/rendering_lime_test.go +++ b/internal/tui/rendering_lime_test.go @@ -12,6 +12,7 @@ import ( "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/modelregistry" "github.com/Gitlawb/zero/internal/sandbox" "github.com/Gitlawb/zero/internal/tools" ) @@ -107,6 +108,65 @@ func TestCommandCardRowRendersAsTitledCard(t *testing.T) { } } +func TestEffortCommandCardRendersAsTitledCard(t *testing.T) { + m := limeTestModel() + row := transcriptRow{ + kind: rowSystem, + text: renderCommandCardTranscript(commandCard{ + Title: "Effort", + Summary: []string{"active effort: auto", "3 supported level(s)"}, + Sections: []commandCardSection{{ + Title: "State", + Fields: []commandField{ + {Key: "active effort", Value: "auto"}, + {Key: "model", Value: "claude-sonnet-4.5"}, + {Key: "available", Value: "low, medium, high"}, + }, + }}, + Actions: []string{"use /effort to switch", "/effort auto to clear"}, + }), + } + + got := plainRender(t, m.renderRow(row, 80, buildRowContext(nil))) + if !strings.Contains(got, "Effort") { + t.Fatalf("effort card should render title in the border, got:\n%s", got) + } + for _, want := range []string{ + "State", + "active effort", + "auto", + "claude-sonnet-4.5", + "low, medium, high", + "actions: use /effort to switch | /effort auto to clear", + } { + if !strings.Contains(got, want) { + t.Fatalf("effort card missing %q in:\n%s", want, got) + } + } + // Status card for unsupported models should still surface the no-controls + // hint rather than falling back to the grey commandOutput panel. + row.text = renderCommandCardTranscript(commandCard{ + Title: "Effort", + Summary: []string{"active effort: auto", "no reasoning controls on this model"}, + Sections: []commandCardSection{{ + Title: "State", + Fields: []commandField{ + {Key: "active effort", Value: "auto"}, + {Key: "model", Value: "glm-5.1"}, + {Key: "available", Value: "none for active model"}, + }, + }}, + Actions: []string{"use /effort to switch", "/effort auto to clear"}, + }) + got = plainRender(t, m.renderRow(row, 80, buildRowContext(nil))) + if !strings.Contains(got, "none for active model") { + t.Fatalf("effort unsupported card should render no-controls row, got:\n%s", got) + } + if strings.Contains(got, "status: warning") { + t.Fatalf("effort card should not carry commandOutput status text, got:\n%s", got) + } +} + func TestCommandCardRowTrimsIndentedActionsLabel(t *testing.T) { m := limeTestModel() row := transcriptRow{ @@ -968,6 +1028,27 @@ func TestStatusLineGroups(t *testing.T) { } } +func TestComposerDividerShowsEffortWhenSet(t *testing.T) { + m := limeTestModel() + m.reasoningEffort = modelregistry.ReasoningEffortHigh + divider := plainRender(t, m.composerDividerLine(110)) + if !strings.Contains(divider, "high") { + t.Fatalf("composer divider = %q, missing effort segment when set", divider) + } +} + +func TestComposerDividerOmitsEffortWhenAuto(t *testing.T) { + m := limeTestModel() + // reasoningEffort == "" (auto) by default — the segment is omitted entirely, + // matching opencode hiding the variant when unset. + divider := plainRender(t, m.composerDividerLine(110)) + for _, effort := range []string{"low", "medium", "high", "minimal", "xhigh", "none"} { + if strings.Contains(divider, effort) { + t.Fatalf("composer divider = %q, should omit effort segment when auto", divider) + } + } +} + func TestTitleBarShowsWorkspaceAndModel(t *testing.T) { m := limeTestModel() m.width = 120 @@ -1314,3 +1395,55 @@ func TestSessionsCardFieldsAreSanitized(t *testing.T) { t.Fatalf("sanitizeCardField left separator bytes: %q", got) } } + +func TestComposerDescriptionHintRendersForSingleSlashMatch(t *testing.T) { + // When the user has typed a slash command that matches exactly one entry in + // the command palette, the composer hint line should surface that command's + // description below the box, claude-code style. + m := limeTestModel() + m.input.SetValue("/effort") + m.recomputeSuggestions() + if !m.commandPaletteOpen || len(m.suggestions) != 1 || m.suggestions[0].Name != "/effort" { + t.Fatalf("setup: expected a single /effort suggestion, got palette=%v matches=%#v", m.commandPaletteOpen, m.suggestions) + } + got := plainRender(t, m.composerDescriptionHint(96)) + if !strings.Contains(got, "reasoning effort") { + t.Fatalf("description hint = %q, want it to mention reasoning effort", got) + } +} + +func TestComposerDescriptionHintStaysEmptyForAmbiguousPrefix(t *testing.T) { + // A prefix that still matches multiple commands should not surface a hint -- + // the dropdown is the right affordance for an ambiguous match. + m := limeTestModel() + m.input.SetValue("/") + m.recomputeSuggestions() + if !m.commandPaletteOpen || len(m.suggestions) < 2 { + t.Fatalf("setup: expected multiple suggestions for bare '/', got palette=%v matches=%d", m.commandPaletteOpen, len(m.suggestions)) + } + if got := m.composerDescriptionHint(96); got != "" { + t.Fatalf("description hint should be empty for ambiguous matches, got %q", got) + } +} + +func TestComposerDescriptionHintStaysEmptyAfterArgs(t *testing.T) { + // Once the user starts typing arguments, the palette narrows off and we + // shouldn't keep advertising the command's description. + m := limeTestModel() + m.input.SetValue("/effort high") + m.recomputeSuggestions() + if got := m.composerDescriptionHint(96); got != "" { + t.Fatalf("description hint should be empty after args, got %q", got) + } +} + +func TestComposerDescriptionHintStaysEmptyForFilePalette(t *testing.T) { + // The @file palette already renders its rows; the description hint is + // scoped to slash commands. + m := limeTestModel() + m.input.SetValue("@") + m.recomputeSuggestions() + if got := m.composerDescriptionHint(96); got != "" { + t.Fatalf("description hint should be empty for file palette, got %q", got) + } +} diff --git a/internal/tui/session_controls.go b/internal/tui/session_controls.go index a0d50c291..4c8dffd24 100644 --- a/internal/tui/session_controls.go +++ b/internal/tui/session_controls.go @@ -59,54 +59,73 @@ func (m model) handleEffortCommand(args string) (model, string) { } if args == "auto" { m.reasoningEffort = "" - return m, strings.Join([]string{ - "Effort", - "active effort: auto", - "Reasoning effort selection will follow the active model/provider defaults.", - }, "\n") + return m, m.effortStatusCard("auto", "Reasoning effort selection will follow the active model/provider defaults.") } requested := modelregistry.ReasoningEffort(args) if !modelregistry.ValidReasoningEffort(requested) { - return m, "Effort\nUnknown reasoning effort: " + args + return m, m.effortStatusCard(args, "Unknown reasoning effort: "+args) } efforts := m.availableReasoningEfforts() if len(efforts) == 0 { - return m, "Effort\nActive model does not expose reasoning effort controls." + return m, m.effortStatusCard("", "Active model does not expose reasoning effort controls.") } if !reasoningEffortAllowed(efforts, requested) { - return m, fmt.Sprintf("Effort\nReasoning effort %q is not supported by %s.", requested, displayValue(m.modelName, "the active model")) + return m, m.effortStatusCard(string(requested), + fmt.Sprintf("Reasoning effort %q is not supported by %s.", requested, displayValue(m.modelName, "the active model"))) } m.reasoningEffort = requested - return m, strings.Join([]string{ - "Effort", - "active effort: " + string(requested), - "model: " + displayValue(m.modelName, "none"), - "Reasoning effort preference is stored for this TUI session.", - }, "\n") + return m, m.effortStatusCard(string(requested), "Reasoning effort preference is stored for this TUI session.") +} + +// effortStatusCard renders the small inline confirmation card shown after a +// /effort mutation (set, auto, unknown, unsupported). The body is a +// lime-bordered card so the transition from "picker open" -> "card collapsed" +// reads as the same surface the picker came from, instead of a separate grey +// status block. +func (m model) effortStatusCard(value string, detail string) string { + active := strings.TrimSpace(value) + if active == "" { + active = "auto" + } + fields := []commandField{ + {Key: "active effort", Value: active}, + {Key: "model", Value: displayValue(m.modelName, "none")}, + } + return renderCommandCardTranscript(commandCard{ + Title: "Effort", + Summary: []string{"active effort: " + active}, + Sections: []commandCardSection{{ + Title: "State", + Fields: fields, + Lines: []string{detail}, + }}, + }) } func (m model) effortText() string { - lines := []string{ - "active effort: " + m.effortDisplay(), - "model: " + displayValue(m.modelName, "none"), - } efforts := m.availableReasoningEfforts() + fields := []commandField{ + {Key: "active effort", Value: m.effortDisplay()}, + {Key: "model", Value: displayValue(m.modelName, "none")}, + } + actions := []string{"use /effort to switch", "/effort auto to clear"} if len(efforts) == 0 { - lines = append(lines, "available: none for active model") - return renderCommandOutput(commandOutput{ + fields = append(fields, commandField{Key: "available", Value: "none for active model"}) + return renderCommandCardTranscript(commandCard{ Title: "Effort", - Status: commandStatusWarning, - Sections: []commandSection{{Title: "State", Lines: lines}}, + Summary: []string{"active effort: " + m.effortDisplay(), "no reasoning controls on this model"}, + Sections: []commandCardSection{{Title: "State", Fields: fields}}, + Actions: actions, }) } - lines = append(lines, "available: "+joinReasoningEfforts(efforts)) - return renderCommandOutput(commandOutput{ + fields = append(fields, commandField{Key: "available", Value: joinReasoningEfforts(efforts)}) + return renderCommandCardTranscript(commandCard{ Title: "Effort", - Status: commandStatusOK, - Sections: []commandSection{{Title: "State", Lines: lines}}, - Hints: []string{"use /effort or /effort auto"}, + Summary: []string{"active effort: " + m.effortDisplay(), fmt.Sprintf("%d supported level(s)", len(efforts))}, + Sections: []commandCardSection{{Title: "State", Fields: fields}}, + Actions: actions, }) } @@ -128,6 +147,31 @@ func (m model) effortDisplay() string { return string(m.reasoningEffort) } +// cycleReasoningEffort advances the reasoning-effort ring opencode-style: +// auto ("") -> first supported -> ... -> last supported -> auto. No-op (model +// unchanged) when the active model exposes no effort controls, so Ctrl+T stays +// quiet on non-reasoning models. Called from the Ctrl+T key case — a rare +// keypress — so the DefaultRegistry() lookup inside availableReasoningEfforts +// is fine here, but MUST NOT be called from the render path (the registry is +// rebuilt on every call). +func (m model) cycleReasoningEffort() (model, tea.Cmd) { + efforts := m.availableReasoningEfforts() + if len(efforts) == 0 { + return m, nil + } + if m.reasoningEffort == "" { + m.reasoningEffort = efforts[0] + return m, nil + } + idx := reasoningEffortIndex(efforts, m.reasoningEffort) + if idx == -1 || idx == len(efforts)-1 { + m.reasoningEffort = "" // wrap to auto + return m, nil + } + m.reasoningEffort = efforts[idx+1] + return m, nil +} + func reasoningEffortAllowed(efforts []modelregistry.ReasoningEffort, want modelregistry.ReasoningEffort) bool { for _, effort := range efforts { if effort == want { @@ -137,6 +181,18 @@ func reasoningEffortAllowed(efforts []modelregistry.ReasoningEffort, want modelr return false } +// reasoningEffortIndex returns the position of want in efforts, or -1. Sibling +// to reasoningEffortAllowed; used by cycleReasoningEffort to find the current +// slot in the model's supported ring. +func reasoningEffortIndex(efforts []modelregistry.ReasoningEffort, want modelregistry.ReasoningEffort) int { + for index, effort := range efforts { + if effort == want { + return index + } + } + return -1 +} + func joinReasoningEfforts(efforts []modelregistry.ReasoningEffort) string { values := make([]string, 0, len(efforts)) for _, effort := range efforts { diff --git a/internal/tui/session_controls_test.go b/internal/tui/session_controls_test.go index d06c38d1e..1632793c6 100644 --- a/internal/tui/session_controls_test.go +++ b/internal/tui/session_controls_test.go @@ -25,9 +25,23 @@ func TestEffortCommandListsAndSetsSupportedEffort(t *testing.T) { if cmd != nil { t.Fatal("expected /effort list to be handled without starting an agent run") } - for _, want := range []string{"Effort", "active effort: auto", "available: low, medium, high"} { - if !transcriptContains(next.transcript, want) { - t.Fatalf("expected effort transcript to contain %q, got %#v", want, next.transcript) + // The output is now a command card payload, so the effort list and the + // active effort appear inside the same row's text rather than as separate + // transcript rows. Strip the card prefix and assert against the rendered + // payload. + var cardPayload string + for _, row := range next.transcript { + if strings.HasPrefix(row.text, "\x00command-card\x00") { + cardPayload = strings.TrimPrefix(row.text, "\x00command-card\x00") + break + } + } + if cardPayload == "" { + t.Fatalf("expected an effort command card row, got %#v", next.transcript) + } + for _, want := range []string{"Effort", "active effort: auto", "available", "low, medium, high"} { + if !strings.Contains(cardPayload, want) { + t.Fatalf("expected card to contain %q, got %q", want, cardPayload) } } @@ -61,6 +75,69 @@ func TestEffortCommandRejectsUnsupportedActiveModel(t *testing.T) { } } +// The Ctrl+T cycle walks the active model's supported ring opencode-style: +// auto ("") -> first supported -> ... -> last supported -> auto. These cover +// every branch of cycleReasoningEffort: empty/auto start, mid-ring advance, +// last-slot wrap, an effort the model doesn't support, and a model with no +// effort controls at all. + +func TestCycleReasoningEffortAutoToFirst(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "claude-sonnet-4.5"}) + if m.reasoningEffort != "" { + t.Fatalf("expected default effort auto, got %q", m.reasoningEffort) + } + next, cmd := m.cycleReasoningEffort() + if cmd != nil { + t.Fatal("expected cycle to produce no command") + } + if next.reasoningEffort != modelregistry.ReasoningEffortLow { + t.Fatalf("expected cycle from auto to land on first supported effort (low), got %q", next.reasoningEffort) + } +} + +func TestCycleReasoningEffortAdvancesToNext(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "claude-sonnet-4.5"}) + m.reasoningEffort = modelregistry.ReasoningEffortLow + next, _ := m.cycleReasoningEffort() + if next.reasoningEffort != modelregistry.ReasoningEffortMedium { + t.Fatalf("expected cycle low -> medium, got %q", next.reasoningEffort) + } +} + +func TestCycleReasoningEffortWrapsToAuto(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "claude-sonnet-4.5"}) + m.reasoningEffort = modelregistry.ReasoningEffortHigh + next, _ := m.cycleReasoningEffort() + if next.reasoningEffort != "" { + t.Fatalf("expected cycle from last supported (high) to wrap to auto, got %q", next.reasoningEffort) + } +} + +func TestCycleReasoningEffortUnknownResetsToAuto(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "claude-sonnet-4.5"}) + // Use a sentinel that is guaranteed to be unplaceable in any supported ring, + // so this test stays correct even if Minimal gets supported later. + m.reasoningEffort = modelregistry.ReasoningEffort("__unknown_effort__") + next, _ := m.cycleReasoningEffort() + if next.reasoningEffort != "" { + t.Fatalf("expected unknown effort to reset to auto, got %q", next.reasoningEffort) + } +} + +func TestCycleReasoningEffortNoOpOnUnsupportedModel(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4.1"}) + // gpt-4.1 exposes no effort controls; set a value directly (the /effort command + // would reject this) to prove the cycle is a true no-op and leaves it untouched. + m.reasoningEffort = modelregistry.ReasoningEffortHigh + next, cmd := m.cycleReasoningEffort() + if cmd != nil { + t.Fatal("expected cycle on unsupported model to produce no command") + } + if next.reasoningEffort != modelregistry.ReasoningEffortHigh { + t.Fatalf("expected cycle to be a no-op on a model without effort controls, got %q", next.reasoningEffort) + } +} + func TestStyleCommandListsAndSetsSessionPreference(t *testing.T) { m := newModel(context.Background(), Options{}) m.input.SetValue("/style") diff --git a/internal/tui/view.go b/internal/tui/view.go index 9e69c9760..df29d035b 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -169,6 +169,15 @@ func (m model) composerDividerLine(width int) string { model := displayValue(strings.TrimSpace(m.modelName), "no model") label, style := m.modeLabel() meta := zeroTheme.muted.Render(model) + zeroTheme.muted.Render(" · ") + style.Render(label) + if m.reasoningEffort != "" { + // Show the active reasoning effort in the brand lime so a just-cycled + // value (Ctrl+T) draws the eye. Omitted on "auto" (m.reasoningEffort == + // "") so the segment appearing/disappearing is itself the auto-state + // feedback. Models without effort controls can't set a non-empty value + // here (handleModelCommand and the /effort picker both gate on + // availableReasoningEfforts), so no extra check is needed. + meta += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render(string(m.reasoningEffort)) + } metaWidth := lipgloss.Width(meta) if width < 8 { return zeroTheme.lineStrong.Render(strings.Repeat("─", width))