From 1094d6be8ebb219abf34d88e700c16319681ca94 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 17 Jun 2026 00:29:15 +0530 Subject: [PATCH 1/8] feat(tui): Ctrl+T cycle + divider display for reasoning effort Reasoning effort was already fully wired (per-model supported lists, DefaultReasoningEffort, the EffectiveReasoningEffort clamp, /effort and /mode), but it was invisible in the UI and reachable only by typing a command. Add the opencode-style one-key fast path: - Ctrl+T cycles the active model's effort ring (auto -> low -> medium -> high -> auto), gated by the same modal condition shift+tab uses and a silent no-op on models with no effort controls. - The composer divider shows the active effort in the brand lime when set; the segment is omitted on "auto", so its presence/absence is itself the auto-state feedback. Zero per-frame registry lookups: the cycle runs only on the rare keypress and the divider branches purely on m.reasoningEffort != "" (DefaultRegistry rebuilds the whole catalog on every call, so it must never be touched from the render path). Tests cover every cycle branch (auto->first, mid-ring advance, last->auto wrap, unknown->auto, no-op on unsupported model) and the divider show/omit paths. Co-Authored-By: Claude --- internal/tui/model.go | 10 +++++ internal/tui/rendering_lime_test.go | 22 ++++++++++ internal/tui/session_controls.go | 37 ++++++++++++++++ internal/tui/session_controls_test.go | 63 +++++++++++++++++++++++++++ internal/tui/view.go | 9 ++++ 5 files changed, 141 insertions(+) diff --git a/internal/tui/model.go b/internal/tui/model.go index 754a559f6..3423ee68b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -740,6 +740,16 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.permissionMode = nextPermissionMode(m.permissionMode) return m, nil } + case keyCtrl(msg, 't'): + // 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.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil && m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil { + return m.cycleReasoningEffort() + } case keyCtrl(msg, 'f'): if m.picker != nil && m.picker.kind == pickerModel { if m.modelPickerIsLoading() { diff --git a/internal/tui/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index a8652ce48..768295499 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" ) @@ -968,6 +969,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 diff --git a/internal/tui/session_controls.go b/internal/tui/session_controls.go index a0d50c291..aedbdc5c9 100644 --- a/internal/tui/session_controls.go +++ b/internal/tui/session_controls.go @@ -128,6 +128,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 +162,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..98cd2c23b 100644 --- a/internal/tui/session_controls_test.go +++ b/internal/tui/session_controls_test.go @@ -61,6 +61,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"}) + // minimal is a valid ReasoningEffort but not in claude-sonnet-4.5's supported + // set, so the ring can't place it — cycle falls back to auto rather than guess. + m.reasoningEffort = modelregistry.ReasoningEffortMinimal + 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..4123d6088 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 == + // ""), matching opencode hiding the variant when unset — so the segment + // appearing/disappearing is itself the auto-state feedback. No registry + // lookup here: the divider renders every frame and DefaultRegistry() + // rebuilds the catalog on every call. + meta += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render(string(m.reasoningEffort)) + } metaWidth := lipgloss.Width(meta) if width < 8 { return zeroTheme.lineStrong.Render(strings.Repeat("─", width)) From 518842c647f6707d121d5345a79336ff084d5865 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 17 Jun 2026 11:59:49 +0530 Subject: [PATCH 2/8] fix(tui): address CodeRabbit review on Ctrl+T effort cycle - Block Ctrl+T effort cycling inside the detailed transcript (matches the shift+tab gate) so state changes don't happen while the user is reading a frozen view. - Use a guaranteed-unplaceable sentinel in the unknown-effort reset test instead of ReasoningEffortMinimal, so the test stays correct if Minimal becomes a supported ring level later. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/tui/model.go | 3 +++ internal/tui/session_controls_test.go | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 3423ee68b..afb98cb4d 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -741,6 +741,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { 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 diff --git a/internal/tui/session_controls_test.go b/internal/tui/session_controls_test.go index 98cd2c23b..5e25d92f4 100644 --- a/internal/tui/session_controls_test.go +++ b/internal/tui/session_controls_test.go @@ -101,9 +101,9 @@ func TestCycleReasoningEffortWrapsToAuto(t *testing.T) { func TestCycleReasoningEffortUnknownResetsToAuto(t *testing.T) { m := newModel(context.Background(), Options{ModelName: "claude-sonnet-4.5"}) - // minimal is a valid ReasoningEffort but not in claude-sonnet-4.5's supported - // set, so the ring can't place it — cycle falls back to auto rather than guess. - m.reasoningEffort = modelregistry.ReasoningEffortMinimal + // 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) From 5f7054758e95638efd910722d07f0aed3160c9c2 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 17 Jun 2026 12:30:42 +0530 Subject: [PATCH 3/8] fix(tui): extract shared noBlockingModal() for shortcut gates The 7-term modal gate was duplicated verbatim between the shift+tab and Ctrl+T cases. Fold it into a single noBlockingModal() helper so the two shortcuts can't drift the day a new modal is added to one and not the other. Pure refactor, no behavior change. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/tui/model.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index afb98cb4d..845af5729 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,7 +745,7 @@ 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 } @@ -750,7 +759,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // 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.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil && m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil { + if m.noBlockingModal() { return m.cycleReasoningEffort() } case keyCtrl(msg, 'f'): From b379f6cba7d163b54691a60c7f4c231b63bc3c79 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 17 Jun 2026 12:52:50 +0530 Subject: [PATCH 4/8] fix(tui): hide divider effort segment on models without effort controls The divider showed whatever m.reasoningEffort was set to, even when the active model had no effort controls (e.g. gpt-4.1). Switching from claude-sonnet-4.5 (low/medium/high) to gpt-4.1 would leave a stale "high" floating in the divider even though it has no effect. Make the segment selective: only render it when the active model exposes availableReasoningEfforts, matching the same appearing/disappearing state feedback the divider already uses for the auto case. The lookup hits a hard-coded static catalog, not a per-frame rebuild, so it's safe on the render path. handleModelCommand already resets an unsupported effort preference on model switch, so no changes are needed there. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/tui/rendering_lime_test.go | 15 +++++++++++++++ internal/tui/view.go | 10 +++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/internal/tui/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index 768295499..9ede50b7b 100644 --- a/internal/tui/rendering_lime_test.go +++ b/internal/tui/rendering_lime_test.go @@ -970,7 +970,10 @@ func TestStatusLineGroups(t *testing.T) { } func TestComposerDividerShowsEffortWhenSet(t *testing.T) { + // claude-sonnet-4.5 exposes effort controls; the divider must render the + // segment when one is set. m := limeTestModel() + m.modelName = "claude-sonnet-4.5" m.reasoningEffort = modelregistry.ReasoningEffortHigh divider := plainRender(t, m.composerDividerLine(110)) if !strings.Contains(divider, "high") { @@ -978,6 +981,18 @@ func TestComposerDividerShowsEffortWhenSet(t *testing.T) { } } +func TestComposerDividerOmitsEffortForModelWithoutEffortControls(t *testing.T) { + // test-model is not in the registry (no effort controls); the divider + // must not render a stale effort segment on it, matching how the segment + // is omitted on "auto" (m.reasoningEffort == ""). + m := limeTestModel() + m.reasoningEffort = modelregistry.ReasoningEffortHigh + divider := plainRender(t, m.composerDividerLine(110)) + if strings.Contains(divider, "high") { + t.Fatalf("composer divider = %q, should omit effort segment on a model without effort controls", divider) + } +} + func TestComposerDividerOmitsEffortWhenAuto(t *testing.T) { m := limeTestModel() // reasoningEffort == "" (auto) by default — the segment is omitted entirely, diff --git a/internal/tui/view.go b/internal/tui/view.go index 4123d6088..abd4abf34 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -169,13 +169,13 @@ 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 != "" { + if m.reasoningEffort != "" && len(m.availableReasoningEfforts()) > 0 { // Show the active reasoning effort in the brand lime so a just-cycled // value (Ctrl+T) draws the eye. Omitted on "auto" (m.reasoningEffort == - // ""), matching opencode hiding the variant when unset — so the segment - // appearing/disappearing is itself the auto-state feedback. No registry - // lookup here: the divider renders every frame and DefaultRegistry() - // rebuilds the catalog on every call. + // "") and on models with no effort controls (e.g. gpt-4.1) so the + // segment appearing/disappearing is the state feedback for both. The + // available-effort lookup is over a static hard-coded catalog, not a + // per-frame rebuild, so it's safe on the render path. meta += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render(string(m.reasoningEffort)) } metaWidth := lipgloss.Width(meta) From f70aa0655541bbd0374f98bea721ee998e19ced5 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 17 Jun 2026 13:07:30 +0530 Subject: [PATCH 5/8] fix(tui): drop model-aware effort gate in divider; /effort picker already selective MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit added `&& len(m.availableReasoningEfforts()) > 0` to the divider's effort-segment gate so a stale value couldn't be shown after switching to a model without effort controls. Reverting that change: the underlying state mutation is already gated elsewhere (handleModelCommand clears an unsupported preference on switch, and the /effort picker only opens for models that actually expose effort controls), so the divider doesn't need its own check. The /effort picker (newEffortPicker, pickerEffort kind) is already fully wired and tested — /effort with no args on a supported model opens the picker; on a model with no effort controls, the text path returns "Active model does not expose reasoning effort controls." Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/tui/rendering_lime_test.go | 15 --------------- internal/tui/view.go | 10 +++++----- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/internal/tui/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index 9ede50b7b..768295499 100644 --- a/internal/tui/rendering_lime_test.go +++ b/internal/tui/rendering_lime_test.go @@ -970,10 +970,7 @@ func TestStatusLineGroups(t *testing.T) { } func TestComposerDividerShowsEffortWhenSet(t *testing.T) { - // claude-sonnet-4.5 exposes effort controls; the divider must render the - // segment when one is set. m := limeTestModel() - m.modelName = "claude-sonnet-4.5" m.reasoningEffort = modelregistry.ReasoningEffortHigh divider := plainRender(t, m.composerDividerLine(110)) if !strings.Contains(divider, "high") { @@ -981,18 +978,6 @@ func TestComposerDividerShowsEffortWhenSet(t *testing.T) { } } -func TestComposerDividerOmitsEffortForModelWithoutEffortControls(t *testing.T) { - // test-model is not in the registry (no effort controls); the divider - // must not render a stale effort segment on it, matching how the segment - // is omitted on "auto" (m.reasoningEffort == ""). - m := limeTestModel() - m.reasoningEffort = modelregistry.ReasoningEffortHigh - divider := plainRender(t, m.composerDividerLine(110)) - if strings.Contains(divider, "high") { - t.Fatalf("composer divider = %q, should omit effort segment on a model without effort controls", divider) - } -} - func TestComposerDividerOmitsEffortWhenAuto(t *testing.T) { m := limeTestModel() // reasoningEffort == "" (auto) by default — the segment is omitted entirely, diff --git a/internal/tui/view.go b/internal/tui/view.go index abd4abf34..df29d035b 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -169,13 +169,13 @@ 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 != "" && len(m.availableReasoningEfforts()) > 0 { + 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 == - // "") and on models with no effort controls (e.g. gpt-4.1) so the - // segment appearing/disappearing is the state feedback for both. The - // available-effort lookup is over a static hard-coded catalog, not a - // per-frame rebuild, so it's safe on the render path. + // "") 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) From 9fcc8a11a3ed5dc707e0c4c9672de33a60ef7f6c Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 17 Jun 2026 13:50:32 +0530 Subject: [PATCH 6/8] fix(tui): open effort picker even when active model has no effort controls Previously newEffortPicker returned nil for any model not in the hard-coded catalog (e.g. ollama-cloud providers serving glm-5.1 or similar). The case commandEffort dispatch fell through to handleEffortCommand, which rendered a grey "Effort / status: warning / available: none for active model" status card -- the very static panel users were reporting. Now newEffortPicker always returns a picker: a single "auto" option when the active model exposes no effort controls, or "auto" plus the model's known efforts otherwise. handleEffortCommand already returns the "Active model does not expose reasoning effort controls" message if any non-auto value is later set, so no other gate is needed. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/tui/picker.go | 13 ++++++----- internal/tui/picker_test.go | 44 +++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) 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) + } +} From c3af19729a16fd85d90ffdf9c1567478ab36ae6a Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 17 Jun 2026 14:07:37 +0530 Subject: [PATCH 7/8] feat(tui): render description hint below composer for single slash match Claude-code-style: when the user types a slash command that resolves to exactly one command in the autocomplete palette, surface that command's description on a muted line just below the composer box. Multiple matches keep the existing dropdown as the right affordance; once the user starts typing arguments the hint clears; the @file palette keeps its own rows and the hint stays scoped to slash commands. The inline argument hint ([low|medium|high|auto]) still renders inside the composer box after the slash, so both UIs coexist. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/tui/model.go | 35 +++++++++++++++++++ internal/tui/rendering_lime_test.go | 52 +++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/internal/tui/model.go b/internal/tui/model.go index 845af5729..f23124bab 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1348,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) @@ -1939,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/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index 768295499..493524a39 100644 --- a/internal/tui/rendering_lime_test.go +++ b/internal/tui/rendering_lime_test.go @@ -1336,3 +1336,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) + } +} From 9f0543975a425d833ff50915ecf7578884564bbb Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 17 Jun 2026 14:22:05 +0530 Subject: [PATCH 8/8] feat(tui): render /effort output as a polished command card The /effort command used to push its status text through renderCommandOutput, which renders as a thin grey panel (faint text on panel background, neutral border). Replace that path with renderCommandCardTranscript so the result renders the same way as /tools, /mcp, /permissions, etc.: a lime-bordered titled card with a key/value "State" section and a footer of next-step actions. Three call sites updated: - handleEffortCommand("auto") -> sets effort to auto, shows confirmation card - handleEffortCommand() -> sets effort, shows confirmation card - handleEffortCommand() -> unknown / unsupported / not exposed -> shows the same card with the relevant detail line effortText() (the no-arg / list / picker-dismiss path) follows the same pattern. The "status: warning" line and grey panel are gone; the warning case instead keeps the lime accent and surfaces "no reasoning controls on this model" in the summary, matching the rest of the TUI's visual language. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/tui/rendering_lime_test.go | 59 ++++++++++++++++++++++ internal/tui/session_controls.go | 73 +++++++++++++++++---------- internal/tui/session_controls_test.go | 20 ++++++-- 3 files changed, 122 insertions(+), 30 deletions(-) diff --git a/internal/tui/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index 493524a39..1b3301dc1 100644 --- a/internal/tui/rendering_lime_test.go +++ b/internal/tui/rendering_lime_test.go @@ -108,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{ diff --git a/internal/tui/session_controls.go b/internal/tui/session_controls.go index aedbdc5c9..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, }) } diff --git a/internal/tui/session_controls_test.go b/internal/tui/session_controls_test.go index 5e25d92f4..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) } }