Skip to content
Merged
59 changes: 58 additions & 1 deletion internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
case keyCtrl(msg, 'f'):
if m.picker != nil && m.picker.kind == pickerModel {
if m.modelPickerIsLoading() {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 ""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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 {
Expand Down
13 changes: 8 additions & 5 deletions internal/tui/picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions internal/tui/picker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
}
133 changes: 133 additions & 0 deletions internal/tui/rendering_lime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 <value> 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 <value> 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 <value> 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{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Loading
Loading