detached 37ef90b

add configurable thinking level

commit
37ef90bbb377043539625f5705f559bbb52b8c78
author
patriceckhart · · 2026-05-26 16:07 UTC
parent
d6a30a5
signature
unsigned
16 files · +555 -72
M README.md +4 -3
@@ -141,7 +141,7 @@ zot --help
141141 | `--base-url <url>` | Override the provider base URL (tests, self-hosted). |
142142 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |
143143 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
144-| `--reasoning low\|medium\|high` | Enable reasoning on supported models. |
144+| `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models. |
145145 | `-c`, `--continue` | Resume the latest session for this cwd. |
146146 | `-r`, `--resume` | Pick a session to resume. |
147147 | `--session <path>` | Resume a specific session file. |
@@ -290,10 +290,11 @@ Background subagents that run alongside your main session. Each one is a separat
290290
291291 ### `/settings`
292292
293-Opens a dialog with every persistent toggle. `up`/`down` to navigate, `enter` or `space` to flip the selected row, `esc` to close. Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current toggles:
293+Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close. Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings:
294294
295295 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
296296 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
297+- **thinking level** — choose reasoning for supported models: off (no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call.
297298
298299 ### `/skills`
299300
@@ -468,7 +469,7 @@ Use `/login` and pick **api key** to paste an AI Studio key. zot probes `/v1beta
468469
469470 > **Free-tier rate limits.** AI Studio's free tier has tight per-minute and per-day caps that vary by model: `gemini-2.5-pro` is the strictest (a few requests per minute, ~50 per day), Flash and Flash-Lite are far more generous. If a Pro turn 429s with `"You exceeded your current quota"` while Flash on the same key still works, you've hit the Pro free-tier RPD. Either switch to Flash for agent loops, or [enable billing](https://aistudio.google.com/app/apikey) on your AI Studio project to flip the same key from free to pay-as-you-go pricing (`$1.25/M` input, `$10/M` output for Pro).
470471
471-Reasoning levels (`--reasoning low|medium|high`) map differently per generation: 2.5 family uses `thinkingBudget` token budgets per model (Pro caps at 32k, Flash at 24k); Gemini 3.x uses the `thinkingLevel` enum (`MINIMAL`/`LOW`/`MEDIUM`/`HIGH`), with Gemini-3-Pro pinned to `LOW` minimum and `HIGH` for any "medium" or "high" request. 2.0-family models have no thinking config at all.
472+Reasoning levels (`--reasoning off|minimum|low|medium|high|maximum`, also configurable in `/settings` as **thinking level**) map differently per generation. Budget-based providers use roughly 1k/2k/8k/16k/32k thinking tokens for minimum/low/medium/high/maximum, with provider/model caps applied (Gemini 2.5 Pro caps at 32k; Flash at 24k). Gemini 3.x uses the `thinkingLevel` enum (`MINIMAL`/`LOW`/`MEDIUM`/`HIGH`), with Gemini-3-Pro pinned to `LOW` minimum and `HIGH` for any "medium" or higher request. Effort-based OpenAI-compatible chat providers map minimum to `low`, low/medium directly, and high/maximum to `high`; the Codex/Responses backend maps maximum to `xhigh` where supported. `off` sends no reasoning config. 2.0-family Gemini models have no thinking config at all.
472473
473474 You can add additional Gemini model IDs to `models.json` under the `google` provider.
474475
@@ -141,7 +141,7 @@ zot --help
141 | `--base-url <url>` | Override the provider base URL (tests, self-hosted). |141 | `--base-url <url>` | Override the provider base URL (tests, self-hosted). |
142 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |142 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |
143 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |143 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
144-| `--reasoning low\|medium\|high` | Enable reasoning on supported models. |144+| `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models. |
145 | `-c`, `--continue` | Resume the latest session for this cwd. |145 | `-c`, `--continue` | Resume the latest session for this cwd. |
146 | `-r`, `--resume` | Pick a session to resume. |146 | `-r`, `--resume` | Pick a session to resume. |
147 | `--session <path>` | Resume a specific session file. |147 | `--session <path>` | Resume a specific session file. |
@@ -290,10 +290,11 @@ Background subagents that run alongside your main session. Each one is a separat
290 290
291 ### `/settings`291 ### `/settings`
292 292
293-Opens a dialog with every persistent toggle. `up`/`down` to navigate, `enter` or `space` to flip the selected row, `esc` to close. Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current toggles:293+Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close. Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings:
294 294
295 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.295 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
296 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.296 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
297+- **thinking level** — choose reasoning for supported models: off (no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call.
297 298
298 ### `/skills`299 ### `/skills`
299 300
@@ -468,7 +469,7 @@ Use `/login` and pick **api key** to paste an AI Studio key. zot probes `/v1beta
468 469
469 > **Free-tier rate limits.** AI Studio's free tier has tight per-minute and per-day caps that vary by model: `gemini-2.5-pro` is the strictest (a few requests per minute, ~50 per day), Flash and Flash-Lite are far more generous. If a Pro turn 429s with `"You exceeded your current quota"` while Flash on the same key still works, you've hit the Pro free-tier RPD. Either switch to Flash for agent loops, or [enable billing](https://aistudio.google.com/app/apikey) on your AI Studio project to flip the same key from free to pay-as-you-go pricing (`$1.25/M` input, `$10/M` output for Pro).470 > **Free-tier rate limits.** AI Studio's free tier has tight per-minute and per-day caps that vary by model: `gemini-2.5-pro` is the strictest (a few requests per minute, ~50 per day), Flash and Flash-Lite are far more generous. If a Pro turn 429s with `"You exceeded your current quota"` while Flash on the same key still works, you've hit the Pro free-tier RPD. Either switch to Flash for agent loops, or [enable billing](https://aistudio.google.com/app/apikey) on your AI Studio project to flip the same key from free to pay-as-you-go pricing (`$1.25/M` input, `$10/M` output for Pro).
470 471
471-Reasoning levels (`--reasoning low|medium|high`) map differently per generation: 2.5 family uses `thinkingBudget` token budgets per model (Pro caps at 32k, Flash at 24k); Gemini 3.x uses the `thinkingLevel` enum (`MINIMAL`/`LOW`/`MEDIUM`/`HIGH`), with Gemini-3-Pro pinned to `LOW` minimum and `HIGH` for any "medium" or "high" request. 2.0-family models have no thinking config at all.472+Reasoning levels (`--reasoning off|minimum|low|medium|high|maximum`, also configurable in `/settings` as **thinking level**) map differently per generation. Budget-based providers use roughly 1k/2k/8k/16k/32k thinking tokens for minimum/low/medium/high/maximum, with provider/model caps applied (Gemini 2.5 Pro caps at 32k; Flash at 24k). Gemini 3.x uses the `thinkingLevel` enum (`MINIMAL`/`LOW`/`MEDIUM`/`HIGH`), with Gemini-3-Pro pinned to `LOW` minimum and `HIGH` for any "medium" or higher request. Effort-based OpenAI-compatible chat providers map minimum to `low`, low/medium directly, and high/maximum to `high`; the Codex/Responses backend maps maximum to `xhigh` where supported. `off` sends no reasoning config. 2.0-family Gemini models have no thinking config at all.
472 473
473 You can add additional Gemini model IDs to `models.json` under the `google` provider.474 You can add additional Gemini model IDs to `models.json` under the `google` provider.
474 475
M internal/agent/args.go +3 -3
@@ -196,10 +196,10 @@ func ParseArgs(in []string) (Args, error) {
196196 return a, err
197197 }
198198 switch strings.ToLower(v) {
199- case "", "low", "medium", "high":
199+ case "", "off", "minimum", "minimal", "low", "medium", "high", "maximum", "max":
200200 a.Reasoning = strings.ToLower(v)
201201 default:
202- return a, fmt.Errorf("--reasoning must be low|medium|high")
202+ return a, fmt.Errorf("--reasoning must be off|minimum|low|medium|high|maximum")
203203 }
204204 case "--session":
205205 v, err := want(&i, arg)
@@ -360,7 +360,7 @@ func PrintHelp(version string) {
360360 row{"--model ID", "model id (see --list-models)"},
361361 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
362362 row{"--base-url URL", "override provider api base url"},
363- row{"--reasoning low|medium|high", "enable reasoning on supported models"},
363+ row{"--reasoning off|minimum|low|medium|high|maximum", "set thinking level on supported models"},
364364 )
365365 section("prompt and session flags",
366366 row{"--system-prompt TEXT", "replace the default system prompt"},
@@ -196,10 +196,10 @@ func ParseArgs(in []string) (Args, error) {
196 return a, err196 return a, err
197 }197 }
198 switch strings.ToLower(v) {198 switch strings.ToLower(v) {
199- case "", "low", "medium", "high":199+ case "", "off", "minimum", "minimal", "low", "medium", "high", "maximum", "max":
200 a.Reasoning = strings.ToLower(v)200 a.Reasoning = strings.ToLower(v)
201 default:201 default:
202- return a, fmt.Errorf("--reasoning must be low|medium|high")202+ return a, fmt.Errorf("--reasoning must be off|minimum|low|medium|high|maximum")
203 }203 }
204 case "--session":204 case "--session":
205 v, err := want(&i, arg)205 v, err := want(&i, arg)
@@ -360,7 +360,7 @@ func PrintHelp(version string) {
360 row{"--model ID", "model id (see --list-models)"},360 row{"--model ID", "model id (see --list-models)"},
361 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},361 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
362 row{"--base-url URL", "override provider api base url"},362 row{"--base-url URL", "override provider api base url"},
363- row{"--reasoning low|medium|high", "enable reasoning on supported models"},363+ row{"--reasoning off|minimum|low|medium|high|maximum", "set thinking level on supported models"},
364 )364 )
365 section("prompt and session flags",365 section("prompt and session flags",
366 row{"--system-prompt TEXT", "replace the default system prompt"},366 row{"--system-prompt TEXT", "replace the default system prompt"},
M internal/agent/build.go +1 -1
@@ -426,7 +426,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
426426 ZotDocsDir: docsDir,
427427 })
428428
429- reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
429+ reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
430430
431431 max := args.MaxSteps // 0 = unlimited
432432
@@ -426,7 +426,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
426 ZotDocsDir: docsDir,426 ZotDocsDir: docsDir,
427 })427 })
428 428
429- reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)429+ reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
430 430
431 max := args.MaxSteps // 0 = unlimited431 max := args.MaxSteps // 0 = unlimited
432 432
M internal/agent/modes/interactive.go +70 -1
@@ -221,6 +221,7 @@ type chatCacheKey struct {
221221 type SettingsStore interface {
222222 SetInlineImages(enabled bool) error
223223 SetAutoSwarm(enabled bool) error
224+ SetReasoning(level string) error
224225 }
225226
226227 type Interactive struct {
@@ -1065,6 +1066,7 @@ func (i *Interactive) redraw() {
10651066 Theme: i.cfg.Theme,
10661067 Provider: i.cfg.Provider,
10671068 Model: i.cfg.Model,
1069+ Reasoning: i.cfg.Reasoning,
10681070 Busy: i.busy,
10691071 BusyPrefix: busyPrefix,
10701072 CWD: i.cfg.CWD,
@@ -1662,7 +1664,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
16621664 }
16631665 act := i.settingsDialog.HandleKey(k)
16641666 if act.Toggle {
1665- i.applySettingToggle(act.Key, act.Value)
1667+ i.applySettingChange(act)
16661668 }
16671669 i.invalidate()
16681670 return false
@@ -2503,6 +2505,27 @@ func (i *Interactive) openSettingsDialog() {
25032505 autoSwarmHint = "swarm supervisor not available in this mode"
25042506 }
25052507
2508+ reasoningOptions := []settingsOption{
2509+ {value: "", label: "off", desc: "no reasoning"},
2510+ {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},
2511+ {value: "low", label: "low", desc: "light (~2k tokens)"},
2512+ {value: "medium", label: "medium", desc: "moderate (~8k tokens)"},
2513+ {value: "high", label: "high", desc: "deep (~16k tokens)"},
2514+ {value: "maximum", label: "maximum", desc: "highest (~32k tokens)"},
2515+ }
2516+ reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
2517+ reasoningChoice := 0
2518+ for idx, opt := range reasoningOptions {
2519+ if opt.value == reasoning {
2520+ reasoningChoice = idx
2521+ break
2522+ }
2523+ }
2524+ reasoningHint := ""
2525+ if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil && !m.Reasoning {
2526+ reasoningHint = "current model does not support thinking"
2527+ }
2528+
25062529 i.settingsDialog.Open([]settingsItem{
25072530 {
25082531 key: "inline_images_enabled",
@@ -2520,9 +2543,25 @@ func (i *Interactive) openSettingsDialog() {
25202543 disabled: autoSwarmDisabled,
25212544 hint: autoSwarmHint,
25222545 },
2546+ {
2547+ key: "reasoning",
2548+ label: "thinking level",
2549+ desc: "reasoning depth for thinking-capable models",
2550+ options: reasoningOptions,
2551+ choice: reasoningChoice,
2552+ hint: reasoningHint,
2553+ },
25232554 })
25242555 }
25252556
2557+func (i *Interactive) applySettingChange(act settingsAction) {
2558+ if act.Key == "reasoning" {
2559+ i.applyReasoningSetting(act.StringValue)
2560+ return
2561+ }
2562+ i.applySettingToggle(act.Key, act.Value)
2563+}
2564+
25262565 func (i *Interactive) applySettingToggle(key string, value bool) {
25272566 // Every setting toggle forces a full repaint at the end — same
25282567 // effect as the user pressing Ctrl+L — so any per-setting visual
@@ -2579,6 +2618,36 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
25792618 }
25802619 }
25812620
2621+func (i *Interactive) applyReasoningSetting(level string) {
2622+ defer func() {
2623+ if i.rend != nil {
2624+ i.rend.Clear()
2625+ }
2626+ i.invalidate()
2627+ }()
2628+ level = provider.NormalizeReasoning(level)
2629+ i.cfg.Reasoning = level
2630+ if i.cfg.SettingsStore != nil {
2631+ if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
2632+ i.mu.Lock()
2633+ i.statusErr = "settings: " + err.Error()
2634+ i.mu.Unlock()
2635+ return
2636+ }
2637+ }
2638+ i.mu.Lock()
2639+ if i.agent != nil {
2640+ i.agent.Reasoning = level
2641+ }
2642+ label := level
2643+ if label == "" {
2644+ label = "off"
2645+ }
2646+ i.statusOK = "thinking level " + label
2647+ i.statusErr = ""
2648+ i.mu.Unlock()
2649+}
2650+
25822651 // buildStudyPrompt returns the canned prompt the /study command
25832652 // submits to the agent.
25842653 //
@@ -221,6 +221,7 @@ type chatCacheKey struct {
221 type SettingsStore interface {221 type SettingsStore interface {
222 SetInlineImages(enabled bool) error222 SetInlineImages(enabled bool) error
223 SetAutoSwarm(enabled bool) error223 SetAutoSwarm(enabled bool) error
224+ SetReasoning(level string) error
224 }225 }
225 226
226 type Interactive struct {227 type Interactive struct {
@@ -1065,6 +1066,7 @@ func (i *Interactive) redraw() {
1065 Theme: i.cfg.Theme,1066 Theme: i.cfg.Theme,
1066 Provider: i.cfg.Provider,1067 Provider: i.cfg.Provider,
1067 Model: i.cfg.Model,1068 Model: i.cfg.Model,
1069+ Reasoning: i.cfg.Reasoning,
1068 Busy: i.busy,1070 Busy: i.busy,
1069 BusyPrefix: busyPrefix,1071 BusyPrefix: busyPrefix,
1070 CWD: i.cfg.CWD,1072 CWD: i.cfg.CWD,
@@ -1662,7 +1664,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1662 }1664 }
1663 act := i.settingsDialog.HandleKey(k)1665 act := i.settingsDialog.HandleKey(k)
1664 if act.Toggle {1666 if act.Toggle {
1665- i.applySettingToggle(act.Key, act.Value)1667+ i.applySettingChange(act)
1666 }1668 }
1667 i.invalidate()1669 i.invalidate()
1668 return false1670 return false
@@ -2503,6 +2505,27 @@ func (i *Interactive) openSettingsDialog() {
2503 autoSwarmHint = "swarm supervisor not available in this mode"2505 autoSwarmHint = "swarm supervisor not available in this mode"
2504 }2506 }
2505 2507
2508+ reasoningOptions := []settingsOption{
2509+ {value: "", label: "off", desc: "no reasoning"},
2510+ {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},
2511+ {value: "low", label: "low", desc: "light (~2k tokens)"},
2512+ {value: "medium", label: "medium", desc: "moderate (~8k tokens)"},
2513+ {value: "high", label: "high", desc: "deep (~16k tokens)"},
2514+ {value: "maximum", label: "maximum", desc: "highest (~32k tokens)"},
2515+ }
2516+ reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
2517+ reasoningChoice := 0
2518+ for idx, opt := range reasoningOptions {
2519+ if opt.value == reasoning {
2520+ reasoningChoice = idx
2521+ break
2522+ }
2523+ }
2524+ reasoningHint := ""
2525+ if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil && !m.Reasoning {
2526+ reasoningHint = "current model does not support thinking"
2527+ }
2528+
2506 i.settingsDialog.Open([]settingsItem{2529 i.settingsDialog.Open([]settingsItem{
2507 {2530 {
2508 key: "inline_images_enabled",2531 key: "inline_images_enabled",
@@ -2520,9 +2543,25 @@ func (i *Interactive) openSettingsDialog() {
2520 disabled: autoSwarmDisabled,2543 disabled: autoSwarmDisabled,
2521 hint: autoSwarmHint,2544 hint: autoSwarmHint,
2522 },2545 },
2546+ {
2547+ key: "reasoning",
2548+ label: "thinking level",
2549+ desc: "reasoning depth for thinking-capable models",
2550+ options: reasoningOptions,
2551+ choice: reasoningChoice,
2552+ hint: reasoningHint,
2553+ },
2523 })2554 })
2524 }2555 }
2525 2556
2557+func (i *Interactive) applySettingChange(act settingsAction) {
2558+ if act.Key == "reasoning" {
2559+ i.applyReasoningSetting(act.StringValue)
2560+ return
2561+ }
2562+ i.applySettingToggle(act.Key, act.Value)
2563+}
2564+
2526 func (i *Interactive) applySettingToggle(key string, value bool) {2565 func (i *Interactive) applySettingToggle(key string, value bool) {
2527 // Every setting toggle forces a full repaint at the end — same2566 // Every setting toggle forces a full repaint at the end — same
2528 // effect as the user pressing Ctrl+L — so any per-setting visual2567 // effect as the user pressing Ctrl+L — so any per-setting visual
@@ -2579,6 +2618,36 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
2579 }2618 }
2580 }2619 }
2581 2620
2621+func (i *Interactive) applyReasoningSetting(level string) {
2622+ defer func() {
2623+ if i.rend != nil {
2624+ i.rend.Clear()
2625+ }
2626+ i.invalidate()
2627+ }()
2628+ level = provider.NormalizeReasoning(level)
2629+ i.cfg.Reasoning = level
2630+ if i.cfg.SettingsStore != nil {
2631+ if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
2632+ i.mu.Lock()
2633+ i.statusErr = "settings: " + err.Error()
2634+ i.mu.Unlock()
2635+ return
2636+ }
2637+ }
2638+ i.mu.Lock()
2639+ if i.agent != nil {
2640+ i.agent.Reasoning = level
2641+ }
2642+ label := level
2643+ if label == "" {
2644+ label = "off"
2645+ }
2646+ i.statusOK = "thinking level " + label
2647+ i.statusErr = ""
2648+ i.mu.Unlock()
2649+}
2650+
2582 // buildStudyPrompt returns the canned prompt the /study command2651 // buildStudyPrompt returns the canned prompt the /study command
2583 // submits to the agent.2652 // submits to the agent.
2584 //2653 //
M internal/agent/modes/settings_dialog.go +118 -9
@@ -5,9 +5,11 @@ import (
55 )
66
77 type settingsDialog struct {
8- active bool
9- items []settingsItem
10- cursor int
8+ active bool
9+ items []settingsItem
10+ cursor int
11+ selecting bool
12+ optionCursor int
1113 }
1214
1315 type settingsItem struct {
@@ -15,15 +17,24 @@ type settingsItem struct {
1517 label string
1618 desc string
1719 value bool
20+ options []settingsOption
21+ choice int
1822 disabled bool
1923 hint string
2024 }
2125
26+type settingsOption struct {
27+ value string
28+ label string
29+ desc string
30+}
31+
2232 type settingsAction struct {
23- Toggle bool
24- Key string
25- Value bool
26- Close bool
33+ Toggle bool
34+ Key string
35+ Value bool
36+ StringValue string
37+ Close bool
2738 }
2839
2940 func newSettingsDialog() *settingsDialog { return &settingsDialog{} }
@@ -34,14 +45,22 @@ func (d *settingsDialog) Open(items []settingsItem) bool {
3445 }
3546 d.items = items
3647 d.cursor = 0
48+ d.selecting = false
49+ d.optionCursor = 0
3750 d.active = true
3851 return true
3952 }
4053
41-func (d *settingsDialog) Close() { d.active = false }
54+func (d *settingsDialog) Close() {
55+ d.active = false
56+ d.selecting = false
57+}
4258 func (d *settingsDialog) Active() bool { return d != nil && d.active }
4359
4460 func (d *settingsDialog) HandleKey(k tui.Key) settingsAction {
61+ if d.selecting {
62+ return d.handleOptionKey(k)
63+ }
4564 switch k.Kind {
4665 case tui.KeyUp:
4766 if d.cursor > 0 {
@@ -64,6 +83,29 @@ func (d *settingsDialog) HandleKey(k tui.Key) settingsAction {
6483 return settingsAction{}
6584 }
6685
86+func (d *settingsDialog) handleOptionKey(k tui.Key) settingsAction {
87+ it := d.items[d.cursor]
88+ switch k.Kind {
89+ case tui.KeyUp:
90+ if d.optionCursor > 0 {
91+ d.optionCursor--
92+ }
93+ case tui.KeyDown:
94+ if d.optionCursor < len(it.options)-1 {
95+ d.optionCursor++
96+ }
97+ case tui.KeyEsc:
98+ d.selecting = false
99+ case tui.KeyEnter:
100+ return d.selectCurrentOption()
101+ case tui.KeyRune:
102+ if k.Rune == ' ' {
103+ return d.selectCurrentOption()
104+ }
105+ }
106+ return settingsAction{}
107+}
108+
67109 func (d *settingsDialog) toggleCurrent() settingsAction {
68110 if len(d.items) == 0 {
69111 d.Close()
@@ -73,24 +115,61 @@ func (d *settingsDialog) toggleCurrent() settingsAction {
73115 if it.disabled {
74116 return settingsAction{}
75117 }
118+ if len(it.options) > 0 {
119+ d.optionCursor = it.choice
120+ if d.optionCursor < 0 || d.optionCursor >= len(it.options) {
121+ d.optionCursor = 0
122+ }
123+ d.selecting = true
124+ return settingsAction{}
125+ }
76126 it.value = !it.value
77127 d.items[d.cursor] = it
78128 return settingsAction{Toggle: true, Key: it.key, Value: it.value}
79129 }
80130
131+func (d *settingsDialog) selectCurrentOption() settingsAction {
132+ if len(d.items) == 0 {
133+ d.Close()
134+ return settingsAction{Close: true}
135+ }
136+ it := d.items[d.cursor]
137+ if len(it.options) == 0 {
138+ d.selecting = false
139+ return settingsAction{}
140+ }
141+ if d.optionCursor < 0 || d.optionCursor >= len(it.options) {
142+ d.optionCursor = 0
143+ }
144+ it.choice = d.optionCursor
145+ d.items[d.cursor] = it
146+ d.selecting = false
147+ return settingsAction{Toggle: true, Key: it.key, StringValue: it.options[it.choice].value}
148+}
149+
81150 func (d *settingsDialog) Render(th tui.Theme, width int) []string {
82151 if !d.Active() {
83152 return nil
84153 }
154+ if d.selecting {
155+ return d.renderOptions(th, width)
156+ }
85157 var lines []string
86158 lines = append(lines, frameHeader(th, "settings", width))
87- lines = append(lines, th.FG256(th.Muted, "toggle with enter/space, esc to close:"))
159+ lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:"))
88160 for i, it := range d.items {
89161 box := "[ ]"
90162 if it.value {
91163 box = "[x]"
92164 }
93165 plain := " " + box + " " + it.label
166+ if len(it.options) > 0 {
167+ box = "[>]"
168+ if it.choice < 0 || it.choice >= len(it.options) {
169+ it.choice = 0
170+ }
171+ plain = " " + box + " " + it.label + ": " + it.options[it.choice].label
172+ }
94173 if it.hint != "" {
95174 plain += " " + th.FG256(th.Muted, "("+it.hint+")")
96175 }
@@ -108,3 +187,33 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
108187 lines = append(lines, frameRule(th, width))
109188 return lines
110189 }
190+
191+func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string {
192+ if len(d.items) == 0 || d.cursor < 0 || d.cursor >= len(d.items) {
193+ d.selecting = false
194+ return d.Render(th, width)
195+ }
196+ it := d.items[d.cursor]
197+ lines := []string{frameHeader(th, "settings: "+it.label, width)}
198+ if it.desc != "" {
199+ lines = append(lines, th.FG256(th.Muted, it.desc))
200+ }
201+ lines = append(lines, th.FG256(th.Muted, "select with enter/space, esc to go back:"))
202+ for idx, opt := range it.options {
203+ marker := " "
204+ if idx == it.choice {
205+ marker = "✓ "
206+ }
207+ plain := " " + marker + opt.label
208+ if idx == d.optionCursor {
209+ lines = append(lines, th.PadHighlight(plain, width))
210+ } else {
211+ lines = append(lines, plain)
212+ }
213+ if opt.desc != "" {
214+ lines = append(lines, th.FG256(th.Muted, " "+opt.desc))
215+ }
216+ }
217+ lines = append(lines, frameRule(th, width))
218+ return lines
219+}
@@ -5,9 +5,11 @@ import (
5 )5 )
6 6
7 type settingsDialog struct {7 type settingsDialog struct {
8- active bool8+ active bool
9- items []settingsItem9+ items []settingsItem
10- cursor int10+ cursor int
11+ selecting bool
12+ optionCursor int
11 }13 }
12 14
13 type settingsItem struct {15 type settingsItem struct {
@@ -15,15 +17,24 @@ type settingsItem struct {
15 label string17 label string
16 desc string18 desc string
17 value bool19 value bool
20+ options []settingsOption
21+ choice int
18 disabled bool22 disabled bool
19 hint string23 hint string
20 }24 }
21 25
26+type settingsOption struct {
27+ value string
28+ label string
29+ desc string
30+}
31+
22 type settingsAction struct {32 type settingsAction struct {
23- Toggle bool33+ Toggle bool
24- Key string34+ Key string
25- Value bool35+ Value bool
26- Close bool36+ StringValue string
37+ Close bool
27 }38 }
28 39
29 func newSettingsDialog() *settingsDialog { return &settingsDialog{} }40 func newSettingsDialog() *settingsDialog { return &settingsDialog{} }
@@ -34,14 +45,22 @@ func (d *settingsDialog) Open(items []settingsItem) bool {
34 }45 }
35 d.items = items46 d.items = items
36 d.cursor = 047 d.cursor = 0
48+ d.selecting = false
49+ d.optionCursor = 0
37 d.active = true50 d.active = true
38 return true51 return true
39 }52 }
40 53
41-func (d *settingsDialog) Close() { d.active = false }54+func (d *settingsDialog) Close() {
55+ d.active = false
56+ d.selecting = false
57+}
42 func (d *settingsDialog) Active() bool { return d != nil && d.active }58 func (d *settingsDialog) Active() bool { return d != nil && d.active }
43 59
44 func (d *settingsDialog) HandleKey(k tui.Key) settingsAction {60 func (d *settingsDialog) HandleKey(k tui.Key) settingsAction {
61+ if d.selecting {
62+ return d.handleOptionKey(k)
63+ }
45 switch k.Kind {64 switch k.Kind {
46 case tui.KeyUp:65 case tui.KeyUp:
47 if d.cursor > 0 {66 if d.cursor > 0 {
@@ -64,6 +83,29 @@ func (d *settingsDialog) HandleKey(k tui.Key) settingsAction {
64 return settingsAction{}83 return settingsAction{}
65 }84 }
66 85
86+func (d *settingsDialog) handleOptionKey(k tui.Key) settingsAction {
87+ it := d.items[d.cursor]
88+ switch k.Kind {
89+ case tui.KeyUp:
90+ if d.optionCursor > 0 {
91+ d.optionCursor--
92+ }
93+ case tui.KeyDown:
94+ if d.optionCursor < len(it.options)-1 {
95+ d.optionCursor++
96+ }
97+ case tui.KeyEsc:
98+ d.selecting = false
99+ case tui.KeyEnter:
100+ return d.selectCurrentOption()
101+ case tui.KeyRune:
102+ if k.Rune == ' ' {
103+ return d.selectCurrentOption()
104+ }
105+ }
106+ return settingsAction{}
107+}
108+
67 func (d *settingsDialog) toggleCurrent() settingsAction {109 func (d *settingsDialog) toggleCurrent() settingsAction {
68 if len(d.items) == 0 {110 if len(d.items) == 0 {
69 d.Close()111 d.Close()
@@ -73,24 +115,61 @@ func (d *settingsDialog) toggleCurrent() settingsAction {
73 if it.disabled {115 if it.disabled {
74 return settingsAction{}116 return settingsAction{}
75 }117 }
118+ if len(it.options) > 0 {
119+ d.optionCursor = it.choice
120+ if d.optionCursor < 0 || d.optionCursor >= len(it.options) {
121+ d.optionCursor = 0
122+ }
123+ d.selecting = true
124+ return settingsAction{}
125+ }
76 it.value = !it.value126 it.value = !it.value
77 d.items[d.cursor] = it127 d.items[d.cursor] = it
78 return settingsAction{Toggle: true, Key: it.key, Value: it.value}128 return settingsAction{Toggle: true, Key: it.key, Value: it.value}
79 }129 }
80 130
131+func (d *settingsDialog) selectCurrentOption() settingsAction {
132+ if len(d.items) == 0 {
133+ d.Close()
134+ return settingsAction{Close: true}
135+ }
136+ it := d.items[d.cursor]
137+ if len(it.options) == 0 {
138+ d.selecting = false
139+ return settingsAction{}
140+ }
141+ if d.optionCursor < 0 || d.optionCursor >= len(it.options) {
142+ d.optionCursor = 0
143+ }
144+ it.choice = d.optionCursor
145+ d.items[d.cursor] = it
146+ d.selecting = false
147+ return settingsAction{Toggle: true, Key: it.key, StringValue: it.options[it.choice].value}
148+}
149+
81 func (d *settingsDialog) Render(th tui.Theme, width int) []string {150 func (d *settingsDialog) Render(th tui.Theme, width int) []string {
82 if !d.Active() {151 if !d.Active() {
83 return nil152 return nil
84 }153 }
154+ if d.selecting {
155+ return d.renderOptions(th, width)
156+ }
85 var lines []string157 var lines []string
86 lines = append(lines, frameHeader(th, "settings", width))158 lines = append(lines, frameHeader(th, "settings", width))
87- lines = append(lines, th.FG256(th.Muted, "toggle with enter/space, esc to close:"))159+ lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:"))
88 for i, it := range d.items {160 for i, it := range d.items {
89 box := "[ ]"161 box := "[ ]"
90 if it.value {162 if it.value {
91 box = "[x]"163 box = "[x]"
92 }164 }
93 plain := " " + box + " " + it.label165 plain := " " + box + " " + it.label
166+ if len(it.options) > 0 {
167+ box = "[>]"
168+ if it.choice < 0 || it.choice >= len(it.options) {
169+ it.choice = 0
170+ }
171+ plain = " " + box + " " + it.label + ": " + it.options[it.choice].label
172+ }
94 if it.hint != "" {173 if it.hint != "" {
95 plain += " " + th.FG256(th.Muted, "("+it.hint+")")174 plain += " " + th.FG256(th.Muted, "("+it.hint+")")
96 }175 }
@@ -108,3 +187,33 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
108 lines = append(lines, frameRule(th, width))187 lines = append(lines, frameRule(th, width))
109 return lines188 return lines
110 }189 }
190+
191+func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string {
192+ if len(d.items) == 0 || d.cursor < 0 || d.cursor >= len(d.items) {
193+ d.selecting = false
194+ return d.Render(th, width)
195+ }
196+ it := d.items[d.cursor]
197+ lines := []string{frameHeader(th, "settings: "+it.label, width)}
198+ if it.desc != "" {
199+ lines = append(lines, th.FG256(th.Muted, it.desc))
200+ }
201+ lines = append(lines, th.FG256(th.Muted, "select with enter/space, esc to go back:"))
202+ for idx, opt := range it.options {
203+ marker := " "
204+ if idx == it.choice {
205+ marker = "✓ "
206+ }
207+ plain := " " + marker + opt.label
208+ if idx == d.optionCursor {
209+ lines = append(lines, th.PadHighlight(plain, width))
210+ } else {
211+ lines = append(lines, plain)
212+ }
213+ if opt.desc != "" {
214+ lines = append(lines, th.FG256(th.Muted, " "+opt.desc))
215+ }
216+ }
217+ lines = append(lines, frameRule(th, width))
218+ return lines
219+}
M internal/agent/settings_store.go +11 -0
@@ -1,5 +1,7 @@
11 package agent
22
3+import "github.com/patriceckhart/zot/internal/provider"
4+
35 type configSettingsStore struct{}
46
57 func (configSettingsStore) SetInlineImages(enabled bool) error {
@@ -20,6 +22,15 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error {
2022 return SaveConfig(cfg)
2123 }
2224
25+func (configSettingsStore) SetReasoning(level string) error {
26+ cfg, err := LoadConfig()
27+ if err != nil {
28+ return err
29+ }
30+ cfg.Reasoning = provider.NormalizeReasoning(level)
31+ return SaveConfig(cfg)
32+}
33+
2334 // AutoSwarmEnabled reads the current auto-swarm flag from config.
2435 // Used by the swarm_spawn tool at call time to gate execution.
2536 func AutoSwarmEnabled() bool {
@@ -1,5 +1,7 @@
1 package agent1 package agent
2 2
3+import "github.com/patriceckhart/zot/internal/provider"
4+
3 type configSettingsStore struct{}5 type configSettingsStore struct{}
4 6
5 func (configSettingsStore) SetInlineImages(enabled bool) error {7 func (configSettingsStore) SetInlineImages(enabled bool) error {
@@ -20,6 +22,15 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error {
20 return SaveConfig(cfg)22 return SaveConfig(cfg)
21 }23 }
22 24
25+func (configSettingsStore) SetReasoning(level string) error {
26+ cfg, err := LoadConfig()
27+ if err != nil {
28+ return err
29+ }
30+ cfg.Reasoning = provider.NormalizeReasoning(level)
31+ return SaveConfig(cfg)
32+}
33+
23 // AutoSwarmEnabled reads the current auto-swarm flag from config.34 // AutoSwarmEnabled reads the current auto-swarm flag from config.
24 // Used by the swarm_spawn tool at call time to gate execution.35 // Used by the swarm_spawn tool at call time to gate execution.
25 func AutoSwarmEnabled() bool {36 func AutoSwarmEnabled() bool {
M internal/provider/anthropic.go +11 -12
@@ -249,10 +249,18 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
249249 if req.Reasoning != "" && m.Reasoning {
250250 budget := anthropicReasoningBudget(req.Reasoning)
251251 if budget > 0 {
252+ // Reasoning requires max_tokens > budget. Keep at least a small
253+ // answer budget while respecting the model's advertised output cap.
254+ const minAnswerTokens = 1024
255+ if m.MaxOutput > minAnswerTokens && budget >= m.MaxOutput {
256+ budget = m.MaxOutput - minAnswerTokens
257+ }
252258 out.Thinking = &anthThinking{Type: "enabled", BudgetTokens: budget}
253- // Reasoning requires max_tokens > budget.
254259 if out.MaxTokens <= budget {
255- out.MaxTokens = budget + 4096
260+ out.MaxTokens = budget + minAnswerTokens
261+ if m.MaxOutput > 0 && out.MaxTokens > m.MaxOutput {
262+ out.MaxTokens = m.MaxOutput
263+ }
256264 }
257265 }
258266 }
@@ -361,16 +369,7 @@ func markLastBlockEphemeral(blocks []interface{}) {
361369 }
362370
363371 func anthropicReasoningBudget(level string) int {
364- switch strings.ToLower(level) {
365- case "low":
366- return 2048
367- case "medium":
368- return 8192
369- case "high":
370- return 16384
371- default:
372- return 0
373- }
372+ return ReasoningBudget(level)
374373 }
375374
376375 func filterAnthAssistantContent(blocks []Content) []Content {
@@ -249,10 +249,18 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
249 if req.Reasoning != "" && m.Reasoning {249 if req.Reasoning != "" && m.Reasoning {
250 budget := anthropicReasoningBudget(req.Reasoning)250 budget := anthropicReasoningBudget(req.Reasoning)
251 if budget > 0 {251 if budget > 0 {
252+ // Reasoning requires max_tokens > budget. Keep at least a small
253+ // answer budget while respecting the model's advertised output cap.
254+ const minAnswerTokens = 1024
255+ if m.MaxOutput > minAnswerTokens && budget >= m.MaxOutput {
256+ budget = m.MaxOutput - minAnswerTokens
257+ }
252 out.Thinking = &anthThinking{Type: "enabled", BudgetTokens: budget}258 out.Thinking = &anthThinking{Type: "enabled", BudgetTokens: budget}
253- // Reasoning requires max_tokens > budget.
254 if out.MaxTokens <= budget {259 if out.MaxTokens <= budget {
255- out.MaxTokens = budget + 4096260+ out.MaxTokens = budget + minAnswerTokens
261+ if m.MaxOutput > 0 && out.MaxTokens > m.MaxOutput {
262+ out.MaxTokens = m.MaxOutput
263+ }
256 }264 }
257 }265 }
258 }266 }
@@ -361,16 +369,7 @@ func markLastBlockEphemeral(blocks []interface{}) {
361 }369 }
362 370
363 func anthropicReasoningBudget(level string) int {371 func anthropicReasoningBudget(level string) int {
364- switch strings.ToLower(level) {372+ return ReasoningBudget(level)
365- case "low":
366- return 2048
367- case "medium":
368- return 8192
369- case "high":
370- return 16384
371- default:
372- return 0
373- }
374 }373 }
375 374
376 func filterAnthAssistantContent(blocks []Content) []Content {375 func filterAnthAssistantContent(blocks []Content) []Content {
M internal/provider/gemini.go +12 -21
@@ -354,7 +354,7 @@ func convertGemToolResultParts(blocks []Content) []gemPart {
354354 // generation: 2.5 family uses thinkingBudget (tokens), 3.x uses
355355 // thinkingLevel (enum). Returns nil when the level is unrecognised.
356356 func geminiThinkingConfig(modelID, level string) *gemThinkingConfig {
357- level = strings.ToLower(level)
357+ level = NormalizeReasoning(level)
358358 id := strings.ToLower(modelID)
359359
360360 // Gemini 3.x: enum-based thinkingLevel. Pro can't go below LOW.
@@ -362,6 +362,12 @@ func geminiThinkingConfig(modelID, level string) *gemThinkingConfig {
362362 isPro := strings.Contains(id, "-pro")
363363 var lvl string
364364 switch level {
365+ case "minimum":
366+ if isPro {
367+ lvl = "LOW"
368+ } else {
369+ lvl = "MINIMAL"
370+ }
365371 case "low":
366372 lvl = "LOW"
367373 case "medium":
@@ -370,7 +376,7 @@ func geminiThinkingConfig(modelID, level string) *gemThinkingConfig {
370376 } else {
371377 lvl = "MEDIUM"
372378 }
373- case "high":
379+ case "high", "maximum":
374380 lvl = "HIGH"
375381 default:
376382 return nil
@@ -379,33 +385,18 @@ func geminiThinkingConfig(modelID, level string) *gemThinkingConfig {
379385 }
380386
381387 // Gemini 2.5 family: token-budget per-model.
382- var budget int
388+ budget := ReasoningBudget(level)
383389 switch {
384390 case strings.Contains(id, "2.5-pro"):
385- switch level {
386- case "low":
387- budget = 2048
388- case "medium":
389- budget = 8192
390- case "high":
391+ if budget > 32768 {
391392 budget = 32768
392393 }
393394 case strings.Contains(id, "2.5-flash-lite"):
394- switch level {
395- case "low":
396- budget = 2048
397- case "medium":
398- budget = 8192
399- case "high":
395+ if budget > 24576 {
400396 budget = 24576
401397 }
402398 case strings.Contains(id, "2.5-flash"):
403- switch level {
404- case "low":
405- budget = 2048
406- case "medium":
407- budget = 8192
408- case "high":
399+ if budget > 24576 {
409400 budget = 24576
410401 }
411402 default:
@@ -354,7 +354,7 @@ func convertGemToolResultParts(blocks []Content) []gemPart {
354 // generation: 2.5 family uses thinkingBudget (tokens), 3.x uses354 // generation: 2.5 family uses thinkingBudget (tokens), 3.x uses
355 // thinkingLevel (enum). Returns nil when the level is unrecognised.355 // thinkingLevel (enum). Returns nil when the level is unrecognised.
356 func geminiThinkingConfig(modelID, level string) *gemThinkingConfig {356 func geminiThinkingConfig(modelID, level string) *gemThinkingConfig {
357- level = strings.ToLower(level)357+ level = NormalizeReasoning(level)
358 id := strings.ToLower(modelID)358 id := strings.ToLower(modelID)
359 359
360 // Gemini 3.x: enum-based thinkingLevel. Pro can't go below LOW.360 // Gemini 3.x: enum-based thinkingLevel. Pro can't go below LOW.
@@ -362,6 +362,12 @@ func geminiThinkingConfig(modelID, level string) *gemThinkingConfig {
362 isPro := strings.Contains(id, "-pro")362 isPro := strings.Contains(id, "-pro")
363 var lvl string363 var lvl string
364 switch level {364 switch level {
365+ case "minimum":
366+ if isPro {
367+ lvl = "LOW"
368+ } else {
369+ lvl = "MINIMAL"
370+ }
365 case "low":371 case "low":
366 lvl = "LOW"372 lvl = "LOW"
367 case "medium":373 case "medium":
@@ -370,7 +376,7 @@ func geminiThinkingConfig(modelID, level string) *gemThinkingConfig {
370 } else {376 } else {
371 lvl = "MEDIUM"377 lvl = "MEDIUM"
372 }378 }
373- case "high":379+ case "high", "maximum":
374 lvl = "HIGH"380 lvl = "HIGH"
375 default:381 default:
376 return nil382 return nil
@@ -379,33 +385,18 @@ func geminiThinkingConfig(modelID, level string) *gemThinkingConfig {
379 }385 }
380 386
381 // Gemini 2.5 family: token-budget per-model.387 // Gemini 2.5 family: token-budget per-model.
382- var budget int388+ budget := ReasoningBudget(level)
383 switch {389 switch {
384 case strings.Contains(id, "2.5-pro"):390 case strings.Contains(id, "2.5-pro"):
385- switch level {391+ if budget > 32768 {
386- case "low":
387- budget = 2048
388- case "medium":
389- budget = 8192
390- case "high":
391 budget = 32768392 budget = 32768
392 }393 }
393 case strings.Contains(id, "2.5-flash-lite"):394 case strings.Contains(id, "2.5-flash-lite"):
394- switch level {395+ if budget > 24576 {
395- case "low":
396- budget = 2048
397- case "medium":
398- budget = 8192
399- case "high":
400 budget = 24576396 budget = 24576
401 }397 }
402 case strings.Contains(id, "2.5-flash"):398 case strings.Contains(id, "2.5-flash"):
403- switch level {399+ if budget > 24576 {
404- case "low":
405- budget = 2048
406- case "medium":
407- budget = 8192
408- case "high":
409 budget = 24576400 budget = 24576
410 }401 }
411 default:402 default:
M internal/provider/gemini_test.go +3 -1
@@ -264,7 +264,9 @@ func TestGeminiThinkingConfig(t *testing.T) {
264264 {"gemini-3-pro", "low", "LOW", 0},
265265 {"gemini-3-pro", "medium", "HIGH", 0}, // Pro can't go below LOW; medium → HIGH
266266 {"gemini-3-flash", "medium", "MEDIUM", 0},
267- {"gemini-2.5-pro", "high", "", 32768},
267+ {"gemini-2.5-pro", "high", "", 16384},
268+ {"gemini-2.5-pro", "maximum", "", 32768},
269+ {"gemini-2.5-flash", "minimum", "", 1024},
268270 {"gemini-2.5-flash", "low", "", 2048},
269271 {"gemini-2.0-flash", "high", "", 0}, // 2.0 has no thinking → nil
270272 }
@@ -264,7 +264,9 @@ func TestGeminiThinkingConfig(t *testing.T) {
264 {"gemini-3-pro", "low", "LOW", 0},264 {"gemini-3-pro", "low", "LOW", 0},
265 {"gemini-3-pro", "medium", "HIGH", 0}, // Pro can't go below LOW; medium → HIGH265 {"gemini-3-pro", "medium", "HIGH", 0}, // Pro can't go below LOW; medium → HIGH
266 {"gemini-3-flash", "medium", "MEDIUM", 0},266 {"gemini-3-flash", "medium", "MEDIUM", 0},
267- {"gemini-2.5-pro", "high", "", 32768},267+ {"gemini-2.5-pro", "high", "", 16384},
268+ {"gemini-2.5-pro", "maximum", "", 32768},
269+ {"gemini-2.5-flash", "minimum", "", 1024},
268 {"gemini-2.5-flash", "low", "", 2048},270 {"gemini-2.5-flash", "low", "", 2048},
269 {"gemini-2.0-flash", "high", "", 0}, // 2.0 has no thinking → nil271 {"gemini-2.0-flash", "high", "", 0}, // 2.0 has no thinking → nil
270 }272 }
M internal/provider/openai.go +2 -2
@@ -185,8 +185,8 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
185185 }
186186 if m.Reasoning {
187187 out.MaxCompletionTok = &maxTok
188- if req.Reasoning != "" {
189- out.ReasoningEffort = strings.ToLower(req.Reasoning)
188+ if effort := OpenAIReasoningEffort(req.Reasoning); effort != "" {
189+ out.ReasoningEffort = effort
190190 }
191191 } else {
192192 out.MaxTokens = &maxTok
@@ -185,8 +185,8 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
185 }185 }
186 if m.Reasoning {186 if m.Reasoning {
187 out.MaxCompletionTok = &maxTok187 out.MaxCompletionTok = &maxTok
188- if req.Reasoning != "" {188+ if effort := OpenAIReasoningEffort(req.Reasoning); effort != "" {
189- out.ReasoningEffort = strings.ToLower(req.Reasoning)189+ out.ReasoningEffort = effort
190 }190 }
191 } else {191 } else {
192 out.MaxTokens = &maxTok192 out.MaxTokens = &maxTok
M internal/provider/openai_codex.go +19 -9
@@ -131,16 +131,21 @@ type codexTool struct {
131131 Parameters json.RawMessage `json:"parameters"`
132132 }
133133
134+type codexReasoningConfig struct {
135+ Effort string `json:"effort,omitempty"`
136+}
137+
134138 type codexRequest struct {
135- Model string `json:"model"`
136- Store bool `json:"store"`
137- Stream bool `json:"stream"`
138- Instructions string `json:"instructions,omitempty"`
139- Input []any `json:"input"`
140- Tools []codexTool `json:"tools,omitempty"`
141- ToolChoice string `json:"tool_choice,omitempty"`
142- ParallelToolCalls bool `json:"parallel_tool_calls"`
143- Include []string `json:"include,omitempty"`
139+ Model string `json:"model"`
140+ Store bool `json:"store"`
141+ Stream bool `json:"stream"`
142+ Instructions string `json:"instructions,omitempty"`
143+ Input []any `json:"input"`
144+ Tools []codexTool `json:"tools,omitempty"`
145+ ToolChoice string `json:"tool_choice,omitempty"`
146+ ParallelToolCalls bool `json:"parallel_tool_calls"`
147+ Include []string `json:"include,omitempty"`
148+ Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`
144149 }
145150
146151 // ---- Request building ----
@@ -163,6 +168,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
163168 ParallelToolCalls: true,
164169 Include: []string{"reasoning.encrypted_content"},
165170 }
171+ if m.Reasoning {
172+ if effort := OpenAICodexReasoningEffort(req.Reasoning); effort != "" {
173+ body.Reasoning = &codexReasoningConfig{Effort: effort}
174+ }
175+ }
166176 if len(req.Tools) > 0 {
167177 body.ToolChoice = "auto"
168178 for _, t := range req.Tools {
@@ -131,16 +131,21 @@ type codexTool struct {
131 Parameters json.RawMessage `json:"parameters"`131 Parameters json.RawMessage `json:"parameters"`
132 }132 }
133 133
134+type codexReasoningConfig struct {
135+ Effort string `json:"effort,omitempty"`
136+}
137+
134 type codexRequest struct {138 type codexRequest struct {
135- Model string `json:"model"`139+ Model string `json:"model"`
136- Store bool `json:"store"`140+ Store bool `json:"store"`
137- Stream bool `json:"stream"`141+ Stream bool `json:"stream"`
138- Instructions string `json:"instructions,omitempty"`142+ Instructions string `json:"instructions,omitempty"`
139- Input []any `json:"input"`143+ Input []any `json:"input"`
140- Tools []codexTool `json:"tools,omitempty"`144+ Tools []codexTool `json:"tools,omitempty"`
141- ToolChoice string `json:"tool_choice,omitempty"`145+ ToolChoice string `json:"tool_choice,omitempty"`
142- ParallelToolCalls bool `json:"parallel_tool_calls"`146+ ParallelToolCalls bool `json:"parallel_tool_calls"`
143- Include []string `json:"include,omitempty"`147+ Include []string `json:"include,omitempty"`
148+ Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`
144 }149 }
145 150
146 // ---- Request building ----151 // ---- Request building ----
@@ -163,6 +168,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
163 ParallelToolCalls: true,168 ParallelToolCalls: true,
164 Include: []string{"reasoning.encrypted_content"},169 Include: []string{"reasoning.encrypted_content"},
165 }170 }
171+ if m.Reasoning {
172+ if effort := OpenAICodexReasoningEffort(req.Reasoning); effort != "" {
173+ body.Reasoning = &codexReasoningConfig{Effort: effort}
174+ }
175+ }
166 if len(req.Tools) > 0 {176 if len(req.Tools) > 0 {
167 body.ToolChoice = "auto"177 body.ToolChoice = "auto"
168 for _, t := range req.Tools {178 for _, t := range req.Tools {
M internal/provider/provider.go +4 -3
@@ -216,9 +216,10 @@ type Request struct {
216216 Tools []Tool
217217 MaxTokens int
218218 Temperature *float32
219- // Reasoning is "", "low", "medium", or "high".
220- // Anthropic maps it to extended-thinking budget tokens.
221- // OpenAI maps it to reasoning_effort.
219+ // Reasoning is "", "minimum", "low", "medium", "high", or "maximum".
220+ // Empty disables reasoning. Budget-based providers map these to roughly
221+ // 1k/2k/8k/16k/32k thinking tokens; effort-based providers map them onto
222+ // their closest supported reasoning_effort values.
222223 Reasoning string
223224 }
224225
@@ -216,9 +216,10 @@ type Request struct {
216 Tools []Tool216 Tools []Tool
217 MaxTokens int217 MaxTokens int
218 Temperature *float32218 Temperature *float32
219- // Reasoning is "", "low", "medium", or "high".219+ // Reasoning is "", "minimum", "low", "medium", "high", or "maximum".
220- // Anthropic maps it to extended-thinking budget tokens.220+ // Empty disables reasoning. Budget-based providers map these to roughly
221- // OpenAI maps it to reasoning_effort.221+ // 1k/2k/8k/16k/32k thinking tokens; effort-based providers map them onto
222+ // their closest supported reasoning_effort values.
222 Reasoning string223 Reasoning string
223 }224 }
224 225
A internal/provider/reasoning.go +78 -0
@@ -0,0 +1,78 @@
1+package provider
2+
3+import "strings"
4+
5+// NormalizeReasoning canonicalizes zot's user-facing thinking levels.
6+// Empty string means reasoning/thinking is disabled.
7+func NormalizeReasoning(level string) string {
8+ switch strings.ToLower(strings.TrimSpace(level)) {
9+ case "", "off", "none", "no", "false", "disabled":
10+ return ""
11+ case "min", "minimal", "minimum":
12+ return "minimum"
13+ case "low":
14+ return "low"
15+ case "med", "medium":
16+ return "medium"
17+ case "hi", "high":
18+ return "high"
19+ case "max", "maximum":
20+ return "maximum"
21+ default:
22+ return strings.ToLower(strings.TrimSpace(level))
23+ }
24+}
25+
26+// ReasoningBudget returns zot's approximate token budget for thinking-capable
27+// providers that accept explicit budgets.
28+func ReasoningBudget(level string) int {
29+ switch NormalizeReasoning(level) {
30+ case "minimum":
31+ return 1024
32+ case "low":
33+ return 2048
34+ case "medium":
35+ return 8192
36+ case "high":
37+ return 16384
38+ case "maximum":
39+ return 32768
40+ default:
41+ return 0
42+ }
43+}
44+
45+// OpenAIReasoningEffort maps zot's six-level setting onto the effort enum
46+// accepted by OpenAI-compatible chat-completions endpoints.
47+func OpenAIReasoningEffort(level string) string {
48+ switch NormalizeReasoning(level) {
49+ case "minimum", "low":
50+ // Many OpenAI-compatible endpoints only accept low/medium/high.
51+ // Use low for zot's minimum instead of the newer minimal enum.
52+ return "low"
53+ case "medium":
54+ return "medium"
55+ case "high", "maximum":
56+ return "high"
57+ default:
58+ return ""
59+ }
60+}
61+
62+// OpenAICodexReasoningEffort maps zot levels onto the ChatGPT/Codex
63+// Responses backend enum. That backend rejects "minimal" and uses
64+// "xhigh" for the highest tier on recent GPT-5.x models.
65+func OpenAICodexReasoningEffort(level string) string {
66+ switch NormalizeReasoning(level) {
67+ case "minimum", "low":
68+ return "low"
69+ case "medium":
70+ return "medium"
71+ case "high":
72+ return "high"
73+ case "maximum":
74+ return "xhigh"
75+ default:
76+ return ""
77+ }
78+}
@@ -0,0 +1,78 @@
1+package provider
2+
3+import "strings"
4+
5+// NormalizeReasoning canonicalizes zot's user-facing thinking levels.
6+// Empty string means reasoning/thinking is disabled.
7+func NormalizeReasoning(level string) string {
8+ switch strings.ToLower(strings.TrimSpace(level)) {
9+ case "", "off", "none", "no", "false", "disabled":
10+ return ""
11+ case "min", "minimal", "minimum":
12+ return "minimum"
13+ case "low":
14+ return "low"
15+ case "med", "medium":
16+ return "medium"
17+ case "hi", "high":
18+ return "high"
19+ case "max", "maximum":
20+ return "maximum"
21+ default:
22+ return strings.ToLower(strings.TrimSpace(level))
23+ }
24+}
25+
26+// ReasoningBudget returns zot's approximate token budget for thinking-capable
27+// providers that accept explicit budgets.
28+func ReasoningBudget(level string) int {
29+ switch NormalizeReasoning(level) {
30+ case "minimum":
31+ return 1024
32+ case "low":
33+ return 2048
34+ case "medium":
35+ return 8192
36+ case "high":
37+ return 16384
38+ case "maximum":
39+ return 32768
40+ default:
41+ return 0
42+ }
43+}
44+
45+// OpenAIReasoningEffort maps zot's six-level setting onto the effort enum
46+// accepted by OpenAI-compatible chat-completions endpoints.
47+func OpenAIReasoningEffort(level string) string {
48+ switch NormalizeReasoning(level) {
49+ case "minimum", "low":
50+ // Many OpenAI-compatible endpoints only accept low/medium/high.
51+ // Use low for zot's minimum instead of the newer minimal enum.
52+ return "low"
53+ case "medium":
54+ return "medium"
55+ case "high", "maximum":
56+ return "high"
57+ default:
58+ return ""
59+ }
60+}
61+
62+// OpenAICodexReasoningEffort maps zot levels onto the ChatGPT/Codex
63+// Responses backend enum. That backend rejects "minimal" and uses
64+// "xhigh" for the highest tier on recent GPT-5.x models.
65+func OpenAICodexReasoningEffort(level string) string {
66+ switch NormalizeReasoning(level) {
67+ case "minimum", "low":
68+ return "low"
69+ case "medium":
70+ return "medium"
71+ case "high":
72+ return "high"
73+ case "maximum":
74+ return "xhigh"
75+ default:
76+ return ""
77+ }
78+}
A internal/provider/reasoning_test.go +36 -0
@@ -0,0 +1,36 @@
1+package provider
2+
3+import "testing"
4+
5+func TestReasoningEffortMappings(t *testing.T) {
6+ cases := []struct {
7+ level string
8+ openai string
9+ codex string
10+ budget int
11+ normalized string
12+ }{
13+ {"off", "", "", 0, ""},
14+ {"minimum", "low", "low", 1024, "minimum"},
15+ {"minimal", "low", "low", 1024, "minimum"},
16+ {"low", "low", "low", 2048, "low"},
17+ {"medium", "medium", "medium", 8192, "medium"},
18+ {"high", "high", "high", 16384, "high"},
19+ {"maximum", "high", "xhigh", 32768, "maximum"},
20+ {"max", "high", "xhigh", 32768, "maximum"},
21+ }
22+ for _, tc := range cases {
23+ if got := NormalizeReasoning(tc.level); got != tc.normalized {
24+ t.Errorf("NormalizeReasoning(%q)=%q want %q", tc.level, got, tc.normalized)
25+ }
26+ if got := OpenAIReasoningEffort(tc.level); got != tc.openai {
27+ t.Errorf("OpenAIReasoningEffort(%q)=%q want %q", tc.level, got, tc.openai)
28+ }
29+ if got := OpenAICodexReasoningEffort(tc.level); got != tc.codex {
30+ t.Errorf("OpenAICodexReasoningEffort(%q)=%q want %q", tc.level, got, tc.codex)
31+ }
32+ if got := ReasoningBudget(tc.level); got != tc.budget {
33+ t.Errorf("ReasoningBudget(%q)=%d want %d", tc.level, got, tc.budget)
34+ }
35+ }
36+}
@@ -0,0 +1,36 @@
1+package provider
2+
3+import "testing"
4+
5+func TestReasoningEffortMappings(t *testing.T) {
6+ cases := []struct {
7+ level string
8+ openai string
9+ codex string
10+ budget int
11+ normalized string
12+ }{
13+ {"off", "", "", 0, ""},
14+ {"minimum", "low", "low", 1024, "minimum"},
15+ {"minimal", "low", "low", 1024, "minimum"},
16+ {"low", "low", "low", 2048, "low"},
17+ {"medium", "medium", "medium", 8192, "medium"},
18+ {"high", "high", "high", 16384, "high"},
19+ {"maximum", "high", "xhigh", 32768, "maximum"},
20+ {"max", "high", "xhigh", 32768, "maximum"},
21+ }
22+ for _, tc := range cases {
23+ if got := NormalizeReasoning(tc.level); got != tc.normalized {
24+ t.Errorf("NormalizeReasoning(%q)=%q want %q", tc.level, got, tc.normalized)
25+ }
26+ if got := OpenAIReasoningEffort(tc.level); got != tc.openai {
27+ t.Errorf("OpenAIReasoningEffort(%q)=%q want %q", tc.level, got, tc.openai)
28+ }
29+ if got := OpenAICodexReasoningEffort(tc.level); got != tc.codex {
30+ t.Errorf("OpenAICodexReasoningEffort(%q)=%q want %q", tc.level, got, tc.codex)
31+ }
32+ if got := ReasoningBudget(tc.level); got != tc.budget {
33+ t.Errorf("ReasoningBudget(%q)=%d want %d", tc.level, got, tc.budget)
34+ }
35+ }
36+}
M internal/tui/statusbar_test.go +129 -0
@@ -57,6 +57,135 @@ func TestStatusBarNoCWD(t *testing.T) {
5757 }
5858 }
5959
60+func TestStatusBarThinkingLevelBetweenModelAndStats(t *testing.T) {
61+ lines := StatusBar(StatusBarParams{
62+ Theme: Dark,
63+ Provider: "openai-codex",
64+ Model: "gpt-5.5",
65+ Reasoning: "minimum",
66+ CWD: "/tmp/x",
67+ Usage: provider.Usage{
68+ InputTokens: 4_300_000,
69+ OutputTokens: 2,
70+ },
71+ Cols: 500,
72+ })
73+ if len(lines) != 2 {
74+ t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
75+ }
76+ plain := stripANSI(lines[0])
77+ modelIdx := strings.Index(plain, "(openai-codex) gpt-5.5")
78+ thinkingIdx := strings.Index(plain, "thinking: minimal")
79+ statsIdx := strings.Index(plain, "↑4.3M")
80+ if modelIdx < 0 || thinkingIdx < 0 || statsIdx < 0 {
81+ t.Fatalf("line should contain model, thinking level, and stats, got %q", plain)
82+ }
83+ if !(modelIdx < thinkingIdx && thinkingIdx < statsIdx) {
84+ t.Fatalf("thinking level should sit between model and stats, got %q", plain)
85+ }
86+}
87+
88+func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) {
89+ lines := StatusBar(StatusBarParams{
90+ Theme: Dark,
91+ Provider: "openai-codex",
92+ Model: "gpt-5.5",
93+ Reasoning: "maximum",
94+ CWD: "/tmp/x",
95+ Usage: provider.Usage{
96+ CostUSD: 0,
97+ },
98+ Subscription: true,
99+ ContextUsed: 100,
100+ ContextMax: 1_000_000,
101+ Cols: 64,
102+ })
103+ if len(lines) != 3 {
104+ t.Fatalf("narrow status with model+thinking fit: want 3 lines, got %d: %q", len(lines), lines)
105+ }
106+ plain := make([]string, len(lines))
107+ for i, line := range lines {
108+ plain[i] = stripANSI(line)
109+ }
110+ if !strings.Contains(plain[0], "(openai-codex) gpt-5.5 thinking: maximum") {
111+ t.Fatalf("line 1 should contain model and thinking level, got %q", plain[0])
112+ }
113+ if !strings.Contains(plain[1], "$0.000 (sub)") || strings.Contains(plain[1], "thinking level") {
114+ t.Fatalf("line 2 should contain only stats, got %q", plain[1])
115+ }
116+ if !strings.Contains(plain[2], "/tmp/x") {
117+ t.Fatalf("line 3 should contain cwd, got %q", plain[2])
118+ }
119+}
120+
121+func TestStatusBarNarrowSplitsAfterThinkingLevel(t *testing.T) {
122+ lines := StatusBar(StatusBarParams{
123+ Theme: Dark,
124+ Provider: "openai-codex",
125+ Model: "gpt-5.5",
126+ Reasoning: "minimum",
127+ CWD: "/tmp/x",
128+ Usage: provider.Usage{
129+ InputTokens: 4_300_000,
130+ OutputTokens: 2,
131+ },
132+ Cols: 40,
133+ })
134+ if len(lines) != 4 {
135+ t.Fatalf("narrow status with thinking: want 4 lines, got %d: %q", len(lines), lines)
136+ }
137+ plain := make([]string, len(lines))
138+ for i, line := range lines {
139+ plain[i] = stripANSI(line)
140+ }
141+ if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") {
142+ t.Fatalf("line 1 should contain model info, got %q", plain[0])
143+ }
144+ if !strings.Contains(plain[1], "thinking: minimal") || strings.Contains(plain[1], "↑4.3M") {
145+ t.Fatalf("line 2 should contain only thinking level, got %q", plain[1])
146+ }
147+ if !strings.Contains(plain[2], "↑4.3M ↓2") {
148+ t.Fatalf("line 3 should contain stats, got %q", plain[2])
149+ }
150+ if !strings.Contains(plain[3], "/tmp/x") {
151+ t.Fatalf("line 4 should contain cwd, got %q", plain[3])
152+ }
153+}
154+
155+func TestStatusBarVeryNarrowSplitsAfterThinkingLevel(t *testing.T) {
156+ lines := StatusBar(StatusBarParams{
157+ Theme: Dark,
158+ Provider: "openai-codex",
159+ Model: "gpt-5.5",
160+ Reasoning: "minimum",
161+ CWD: "/tmp/x",
162+ Usage: provider.Usage{
163+ InputTokens: 4_300_000,
164+ OutputTokens: 2,
165+ },
166+ Cols: 32,
167+ })
168+ if len(lines) != 4 {
169+ t.Fatalf("narrow status with thinking: want 4 lines, got %d: %q", len(lines), lines)
170+ }
171+ plain := make([]string, len(lines))
172+ for i, line := range lines {
173+ plain[i] = stripANSI(line)
174+ }
175+ if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") {
176+ t.Fatalf("line 1 should contain model info, got %q", plain[0])
177+ }
178+ if !strings.Contains(plain[1], "thinking: minimal") {
179+ t.Fatalf("line 2 should contain thinking level, got %q", plain[1])
180+ }
181+ if !strings.Contains(plain[2], "↑4.3M ↓2") {
182+ t.Fatalf("line 3 should contain stats, got %q", plain[2])
183+ }
184+ if !strings.Contains(plain[3], "/tmp/x") {
185+ t.Fatalf("line 4 should contain cwd, got %q", plain[3])
186+ }
187+}
188+
60189 func TestStatusBarNoYoloTagPrecedesCWD(t *testing.T) {
61190 lines := StatusBar(StatusBarParams{
62191 Theme: Dark,
@@ -57,6 +57,135 @@ func TestStatusBarNoCWD(t *testing.T) {
57 }57 }
58 }58 }
59 59
60+func TestStatusBarThinkingLevelBetweenModelAndStats(t *testing.T) {
61+ lines := StatusBar(StatusBarParams{
62+ Theme: Dark,
63+ Provider: "openai-codex",
64+ Model: "gpt-5.5",
65+ Reasoning: "minimum",
66+ CWD: "/tmp/x",
67+ Usage: provider.Usage{
68+ InputTokens: 4_300_000,
69+ OutputTokens: 2,
70+ },
71+ Cols: 500,
72+ })
73+ if len(lines) != 2 {
74+ t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
75+ }
76+ plain := stripANSI(lines[0])
77+ modelIdx := strings.Index(plain, "(openai-codex) gpt-5.5")
78+ thinkingIdx := strings.Index(plain, "thinking: minimal")
79+ statsIdx := strings.Index(plain, "↑4.3M")
80+ if modelIdx < 0 || thinkingIdx < 0 || statsIdx < 0 {
81+ t.Fatalf("line should contain model, thinking level, and stats, got %q", plain)
82+ }
83+ if !(modelIdx < thinkingIdx && thinkingIdx < statsIdx) {
84+ t.Fatalf("thinking level should sit between model and stats, got %q", plain)
85+ }
86+}
87+
88+func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) {
89+ lines := StatusBar(StatusBarParams{
90+ Theme: Dark,
91+ Provider: "openai-codex",
92+ Model: "gpt-5.5",
93+ Reasoning: "maximum",
94+ CWD: "/tmp/x",
95+ Usage: provider.Usage{
96+ CostUSD: 0,
97+ },
98+ Subscription: true,
99+ ContextUsed: 100,
100+ ContextMax: 1_000_000,
101+ Cols: 64,
102+ })
103+ if len(lines) != 3 {
104+ t.Fatalf("narrow status with model+thinking fit: want 3 lines, got %d: %q", len(lines), lines)
105+ }
106+ plain := make([]string, len(lines))
107+ for i, line := range lines {
108+ plain[i] = stripANSI(line)
109+ }
110+ if !strings.Contains(plain[0], "(openai-codex) gpt-5.5 thinking: maximum") {
111+ t.Fatalf("line 1 should contain model and thinking level, got %q", plain[0])
112+ }
113+ if !strings.Contains(plain[1], "$0.000 (sub)") || strings.Contains(plain[1], "thinking level") {
114+ t.Fatalf("line 2 should contain only stats, got %q", plain[1])
115+ }
116+ if !strings.Contains(plain[2], "/tmp/x") {
117+ t.Fatalf("line 3 should contain cwd, got %q", plain[2])
118+ }
119+}
120+
121+func TestStatusBarNarrowSplitsAfterThinkingLevel(t *testing.T) {
122+ lines := StatusBar(StatusBarParams{
123+ Theme: Dark,
124+ Provider: "openai-codex",
125+ Model: "gpt-5.5",
126+ Reasoning: "minimum",
127+ CWD: "/tmp/x",
128+ Usage: provider.Usage{
129+ InputTokens: 4_300_000,
130+ OutputTokens: 2,
131+ },
132+ Cols: 40,
133+ })
134+ if len(lines) != 4 {
135+ t.Fatalf("narrow status with thinking: want 4 lines, got %d: %q", len(lines), lines)
136+ }
137+ plain := make([]string, len(lines))
138+ for i, line := range lines {
139+ plain[i] = stripANSI(line)
140+ }
141+ if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") {
142+ t.Fatalf("line 1 should contain model info, got %q", plain[0])
143+ }
144+ if !strings.Contains(plain[1], "thinking: minimal") || strings.Contains(plain[1], "↑4.3M") {
145+ t.Fatalf("line 2 should contain only thinking level, got %q", plain[1])
146+ }
147+ if !strings.Contains(plain[2], "↑4.3M ↓2") {
148+ t.Fatalf("line 3 should contain stats, got %q", plain[2])
149+ }
150+ if !strings.Contains(plain[3], "/tmp/x") {
151+ t.Fatalf("line 4 should contain cwd, got %q", plain[3])
152+ }
153+}
154+
155+func TestStatusBarVeryNarrowSplitsAfterThinkingLevel(t *testing.T) {
156+ lines := StatusBar(StatusBarParams{
157+ Theme: Dark,
158+ Provider: "openai-codex",
159+ Model: "gpt-5.5",
160+ Reasoning: "minimum",
161+ CWD: "/tmp/x",
162+ Usage: provider.Usage{
163+ InputTokens: 4_300_000,
164+ OutputTokens: 2,
165+ },
166+ Cols: 32,
167+ })
168+ if len(lines) != 4 {
169+ t.Fatalf("narrow status with thinking: want 4 lines, got %d: %q", len(lines), lines)
170+ }
171+ plain := make([]string, len(lines))
172+ for i, line := range lines {
173+ plain[i] = stripANSI(line)
174+ }
175+ if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") {
176+ t.Fatalf("line 1 should contain model info, got %q", plain[0])
177+ }
178+ if !strings.Contains(plain[1], "thinking: minimal") {
179+ t.Fatalf("line 2 should contain thinking level, got %q", plain[1])
180+ }
181+ if !strings.Contains(plain[2], "↑4.3M ↓2") {
182+ t.Fatalf("line 3 should contain stats, got %q", plain[2])
183+ }
184+ if !strings.Contains(plain[3], "/tmp/x") {
185+ t.Fatalf("line 4 should contain cwd, got %q", plain[3])
186+ }
187+}
188+
60 func TestStatusBarNoYoloTagPrecedesCWD(t *testing.T) {189 func TestStatusBarNoYoloTagPrecedesCWD(t *testing.T) {
61 lines := StatusBar(StatusBarParams{190 lines := StatusBar(StatusBarParams{
62 Theme: Dark,191 Theme: Dark,
M internal/tui/view.go +54 -7
@@ -1973,6 +1973,7 @@ type StatusBarParams struct {
19731973 Theme Theme
19741974 Provider string
19751975 Model string
1976+ Reasoning string // "" means thinking off
19761977 Busy bool
19771978 BusyPrefix string // spinner + funny line when busy
19781979 CWD string
@@ -2069,7 +2070,20 @@ func StatusBar(p StatusBarParams) []string {
20692070 const pad = " " // 2 spaces
20702071
20712072 left := fmt.Sprintf("(%s) %s", p.Provider, p.Model)
2072- middle := strings.Join(stats, " ")
2073+ thinking := thinkingLevelLabel(p.Reasoning)
2074+ thinkingText := ""
2075+ if thinking != "" {
2076+ thinkingText = "thinking: " + thinking
2077+ }
2078+ statsText := strings.Join(stats, " ")
2079+ middleParts := make([]string, 0, 2)
2080+ if thinkingText != "" {
2081+ middleParts = append(middleParts, thinkingText)
2082+ }
2083+ if statsText != "" {
2084+ middleParts = append(middleParts, statsText)
2085+ }
2086+ middle := strings.Join(middleParts, " ")
20732087
20742088 var leftBuilder strings.Builder
20752089 if p.BusyPrefix != "" {
@@ -2122,8 +2136,7 @@ func StatusBar(p StatusBarParams) []string {
21222136 modelLine := pad + th.FG256(th.Muted, left)
21232137 lines := []string{busyLine}
21242138 if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols {
2125- lines = append(lines, modelLine)
2126- lines = append(lines, pad+th.FG256(th.Muted, middle))
2139+ lines = appendWrappedStatusLines(lines, th, pad, left, thinkingText, statsText, p.Cols)
21272140 } else {
21282141 var infoBuilder strings.Builder
21292142 infoBuilder.WriteString(modelLine)
@@ -2144,10 +2157,8 @@ func StatusBar(p StatusBarParams) []string {
21442157 // avoids the terminal's hard wrap cutting the stats or pushing cwd
21452158 // into an awkward position on small widths.
21462159 if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {
2147- lines := []string{
2148- pad + th.FG256(th.Muted, left),
2149- pad + th.FG256(th.Muted, middle),
2150- }
2160+ var lines []string
2161+ lines = appendWrappedStatusLines(lines, th, pad, left, thinkingText, statsText, p.Cols)
21512162 if cwd != "" {
21522163 lines = append(lines, pad+th.FG256(th.Muted, cwd))
21532164 }
@@ -2164,6 +2175,42 @@ func StatusBar(p StatusBarParams) []string {
21642175 return []string{primary, cwdRendered}
21652176 }
21662177
2178+func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinkingText, statsText string, cols int) []string {
2179+ modelLine := pad + th.FG256(th.Muted, modelText)
2180+ if thinkingText == "" {
2181+ lines = append(lines, modelLine)
2182+ if statsText != "" {
2183+ lines = append(lines, pad+th.FG256(th.Muted, statsText))
2184+ }
2185+ return lines
2186+ }
2187+
2188+ modelThinkingPlain := pad + modelText + pad + thinkingText
2189+ if cols <= 0 || visibleWidth(modelThinkingPlain) <= cols {
2190+ lines = append(lines, pad+th.FG256(th.Muted, modelText+pad+thinkingText))
2191+ } else {
2192+ lines = append(lines, modelLine)
2193+ lines = append(lines, pad+th.FG256(th.Muted, thinkingText))
2194+ }
2195+ if statsText != "" {
2196+ lines = append(lines, pad+th.FG256(th.Muted, statsText))
2197+ }
2198+ return lines
2199+}
2200+
2201+func thinkingLevelLabel(level string) string {
2202+ switch strings.ToLower(strings.TrimSpace(level)) {
2203+ case "", "off", "none", "no", "false", "disabled":
2204+ return ""
2205+ case "minimum", "minimal", "min":
2206+ return "minimal"
2207+ case "maximum", "max", "xhigh":
2208+ return "maximum"
2209+ default:
2210+ return strings.ToLower(strings.TrimSpace(level))
2211+ }
2212+}
2213+
21672214 // contextUsage renders the "N%/ctxMax" fragment, returning the
21682215 // rendered string plus the colour to wrap it in.
21692216 func contextUsage(th Theme, used, max int) (string, int) {
@@ -1973,6 +1973,7 @@ type StatusBarParams struct {
1973 Theme Theme1973 Theme Theme
1974 Provider string1974 Provider string
1975 Model string1975 Model string
1976+ Reasoning string // "" means thinking off
1976 Busy bool1977 Busy bool
1977 BusyPrefix string // spinner + funny line when busy1978 BusyPrefix string // spinner + funny line when busy
1978 CWD string1979 CWD string
@@ -2069,7 +2070,20 @@ func StatusBar(p StatusBarParams) []string {
2069 const pad = " " // 2 spaces2070 const pad = " " // 2 spaces
2070 2071
2071 left := fmt.Sprintf("(%s) %s", p.Provider, p.Model)2072 left := fmt.Sprintf("(%s) %s", p.Provider, p.Model)
2072- middle := strings.Join(stats, " ")2073+ thinking := thinkingLevelLabel(p.Reasoning)
2074+ thinkingText := ""
2075+ if thinking != "" {
2076+ thinkingText = "thinking: " + thinking
2077+ }
2078+ statsText := strings.Join(stats, " ")
2079+ middleParts := make([]string, 0, 2)
2080+ if thinkingText != "" {
2081+ middleParts = append(middleParts, thinkingText)
2082+ }
2083+ if statsText != "" {
2084+ middleParts = append(middleParts, statsText)
2085+ }
2086+ middle := strings.Join(middleParts, " ")
2073 2087
2074 var leftBuilder strings.Builder2088 var leftBuilder strings.Builder
2075 if p.BusyPrefix != "" {2089 if p.BusyPrefix != "" {
@@ -2122,8 +2136,7 @@ func StatusBar(p StatusBarParams) []string {
2122 modelLine := pad + th.FG256(th.Muted, left)2136 modelLine := pad + th.FG256(th.Muted, left)
2123 lines := []string{busyLine}2137 lines := []string{busyLine}
2124 if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols {2138 if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols {
2125- lines = append(lines, modelLine)2139+ lines = appendWrappedStatusLines(lines, th, pad, left, thinkingText, statsText, p.Cols)
2126- lines = append(lines, pad+th.FG256(th.Muted, middle))
2127 } else {2140 } else {
2128 var infoBuilder strings.Builder2141 var infoBuilder strings.Builder
2129 infoBuilder.WriteString(modelLine)2142 infoBuilder.WriteString(modelLine)
@@ -2144,10 +2157,8 @@ func StatusBar(p StatusBarParams) []string {
2144 // avoids the terminal's hard wrap cutting the stats or pushing cwd2157 // avoids the terminal's hard wrap cutting the stats or pushing cwd
2145 // into an awkward position on small widths.2158 // into an awkward position on small widths.
2146 if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {2159 if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {
2147- lines := []string{2160+ var lines []string
2148- pad + th.FG256(th.Muted, left),2161+ lines = appendWrappedStatusLines(lines, th, pad, left, thinkingText, statsText, p.Cols)
2149- pad + th.FG256(th.Muted, middle),
2150- }
2151 if cwd != "" {2162 if cwd != "" {
2152 lines = append(lines, pad+th.FG256(th.Muted, cwd))2163 lines = append(lines, pad+th.FG256(th.Muted, cwd))
2153 }2164 }
@@ -2164,6 +2175,42 @@ func StatusBar(p StatusBarParams) []string {
2164 return []string{primary, cwdRendered}2175 return []string{primary, cwdRendered}
2165 }2176 }
2166 2177
2178+func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinkingText, statsText string, cols int) []string {
2179+ modelLine := pad + th.FG256(th.Muted, modelText)
2180+ if thinkingText == "" {
2181+ lines = append(lines, modelLine)
2182+ if statsText != "" {
2183+ lines = append(lines, pad+th.FG256(th.Muted, statsText))
2184+ }
2185+ return lines
2186+ }
2187+
2188+ modelThinkingPlain := pad + modelText + pad + thinkingText
2189+ if cols <= 0 || visibleWidth(modelThinkingPlain) <= cols {
2190+ lines = append(lines, pad+th.FG256(th.Muted, modelText+pad+thinkingText))
2191+ } else {
2192+ lines = append(lines, modelLine)
2193+ lines = append(lines, pad+th.FG256(th.Muted, thinkingText))
2194+ }
2195+ if statsText != "" {
2196+ lines = append(lines, pad+th.FG256(th.Muted, statsText))
2197+ }
2198+ return lines
2199+}
2200+
2201+func thinkingLevelLabel(level string) string {
2202+ switch strings.ToLower(strings.TrimSpace(level)) {
2203+ case "", "off", "none", "no", "false", "disabled":
2204+ return ""
2205+ case "minimum", "minimal", "min":
2206+ return "minimal"
2207+ case "maximum", "max", "xhigh":
2208+ return "maximum"
2209+ default:
2210+ return strings.ToLower(strings.TrimSpace(level))
2211+ }
2212+}
2213+
2167 // contextUsage renders the "N%/ctxMax" fragment, returning the2214 // contextUsage renders the "N%/ctxMax" fragment, returning the
2168 // rendered string plus the colour to wrap it in.2215 // rendered string plus the colour to wrap it in.
2169 func contextUsage(th Theme, used, max int) (string, int) {2216 func contextUsage(th Theme, used, max int) (string, int) {

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