detached 7028e8c

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 UnverifiedB5690EEEBB952194 The 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) {
29572957 if act.Close {
29582958 i.quickModelAssign = 0
29592959 }
2960+ if act.ReasoningChanged {
2961+ i.applyReasoningSetting(act.Reasoning)
2962+ }
29602963 if act.Select {
29612964 if i.quickModelAssign > 0 {
29622965 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 = 02958 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 (
66 "strings"
77 "testing"
88
9+ "github.com/bnema/zut/packages/provider"
910 "github.com/bnema/zut/packages/tui"
1011 )
1112
@@ -81,13 +82,15 @@ func TestReasoningCommandOpensDirectSelector(t *testing.T) {
8182 }
8283 }
8384
84-func TestModelDialogAdvertisesReasoningSelector(t *testing.T) {
85+func TestModelDialogAdvertisesReasoningNavigation(t *testing.T) {
8586 d := newModelDialog()
8687 d.Open("", nil, "high")
88+ d.view = []provider.Model{{Provider: "openai-codex", ID: "gpt-5.6-luna", Reasoning: true}}
8789
8890 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)
9194 }
9295 }
9396
@@ -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 {
3636
3737 // modelDialogAction is returned by HandleKey.
3838 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
4345 }
4446
4547 func newModelDialog() *modelDialog {
@@ -155,11 +157,17 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
155157 }
156158 lines = append(lines, th.FGColor(th.Muted, hint))
157159 if d.showReasoning {
158- reasoning := d.reasoning
160+ reasoning, adjustable := d.cursorReasoning()
159161 if reasoning == "" {
160162 reasoning = "off"
161163 }
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+ )
163171 }
164172
165173 if len(d.view) == 0 {
@@ -266,9 +274,69 @@ func padRight(s string, w int) string {
266274 return runewidth.Truncate(s, w, "…")
267275 }
268276
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+
269329 // HandleKey advances the dialog and returns an action to apply, if any.
270330 func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
271331 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+ }
272340 case tui.KeyUp:
273341 if d.cursor > 0 {
274342 d.cursor--
@@ -305,6 +373,14 @@ func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
305373 if k.Alt || k.Ctrl {
306374 break
307375 }
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+ }
308384 // Only printable ASCII is useful for narrowing.
309385 if k.Rune >= 0x20 && k.Rune < 0x7f {
310386 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 bool39+ Select bool
40- Provider string40+ Provider string
41- Model string41+ Model string
42- Close bool42+ 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.reasoning160+ 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 break374 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) {
978978 if d.spawnDraft != "refactor X" {
979979 t.Fatalf("spawnDraft = %q; want %q (the /model line should be stripped, the task kept)", d.spawnDraft, "refactor X")
980980 }
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+ }
981987
982988 // Picker render must take over the dialog frame.
983989 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")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close