feat(models): adjust reasoning from model picker (#108)
* feat(models): adjust reasoning from model picker * fix(models): preserve picker filtering shortcuts * fix(models): isolate hidden picker controls
- commit
- 7028e8ced8974a32ae6bcb06dd476b8c3ccecd87
- author
- bnema · · 2026-08-11 03:52 UTC
- committer
- GitHub · · 2026-08-11 03:52 UTC
- parent
- b6d6f7a
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
5 files · +251 -9
M packages/agent/modes/interactive.go +3 -0
| @@ -2957,6 +2957,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2957 | 2957 | if act.Close { | |
| 2958 | 2958 | i.quickModelAssign = 0 | |
| 2959 | 2959 | } | |
| 2960 | + | if act.ReasoningChanged { | |
| 2961 | + | i.applyReasoningSetting(act.Reasoning) | |
| 2962 | + | } | |
| 2960 | 2963 | if act.Select { | |
| 2961 | 2964 | if i.quickModelAssign > 0 { | |
| 2962 | 2965 | i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model) | |
| @@ -2957,6 +2957,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2957 | if act.Close { | 2957 | if act.Close { | ||
| 2958 | i.quickModelAssign = 0 | 2958 | i.quickModelAssign = 0 | ||
| 2959 | } | 2959 | } | ||
| 2960 | + | if act.ReasoningChanged { | |||
| 2961 | + | i.applyReasoningSetting(act.Reasoning) | |||
| 2962 | + | } | |||
| 2960 | if act.Select { | 2963 | if act.Select { | ||
| 2961 | if i.quickModelAssign > 0 { | 2964 | if i.quickModelAssign > 0 { | ||
| 2962 | i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model) | 2965 | i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model) | ||
M packages/agent/modes/interactive_model_test.go +6 -3
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/zut/packages/provider" | |
| 9 | 10 | "github.com/bnema/zut/packages/tui" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| @@ -81,13 +82,15 @@ func TestReasoningCommandOpensDirectSelector(t *testing.T) { | |||
| 81 | 82 | } | |
| 82 | 83 | } | |
| 83 | 84 | ||
| 84 | - | func TestModelDialogAdvertisesReasoningSelector(t *testing.T) { | |
| 85 | + | func TestModelDialogAdvertisesReasoningNavigation(t *testing.T) { | |
| 85 | 86 | d := newModelDialog() | |
| 86 | 87 | d.Open("", nil, "high") | |
| 88 | + | d.view = []provider.Model{{Provider: "openai-codex", ID: "gpt-5.6-luna", Reasoning: true}} | |
| 87 | 89 | ||
| 88 | 90 | text := strings.Join(d.Render(tui.Dark, 100), "\n") | |
| 89 | - | if !strings.Contains(text, "current reasoning: high") || !strings.Contains(text, "/reasoning") { | |
| 90 | - | t.Fatalf("model dialog missing reasoning guidance: %q", text) | |
| 91 | + | plain := stripANSIBytes(text) | |
| 92 | + | if !strings.Contains(plain, "reasoning: high") || !strings.Contains(plain, "←/→ or H/L to change") { | |
| 93 | + | t.Fatalf("model dialog missing reasoning guidance: %q", plain) | |
| 91 | 94 | } | |
| 92 | 95 | } | |
| 93 | 96 | ||
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | + | "github.com/bnema/zut/packages/provider" | |||
| 9 | "github.com/bnema/zut/packages/tui" | 10 | "github.com/bnema/zut/packages/tui" | ||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| @@ -81,13 +82,15 @@ func TestReasoningCommandOpensDirectSelector(t *testing.T) { | |||||
| 81 | } | 82 | } | ||
| 82 | } | 83 | } | ||
| 83 | 84 | ||||
| 84 | - | func TestModelDialogAdvertisesReasoningSelector(t *testing.T) { | 85 | + | func TestModelDialogAdvertisesReasoningNavigation(t *testing.T) { |
| 85 | d := newModelDialog() | 86 | d := newModelDialog() | ||
| 86 | d.Open("", nil, "high") | 87 | d.Open("", nil, "high") | ||
| 88 | + | d.view = []provider.Model{{Provider: "openai-codex", ID: "gpt-5.6-luna", Reasoning: true}} | |||
| 87 | 89 | ||||
| 88 | text := strings.Join(d.Render(tui.Dark, 100), "\n") | 90 | text := strings.Join(d.Render(tui.Dark, 100), "\n") | ||
| 89 | - | if !strings.Contains(text, "current reasoning: high") || !strings.Contains(text, "/reasoning") { | 91 | + | plain := stripANSIBytes(text) |
| 90 | - | t.Fatalf("model dialog missing reasoning guidance: %q", text) | 92 | + | if !strings.Contains(plain, "reasoning: high") || !strings.Contains(plain, "←/→ or H/L to change") { |
| 93 | + | t.Fatalf("model dialog missing reasoning guidance: %q", plain) | |||
| 91 | } | 94 | } | ||
| 92 | } | 95 | } | ||
| 93 | 96 | ||||
M packages/agent/modes/model_dialog.go +82 -6
| @@ -36,10 +36,12 @@ type modelDialog struct { | |||
| 36 | 36 | ||
| 37 | 37 | // modelDialogAction is returned by HandleKey. | |
| 38 | 38 | type modelDialogAction struct { | |
| 39 | - | Select bool | |
| 40 | - | Provider string | |
| 41 | - | Model string | |
| 42 | - | Close bool | |
| 39 | + | Select bool | |
| 40 | + | Provider string | |
| 41 | + | Model string | |
| 42 | + | ReasoningChanged bool | |
| 43 | + | Reasoning string | |
| 44 | + | Close bool | |
| 43 | 45 | } | |
| 44 | 46 | ||
| 45 | 47 | func newModelDialog() *modelDialog { | |
| @@ -155,11 +157,17 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string { | |||
| 155 | 157 | } | |
| 156 | 158 | lines = append(lines, th.FGColor(th.Muted, hint)) | |
| 157 | 159 | if d.showReasoning { | |
| 158 | - | reasoning := d.reasoning | |
| 160 | + | reasoning, adjustable := d.cursorReasoning() | |
| 159 | 161 | if reasoning == "" { | |
| 160 | 162 | reasoning = "off" | |
| 161 | 163 | } | |
| 162 | - | lines = append(lines, th.FGColor(th.Muted, "✦ supports reasoning; current reasoning: "+reasoning+"; change with /reasoning")) | |
| 164 | + | help := " unavailable for highlighted model" | |
| 165 | + | if adjustable { | |
| 166 | + | help = " ←/→ or H/L to change" | |
| 167 | + | } | |
| 168 | + | lines = append(lines, | |
| 169 | + | th.FGColor(th.Muted, "✦ reasoning: ")+th.FGColor(reasoningLevelColor(th, reasoning), reasoning)+th.FGColor(th.Muted, help), | |
| 170 | + | ) | |
| 163 | 171 | } | |
| 164 | 172 | ||
| 165 | 173 | if len(d.view) == 0 { | |
| @@ -266,9 +274,69 @@ func padRight(s string, w int) string { | |||
| 266 | 274 | return runewidth.Truncate(s, w, "…") | |
| 267 | 275 | } | |
| 268 | 276 | ||
| 277 | + | // reasoningLevelColor makes the active reasoning depth legible at a glance: | |
| 278 | + | // low levels use the theme accent, medium/high use the warning color, and the | |
| 279 | + | // most expensive levels use the explicit thinking-max color. | |
| 280 | + | func reasoningLevelColor(th tui.Theme, level string) tui.TerminalColor { | |
| 281 | + | switch provider.NormalizeReasoning(level) { | |
| 282 | + | case "minimum", "low": | |
| 283 | + | return th.Accent | |
| 284 | + | case "medium", "high": | |
| 285 | + | return th.Warning | |
| 286 | + | case "xhigh", "max": | |
| 287 | + | return th.ThinkingMax | |
| 288 | + | default: | |
| 289 | + | return th.Muted | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // cursorReasoning returns the effective level for the model under the cursor | |
| 294 | + | // and whether that model allows it to be adjusted. | |
| 295 | + | func (d *modelDialog) cursorReasoning() (string, bool) { | |
| 296 | + | if len(d.view) == 0 || d.cursor < 0 || d.cursor >= len(d.view) { | |
| 297 | + | return d.reasoning, false | |
| 298 | + | } | |
| 299 | + | model := d.view[d.cursor] | |
| 300 | + | levels := provider.AvailableReasoningLevels(model) | |
| 301 | + | return provider.ClampReasoningForModel(model, d.reasoning), len(levels) > 1 | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | // stepReasoning moves through the levels supported by the model under the | |
| 305 | + | // cursor. It stops at each end rather than wrapping, matching arrow-key | |
| 306 | + | // expectations and preventing an accidental jump from max to off. | |
| 307 | + | func (d *modelDialog) stepReasoning(direction int) modelDialogAction { | |
| 308 | + | if len(d.view) == 0 || d.cursor < 0 || d.cursor >= len(d.view) { | |
| 309 | + | return modelDialogAction{} | |
| 310 | + | } | |
| 311 | + | model := d.view[d.cursor] | |
| 312 | + | levels := provider.AvailableReasoningLevels(model) | |
| 313 | + | current := provider.ClampReasoningForModel(model, d.reasoning) | |
| 314 | + | idx := 0 | |
| 315 | + | for i, level := range levels { | |
| 316 | + | if level == current { | |
| 317 | + | idx = i | |
| 318 | + | break | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | next := idx + direction | |
| 322 | + | if next < 0 || next >= len(levels) { | |
| 323 | + | return modelDialogAction{} | |
| 324 | + | } | |
| 325 | + | d.reasoning = levels[next] | |
| 326 | + | return modelDialogAction{ReasoningChanged: true, Reasoning: d.reasoning} | |
| 327 | + | } | |
| 328 | + | ||
| 269 | 329 | // HandleKey advances the dialog and returns an action to apply, if any. | |
| 270 | 330 | func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction { | |
| 271 | 331 | switch k.Kind { | |
| 332 | + | case tui.KeyLeft: | |
| 333 | + | if d.showReasoning { | |
| 334 | + | return d.stepReasoning(-1) | |
| 335 | + | } | |
| 336 | + | case tui.KeyRight: | |
| 337 | + | if d.showReasoning { | |
| 338 | + | return d.stepReasoning(1) | |
| 339 | + | } | |
| 272 | 340 | case tui.KeyUp: | |
| 273 | 341 | if d.cursor > 0 { | |
| 274 | 342 | d.cursor-- | |
| @@ -305,6 +373,14 @@ func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction { | |||
| 305 | 373 | if k.Alt || k.Ctrl { | |
| 306 | 374 | break | |
| 307 | 375 | } | |
| 376 | + | if d.showReasoning { | |
| 377 | + | switch k.Rune { | |
| 378 | + | case 'H': | |
| 379 | + | return d.stepReasoning(-1) | |
| 380 | + | case 'L': | |
| 381 | + | return d.stepReasoning(1) | |
| 382 | + | } | |
| 383 | + | } | |
| 308 | 384 | // Only printable ASCII is useful for narrowing. | |
| 309 | 385 | if k.Rune >= 0x20 && k.Rune < 0x7f { | |
| 310 | 386 | d.query += string(k.Rune) | |
| @@ -36,10 +36,12 @@ type modelDialog struct { | |||||
| 36 | 36 | ||||
| 37 | // modelDialogAction is returned by HandleKey. | 37 | // modelDialogAction is returned by HandleKey. | ||
| 38 | type modelDialogAction struct { | 38 | type modelDialogAction struct { | ||
| 39 | - | Select bool | 39 | + | Select bool |
| 40 | - | Provider string | 40 | + | Provider string |
| 41 | - | Model string | 41 | + | Model string |
| 42 | - | Close bool | 42 | + | ReasoningChanged bool |
| 43 | + | Reasoning string | |||
| 44 | + | Close bool | |||
| 43 | } | 45 | } | ||
| 44 | 46 | ||||
| 45 | func newModelDialog() *modelDialog { | 47 | func newModelDialog() *modelDialog { | ||
| @@ -155,11 +157,17 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string { | |||||
| 155 | } | 157 | } | ||
| 156 | lines = append(lines, th.FGColor(th.Muted, hint)) | 158 | lines = append(lines, th.FGColor(th.Muted, hint)) | ||
| 157 | if d.showReasoning { | 159 | if d.showReasoning { | ||
| 158 | - | reasoning := d.reasoning | 160 | + | reasoning, adjustable := d.cursorReasoning() |
| 159 | if reasoning == "" { | 161 | if reasoning == "" { | ||
| 160 | reasoning = "off" | 162 | reasoning = "off" | ||
| 161 | } | 163 | } | ||
| 162 | - | lines = append(lines, th.FGColor(th.Muted, "✦ supports reasoning; current reasoning: "+reasoning+"; change with /reasoning")) | 164 | + | help := " unavailable for highlighted model" |
| 165 | + | if adjustable { | |||
| 166 | + | help = " ←/→ or H/L to change" | |||
| 167 | + | } | |||
| 168 | + | lines = append(lines, | |||
| 169 | + | th.FGColor(th.Muted, "✦ reasoning: ")+th.FGColor(reasoningLevelColor(th, reasoning), reasoning)+th.FGColor(th.Muted, help), | |||
| 170 | + | ) | |||
| 163 | } | 171 | } | ||
| 164 | 172 | ||||
| 165 | if len(d.view) == 0 { | 173 | if len(d.view) == 0 { | ||
| @@ -266,9 +274,69 @@ func padRight(s string, w int) string { | |||||
| 266 | return runewidth.Truncate(s, w, "…") | 274 | return runewidth.Truncate(s, w, "…") | ||
| 267 | } | 275 | } | ||
| 268 | 276 | ||||
| 277 | + | // reasoningLevelColor makes the active reasoning depth legible at a glance: | |||
| 278 | + | // low levels use the theme accent, medium/high use the warning color, and the | |||
| 279 | + | // most expensive levels use the explicit thinking-max color. | |||
| 280 | + | func reasoningLevelColor(th tui.Theme, level string) tui.TerminalColor { | |||
| 281 | + | switch provider.NormalizeReasoning(level) { | |||
| 282 | + | case "minimum", "low": | |||
| 283 | + | return th.Accent | |||
| 284 | + | case "medium", "high": | |||
| 285 | + | return th.Warning | |||
| 286 | + | case "xhigh", "max": | |||
| 287 | + | return th.ThinkingMax | |||
| 288 | + | default: | |||
| 289 | + | return th.Muted | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // cursorReasoning returns the effective level for the model under the cursor | |||
| 294 | + | // and whether that model allows it to be adjusted. | |||
| 295 | + | func (d *modelDialog) cursorReasoning() (string, bool) { | |||
| 296 | + | if len(d.view) == 0 || d.cursor < 0 || d.cursor >= len(d.view) { | |||
| 297 | + | return d.reasoning, false | |||
| 298 | + | } | |||
| 299 | + | model := d.view[d.cursor] | |||
| 300 | + | levels := provider.AvailableReasoningLevels(model) | |||
| 301 | + | return provider.ClampReasoningForModel(model, d.reasoning), len(levels) > 1 | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | // stepReasoning moves through the levels supported by the model under the | |||
| 305 | + | // cursor. It stops at each end rather than wrapping, matching arrow-key | |||
| 306 | + | // expectations and preventing an accidental jump from max to off. | |||
| 307 | + | func (d *modelDialog) stepReasoning(direction int) modelDialogAction { | |||
| 308 | + | if len(d.view) == 0 || d.cursor < 0 || d.cursor >= len(d.view) { | |||
| 309 | + | return modelDialogAction{} | |||
| 310 | + | } | |||
| 311 | + | model := d.view[d.cursor] | |||
| 312 | + | levels := provider.AvailableReasoningLevels(model) | |||
| 313 | + | current := provider.ClampReasoningForModel(model, d.reasoning) | |||
| 314 | + | idx := 0 | |||
| 315 | + | for i, level := range levels { | |||
| 316 | + | if level == current { | |||
| 317 | + | idx = i | |||
| 318 | + | break | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | next := idx + direction | |||
| 322 | + | if next < 0 || next >= len(levels) { | |||
| 323 | + | return modelDialogAction{} | |||
| 324 | + | } | |||
| 325 | + | d.reasoning = levels[next] | |||
| 326 | + | return modelDialogAction{ReasoningChanged: true, Reasoning: d.reasoning} | |||
| 327 | + | } | |||
| 328 | + | ||||
| 269 | // HandleKey advances the dialog and returns an action to apply, if any. | 329 | // HandleKey advances the dialog and returns an action to apply, if any. | ||
| 270 | func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction { | 330 | func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction { | ||
| 271 | switch k.Kind { | 331 | switch k.Kind { | ||
| 332 | + | case tui.KeyLeft: | |||
| 333 | + | if d.showReasoning { | |||
| 334 | + | return d.stepReasoning(-1) | |||
| 335 | + | } | |||
| 336 | + | case tui.KeyRight: | |||
| 337 | + | if d.showReasoning { | |||
| 338 | + | return d.stepReasoning(1) | |||
| 339 | + | } | |||
| 272 | case tui.KeyUp: | 340 | case tui.KeyUp: | ||
| 273 | if d.cursor > 0 { | 341 | if d.cursor > 0 { | ||
| 274 | d.cursor-- | 342 | d.cursor-- | ||
| @@ -305,6 +373,14 @@ func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction { | |||||
| 305 | if k.Alt || k.Ctrl { | 373 | if k.Alt || k.Ctrl { | ||
| 306 | break | 374 | break | ||
| 307 | } | 375 | } | ||
| 376 | + | if d.showReasoning { | |||
| 377 | + | switch k.Rune { | |||
| 378 | + | case 'H': | |||
| 379 | + | return d.stepReasoning(-1) | |||
| 380 | + | case 'L': | |||
| 381 | + | return d.stepReasoning(1) | |||
| 382 | + | } | |||
| 383 | + | } | |||
| 308 | // Only printable ASCII is useful for narrowing. | 384 | // Only printable ASCII is useful for narrowing. | ||
| 309 | if k.Rune >= 0x20 && k.Rune < 0x7f { | 385 | if k.Rune >= 0x20 && k.Rune < 0x7f { | ||
| 310 | d.query += string(k.Rune) | 386 | d.query += string(k.Rune) | ||
A packages/agent/modes/model_dialog_test.go +154 -0
| @@ -0,0 +1,154 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zut/packages/provider" | |
| 9 | + | "github.com/bnema/zut/packages/tui" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestModelDialogReasoningNavigation(t *testing.T) { | |
| 13 | + | d := &modelDialog{ | |
| 14 | + | active: true, | |
| 15 | + | view: []provider.Model{{Provider: "openai-codex", ID: "gpt-5.6-luna", Reasoning: true}}, | |
| 16 | + | reasoning: "high", | |
| 17 | + | showReasoning: true, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | act := d.HandleKey(tui.Key{Kind: tui.KeyRight}) | |
| 21 | + | if !act.ReasoningChanged || act.Reasoning != "xhigh" || d.reasoning != "xhigh" { | |
| 22 | + | t.Fatalf("right action = %+v, dialog reasoning = %q; want xhigh", act, d.reasoning) | |
| 23 | + | } | |
| 24 | + | act = d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'L'}) | |
| 25 | + | if !act.ReasoningChanged || act.Reasoning != "max" || d.reasoning != "max" { | |
| 26 | + | t.Fatalf("L action = %+v, dialog reasoning = %q; want max", act, d.reasoning) | |
| 27 | + | } | |
| 28 | + | act = d.HandleKey(tui.Key{Kind: tui.KeyLeft}) | |
| 29 | + | if !act.ReasoningChanged || act.Reasoning != "xhigh" { | |
| 30 | + | t.Fatalf("left action = %+v; want xhigh", act) | |
| 31 | + | } | |
| 32 | + | act = d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'H'}) | |
| 33 | + | if !act.ReasoningChanged || act.Reasoning != "high" { | |
| 34 | + | t.Fatalf("H action = %+v; want high", act) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestModelDialogReasoningAllowsLowercaseFilter(t *testing.T) { | |
| 39 | + | d := &modelDialog{active: true, showReasoning: true} | |
| 40 | + | for _, r := range "claude" { | |
| 41 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |
| 42 | + | } | |
| 43 | + | if d.query != "claude" { | |
| 44 | + | t.Fatalf("query = %q, want claude", d.query) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestModelDialogWithoutReasoningTreatsHAsFilter(t *testing.T) { | |
| 49 | + | d := &modelDialog{active: true} | |
| 50 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'H'}) | |
| 51 | + | if d.query != "H" { | |
| 52 | + | t.Fatalf("embedded picker query = %q, want H", d.query) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestModelDialogReasoningNavigationStopsAtBounds(t *testing.T) { | |
| 57 | + | d := &modelDialog{ | |
| 58 | + | active: true, | |
| 59 | + | showReasoning: true, | |
| 60 | + | view: []provider.Model{{Provider: "openai-codex", ID: "gpt-5.6-luna", Reasoning: true}}, | |
| 61 | + | reasoning: "", | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | if act := d.HandleKey(tui.Key{Kind: tui.KeyLeft}); act.ReasoningChanged { | |
| 65 | + | t.Fatalf("left at off changed reasoning: %+v", act) | |
| 66 | + | } | |
| 67 | + | d.reasoning = "max" | |
| 68 | + | if act := d.HandleKey(tui.Key{Kind: tui.KeyRight}); act.ReasoningChanged { | |
| 69 | + | t.Fatalf("right at max changed reasoning: %+v", act) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestModelDialogReasoningNavigationAppliesToInteractive(t *testing.T) { | |
| 74 | + | d := &modelDialog{ | |
| 75 | + | active: true, | |
| 76 | + | showReasoning: true, | |
| 77 | + | view: []provider.Model{{Provider: "openai-codex", ID: "gpt-5.6-luna", Reasoning: true}}, | |
| 78 | + | reasoning: "high", | |
| 79 | + | } | |
| 80 | + | var changed string | |
| 81 | + | i := &Interactive{ | |
| 82 | + | cfg: InteractiveConfig{ | |
| 83 | + | Reasoning: "high", | |
| 84 | + | OnReasoningChanged: func(level string) { changed = level }, | |
| 85 | + | }, | |
| 86 | + | modelDialog: d, | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRight}) | |
| 90 | + | if i.cfg.Reasoning != "xhigh" || changed != "xhigh" { | |
| 91 | + | t.Fatalf("reasoning = %q, callback = %q; want xhigh", i.cfg.Reasoning, changed) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestModelDialogWithoutReasoningIgnoresArrows(t *testing.T) { | |
| 96 | + | d := &modelDialog{ | |
| 97 | + | active: true, | |
| 98 | + | view: []provider.Model{{Provider: "openai-codex", ID: "gpt-5.6-luna", Reasoning: true}}, | |
| 99 | + | reasoning: "high", | |
| 100 | + | } | |
| 101 | + | if act := d.HandleKey(tui.Key{Kind: tui.KeyRight}); act.ReasoningChanged || d.reasoning != "high" { | |
| 102 | + | t.Fatalf("hidden reasoning changed: action=%+v reasoning=%q", act, d.reasoning) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestModelDialogReasoningReflectsHighlightedModel(t *testing.T) { | |
| 107 | + | d := &modelDialog{ | |
| 108 | + | active: true, | |
| 109 | + | showReasoning: true, | |
| 110 | + | reasoning: "high", | |
| 111 | + | view: []provider.Model{{Provider: "google", ID: "gemini-2.0-flash"}}, | |
| 112 | + | } | |
| 113 | + | text := stripANSIBytes(strings.Join(d.Render(tui.Dark, 100), "\n")) | |
| 114 | + | if !strings.Contains(text, "reasoning: off") { | |
| 115 | + | t.Fatalf("effective reasoning level missing from %q", text) | |
| 116 | + | } | |
| 117 | + | if !strings.Contains(text, "unavailable for highlighted model") { | |
| 118 | + | t.Fatalf("unsupported-model hint missing from %q", text) | |
| 119 | + | } | |
| 120 | + | if strings.Contains(text, "H/L to change") { | |
| 121 | + | t.Fatalf("unsupported model advertises reasoning controls: %q", text) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestModelDialogReasoningUsesLevelColor(t *testing.T) { | |
| 126 | + | tests := []struct { | |
| 127 | + | level string | |
| 128 | + | color tui.TerminalColor | |
| 129 | + | }{ | |
| 130 | + | {"off", tui.Dark.Muted}, | |
| 131 | + | {"low", tui.Dark.Accent}, | |
| 132 | + | {"high", tui.Dark.Warning}, | |
| 133 | + | {"max", tui.Dark.ThinkingMax}, | |
| 134 | + | } | |
| 135 | + | for _, tt := range tests { | |
| 136 | + | if got := reasoningLevelColor(tui.Dark, tt.level); got != tt.color { | |
| 137 | + | t.Errorf("reasoningLevelColor(%q) = %#v, want %#v", tt.level, got, tt.color) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | d := &modelDialog{ | |
| 142 | + | active: true, | |
| 143 | + | showReasoning: true, | |
| 144 | + | reasoning: "max", | |
| 145 | + | view: []provider.Model{{Provider: "openai-codex", ID: "gpt-5.6-luna", Reasoning: true}}, | |
| 146 | + | } | |
| 147 | + | text := strings.Join(d.Render(tui.Dark, 100), "\n") | |
| 148 | + | if !strings.Contains(text, tui.Dark.FGColor(tui.Dark.ThinkingMax, "max")) { | |
| 149 | + | t.Fatalf("max reasoning color missing from %q", text) | |
| 150 | + | } | |
| 151 | + | if !strings.Contains(text, "←/→ or H/L to change") { | |
| 152 | + | t.Fatalf("reasoning navigation hint missing from %q", text) | |
| 153 | + | } | |
| 154 | + | } | |
| @@ -0,0 +1,154 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zut/packages/provider" | |||
| 9 | + | "github.com/bnema/zut/packages/tui" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestModelDialogReasoningNavigation(t *testing.T) { | |||
| 13 | + | d := &modelDialog{ | |||
| 14 | + | active: true, | |||
| 15 | + | view: []provider.Model{{Provider: "openai-codex", ID: "gpt-5.6-luna", Reasoning: true}}, | |||
| 16 | + | reasoning: "high", | |||
| 17 | + | showReasoning: true, | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | act := d.HandleKey(tui.Key{Kind: tui.KeyRight}) | |||
| 21 | + | if !act.ReasoningChanged || act.Reasoning != "xhigh" || d.reasoning != "xhigh" { | |||
| 22 | + | t.Fatalf("right action = %+v, dialog reasoning = %q; want xhigh", act, d.reasoning) | |||
| 23 | + | } | |||
| 24 | + | act = d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'L'}) | |||
| 25 | + | if !act.ReasoningChanged || act.Reasoning != "max" || d.reasoning != "max" { | |||
| 26 | + | t.Fatalf("L action = %+v, dialog reasoning = %q; want max", act, d.reasoning) | |||
| 27 | + | } | |||
| 28 | + | act = d.HandleKey(tui.Key{Kind: tui.KeyLeft}) | |||
| 29 | + | if !act.ReasoningChanged || act.Reasoning != "xhigh" { | |||
| 30 | + | t.Fatalf("left action = %+v; want xhigh", act) | |||
| 31 | + | } | |||
| 32 | + | act = d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'H'}) | |||
| 33 | + | if !act.ReasoningChanged || act.Reasoning != "high" { | |||
| 34 | + | t.Fatalf("H action = %+v; want high", act) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestModelDialogReasoningAllowsLowercaseFilter(t *testing.T) { | |||
| 39 | + | d := &modelDialog{active: true, showReasoning: true} | |||
| 40 | + | for _, r := range "claude" { | |||
| 41 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |||
| 42 | + | } | |||
| 43 | + | if d.query != "claude" { | |||
| 44 | + | t.Fatalf("query = %q, want claude", d.query) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestModelDialogWithoutReasoningTreatsHAsFilter(t *testing.T) { | |||
| 49 | + | d := &modelDialog{active: true} | |||
| 50 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'H'}) | |||
| 51 | + | if d.query != "H" { | |||
| 52 | + | t.Fatalf("embedded picker query = %q, want H", d.query) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestModelDialogReasoningNavigationStopsAtBounds(t *testing.T) { | |||
| 57 | + | d := &modelDialog{ | |||
| 58 | + | active: true, | |||
| 59 | + | showReasoning: true, | |||
| 60 | + | view: []provider.Model{{Provider: "openai-codex", ID: "gpt-5.6-luna", Reasoning: true}}, | |||
| 61 | + | reasoning: "", | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | if act := d.HandleKey(tui.Key{Kind: tui.KeyLeft}); act.ReasoningChanged { | |||
| 65 | + | t.Fatalf("left at off changed reasoning: %+v", act) | |||
| 66 | + | } | |||
| 67 | + | d.reasoning = "max" | |||
| 68 | + | if act := d.HandleKey(tui.Key{Kind: tui.KeyRight}); act.ReasoningChanged { | |||
| 69 | + | t.Fatalf("right at max changed reasoning: %+v", act) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestModelDialogReasoningNavigationAppliesToInteractive(t *testing.T) { | |||
| 74 | + | d := &modelDialog{ | |||
| 75 | + | active: true, | |||
| 76 | + | showReasoning: true, | |||
| 77 | + | view: []provider.Model{{Provider: "openai-codex", ID: "gpt-5.6-luna", Reasoning: true}}, | |||
| 78 | + | reasoning: "high", | |||
| 79 | + | } | |||
| 80 | + | var changed string | |||
| 81 | + | i := &Interactive{ | |||
| 82 | + | cfg: InteractiveConfig{ | |||
| 83 | + | Reasoning: "high", | |||
| 84 | + | OnReasoningChanged: func(level string) { changed = level }, | |||
| 85 | + | }, | |||
| 86 | + | modelDialog: d, | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRight}) | |||
| 90 | + | if i.cfg.Reasoning != "xhigh" || changed != "xhigh" { | |||
| 91 | + | t.Fatalf("reasoning = %q, callback = %q; want xhigh", i.cfg.Reasoning, changed) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestModelDialogWithoutReasoningIgnoresArrows(t *testing.T) { | |||
| 96 | + | d := &modelDialog{ | |||
| 97 | + | active: true, | |||
| 98 | + | view: []provider.Model{{Provider: "openai-codex", ID: "gpt-5.6-luna", Reasoning: true}}, | |||
| 99 | + | reasoning: "high", | |||
| 100 | + | } | |||
| 101 | + | if act := d.HandleKey(tui.Key{Kind: tui.KeyRight}); act.ReasoningChanged || d.reasoning != "high" { | |||
| 102 | + | t.Fatalf("hidden reasoning changed: action=%+v reasoning=%q", act, d.reasoning) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestModelDialogReasoningReflectsHighlightedModel(t *testing.T) { | |||
| 107 | + | d := &modelDialog{ | |||
| 108 | + | active: true, | |||
| 109 | + | showReasoning: true, | |||
| 110 | + | reasoning: "high", | |||
| 111 | + | view: []provider.Model{{Provider: "google", ID: "gemini-2.0-flash"}}, | |||
| 112 | + | } | |||
| 113 | + | text := stripANSIBytes(strings.Join(d.Render(tui.Dark, 100), "\n")) | |||
| 114 | + | if !strings.Contains(text, "reasoning: off") { | |||
| 115 | + | t.Fatalf("effective reasoning level missing from %q", text) | |||
| 116 | + | } | |||
| 117 | + | if !strings.Contains(text, "unavailable for highlighted model") { | |||
| 118 | + | t.Fatalf("unsupported-model hint missing from %q", text) | |||
| 119 | + | } | |||
| 120 | + | if strings.Contains(text, "H/L to change") { | |||
| 121 | + | t.Fatalf("unsupported model advertises reasoning controls: %q", text) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestModelDialogReasoningUsesLevelColor(t *testing.T) { | |||
| 126 | + | tests := []struct { | |||
| 127 | + | level string | |||
| 128 | + | color tui.TerminalColor | |||
| 129 | + | }{ | |||
| 130 | + | {"off", tui.Dark.Muted}, | |||
| 131 | + | {"low", tui.Dark.Accent}, | |||
| 132 | + | {"high", tui.Dark.Warning}, | |||
| 133 | + | {"max", tui.Dark.ThinkingMax}, | |||
| 134 | + | } | |||
| 135 | + | for _, tt := range tests { | |||
| 136 | + | if got := reasoningLevelColor(tui.Dark, tt.level); got != tt.color { | |||
| 137 | + | t.Errorf("reasoningLevelColor(%q) = %#v, want %#v", tt.level, got, tt.color) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | d := &modelDialog{ | |||
| 142 | + | active: true, | |||
| 143 | + | showReasoning: true, | |||
| 144 | + | reasoning: "max", | |||
| 145 | + | view: []provider.Model{{Provider: "openai-codex", ID: "gpt-5.6-luna", Reasoning: true}}, | |||
| 146 | + | } | |||
| 147 | + | text := strings.Join(d.Render(tui.Dark, 100), "\n") | |||
| 148 | + | if !strings.Contains(text, tui.Dark.FGColor(tui.Dark.ThinkingMax, "max")) { | |||
| 149 | + | t.Fatalf("max reasoning color missing from %q", text) | |||
| 150 | + | } | |||
| 151 | + | if !strings.Contains(text, "←/→ or H/L to change") { | |||
| 152 | + | t.Fatalf("reasoning navigation hint missing from %q", text) | |||
| 153 | + | } | |||
| 154 | + | } | |||
M packages/agent/modes/subagents_dialog_test.go +6 -0
| @@ -978,6 +978,12 @@ func TestSubagentsDialogSpawnEditor_SlashModelOpensPicker(t *testing.T) { | |||
| 978 | 978 | if d.spawnDraft != "refactor X" { | |
| 979 | 979 | t.Fatalf("spawnDraft = %q; want %q (the /model line should be stripped, the task kept)", d.spawnDraft, "refactor X") | |
| 980 | 980 | } | |
| 981 | + | // The embedded picker has no reasoning control, so H must remain a filter | |
| 982 | + | // character rather than being consumed as a reasoning shortcut. | |
| 983 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'H'}) | |
| 984 | + | if d.modelPicker.query != "H" { | |
| 985 | + | t.Fatalf("embedded picker query = %q, want H", d.modelPicker.query) | |
| 986 | + | } | |
| 981 | 987 | ||
| 982 | 988 | // Picker render must take over the dialog frame. | |
| 983 | 989 | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| @@ -978,6 +978,12 @@ func TestSubagentsDialogSpawnEditor_SlashModelOpensPicker(t *testing.T) { | |||||
| 978 | if d.spawnDraft != "refactor X" { | 978 | if d.spawnDraft != "refactor X" { | ||
| 979 | t.Fatalf("spawnDraft = %q; want %q (the /model line should be stripped, the task kept)", d.spawnDraft, "refactor X") | 979 | t.Fatalf("spawnDraft = %q; want %q (the /model line should be stripped, the task kept)", d.spawnDraft, "refactor X") | ||
| 980 | } | 980 | } | ||
| 981 | + | // The embedded picker has no reasoning control, so H must remain a filter | |||
| 982 | + | // character rather than being consumed as a reasoning shortcut. | |||
| 983 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'H'}) | |||
| 984 | + | if d.modelPicker.query != "H" { | |||
| 985 | + | t.Fatalf("embedded picker query = %q, want H", d.modelPicker.query) | |||
| 986 | + | } | |||
| 981 | 987 | ||||
| 982 | // Picker render must take over the dialog frame. | 988 | // Picker render must take over the dialog frame. | ||
| 983 | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | 989 | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | ||