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 | |||
| 141 | 141 | | `--base-url <url>` | Override the provider base URL (tests, self-hosted). | | |
| 142 | 142 | | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). | | |
| 143 | 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 | 145 | | `-c`, `--continue` | Resume the latest session for this cwd. | | |
| 146 | 146 | | `-r`, `--resume` | Pick a session to resume. | | |
| 147 | 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 | 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 | 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 | 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 | 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 | 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 | 474 | You can add additional Gemini model IDs to `models.json` under the `google` provider. | |
| 474 | 475 | ||
| @@ -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) { | |||
| 196 | 196 | return a, err | |
| 197 | 197 | } | |
| 198 | 198 | switch strings.ToLower(v) { | |
| 199 | - | case "", "low", "medium", "high": | |
| 199 | + | case "", "off", "minimum", "minimal", "low", "medium", "high", "maximum", "max": | |
| 200 | 200 | a.Reasoning = strings.ToLower(v) | |
| 201 | 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 | 204 | case "--session": | |
| 205 | 205 | v, err := want(&i, arg) | |
| @@ -360,7 +360,7 @@ func PrintHelp(version string) { | |||
| 360 | 360 | row{"--model ID", "model id (see --list-models)"}, | |
| 361 | 361 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | |
| 362 | 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 | 365 | section("prompt and session flags", | |
| 366 | 366 | row{"--system-prompt TEXT", "replace the default system prompt"}, | |
| @@ -196,10 +196,10 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 196 | return a, err | 196 | 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) { | |||
| 426 | 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 | 431 | max := args.MaxSteps // 0 = unlimited | |
| 432 | 432 | ||
| @@ -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 = unlimited | 431 | max := args.MaxSteps // 0 = unlimited | ||
| 432 | 432 | ||||
M internal/agent/modes/interactive.go +70 -1
| @@ -221,6 +221,7 @@ type chatCacheKey struct { | |||
| 221 | 221 | type SettingsStore interface { | |
| 222 | 222 | SetInlineImages(enabled bool) error | |
| 223 | 223 | SetAutoSwarm(enabled bool) error | |
| 224 | + | SetReasoning(level string) error | |
| 224 | 225 | } | |
| 225 | 226 | ||
| 226 | 227 | type Interactive struct { | |
| @@ -1065,6 +1066,7 @@ func (i *Interactive) redraw() { | |||
| 1065 | 1066 | Theme: i.cfg.Theme, | |
| 1066 | 1067 | Provider: i.cfg.Provider, | |
| 1067 | 1068 | Model: i.cfg.Model, | |
| 1069 | + | Reasoning: i.cfg.Reasoning, | |
| 1068 | 1070 | Busy: i.busy, | |
| 1069 | 1071 | BusyPrefix: busyPrefix, | |
| 1070 | 1072 | CWD: i.cfg.CWD, | |
| @@ -1662,7 +1664,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1662 | 1664 | } | |
| 1663 | 1665 | act := i.settingsDialog.HandleKey(k) | |
| 1664 | 1666 | if act.Toggle { | |
| 1665 | - | i.applySettingToggle(act.Key, act.Value) | |
| 1667 | + | i.applySettingChange(act) | |
| 1666 | 1668 | } | |
| 1667 | 1669 | i.invalidate() | |
| 1668 | 1670 | return false | |
| @@ -2503,6 +2505,27 @@ func (i *Interactive) openSettingsDialog() { | |||
| 2503 | 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 | 2529 | i.settingsDialog.Open([]settingsItem{ | |
| 2507 | 2530 | { | |
| 2508 | 2531 | key: "inline_images_enabled", | |
| @@ -2520,9 +2543,25 @@ func (i *Interactive) openSettingsDialog() { | |||
| 2520 | 2543 | disabled: autoSwarmDisabled, | |
| 2521 | 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 | 2565 | func (i *Interactive) applySettingToggle(key string, value bool) { | |
| 2527 | 2566 | // Every setting toggle forces a full repaint at the end — same | |
| 2528 | 2567 | // 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 | 2651 | // buildStudyPrompt returns the canned prompt the /study command | |
| 2583 | 2652 | // submits to the agent. | |
| 2584 | 2653 | // | |
| @@ -221,6 +221,7 @@ type chatCacheKey struct { | |||||
| 221 | type SettingsStore interface { | 221 | type SettingsStore interface { | ||
| 222 | SetInlineImages(enabled bool) error | 222 | SetInlineImages(enabled bool) error | ||
| 223 | SetAutoSwarm(enabled bool) error | 223 | 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 false | 1670 | 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 — same | 2566 | // Every setting toggle forces a full repaint at the end — same | ||
| 2528 | // effect as the user pressing Ctrl+L — so any per-setting visual | 2567 | // 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 command | 2651 | // 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 ( | |||
| 5 | 5 | ) | |
| 6 | 6 | ||
| 7 | 7 | 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 | |
| 11 | 13 | } | |
| 12 | 14 | ||
| 13 | 15 | type settingsItem struct { | |
| @@ -15,15 +17,24 @@ type settingsItem struct { | |||
| 15 | 17 | label string | |
| 16 | 18 | desc string | |
| 17 | 19 | value bool | |
| 20 | + | options []settingsOption | |
| 21 | + | choice int | |
| 18 | 22 | disabled bool | |
| 19 | 23 | hint string | |
| 20 | 24 | } | |
| 21 | 25 | ||
| 26 | + | type settingsOption struct { | |
| 27 | + | value string | |
| 28 | + | label string | |
| 29 | + | desc string | |
| 30 | + | } | |
| 31 | + | ||
| 22 | 32 | 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 | |
| 27 | 38 | } | |
| 28 | 39 | ||
| 29 | 40 | func newSettingsDialog() *settingsDialog { return &settingsDialog{} } | |
| @@ -34,14 +45,22 @@ func (d *settingsDialog) Open(items []settingsItem) bool { | |||
| 34 | 45 | } | |
| 35 | 46 | d.items = items | |
| 36 | 47 | d.cursor = 0 | |
| 48 | + | d.selecting = false | |
| 49 | + | d.optionCursor = 0 | |
| 37 | 50 | d.active = true | |
| 38 | 51 | 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 | 58 | func (d *settingsDialog) Active() bool { return d != nil && d.active } | |
| 43 | 59 | ||
| 44 | 60 | func (d *settingsDialog) HandleKey(k tui.Key) settingsAction { | |
| 61 | + | if d.selecting { | |
| 62 | + | return d.handleOptionKey(k) | |
| 63 | + | } | |
| 45 | 64 | switch k.Kind { | |
| 46 | 65 | case tui.KeyUp: | |
| 47 | 66 | if d.cursor > 0 { | |
| @@ -64,6 +83,29 @@ func (d *settingsDialog) HandleKey(k tui.Key) settingsAction { | |||
| 64 | 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 | 109 | func (d *settingsDialog) toggleCurrent() settingsAction { | |
| 68 | 110 | if len(d.items) == 0 { | |
| 69 | 111 | d.Close() | |
| @@ -73,24 +115,61 @@ func (d *settingsDialog) toggleCurrent() settingsAction { | |||
| 73 | 115 | if it.disabled { | |
| 74 | 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 | 126 | it.value = !it.value | |
| 77 | 127 | d.items[d.cursor] = it | |
| 78 | 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 | 150 | func (d *settingsDialog) Render(th tui.Theme, width int) []string { | |
| 82 | 151 | if !d.Active() { | |
| 83 | 152 | return nil | |
| 84 | 153 | } | |
| 154 | + | if d.selecting { | |
| 155 | + | return d.renderOptions(th, width) | |
| 156 | + | } | |
| 85 | 157 | var lines []string | |
| 86 | 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 | 160 | for i, it := range d.items { | |
| 89 | 161 | box := "[ ]" | |
| 90 | 162 | if it.value { | |
| 91 | 163 | box = "[x]" | |
| 92 | 164 | } | |
| 93 | 165 | 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 | 173 | if it.hint != "" { | |
| 95 | 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 | 187 | lines = append(lines, frameRule(th, width)) | |
| 109 | 188 | 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 | + | } | |
| @@ -5,9 +5,11 @@ import ( | |||||
| 5 | ) | 5 | ) | ||
| 6 | 6 | ||||
| 7 | type settingsDialog struct { | 7 | type settingsDialog struct { | ||
| 8 | - | active bool | 8 | + | active bool |
| 9 | - | items []settingsItem | 9 | + | items []settingsItem |
| 10 | - | cursor int | 10 | + | 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 string | 17 | label string | ||
| 16 | desc string | 18 | desc string | ||
| 17 | value bool | 19 | value bool | ||
| 20 | + | options []settingsOption | |||
| 21 | + | choice int | |||
| 18 | disabled bool | 22 | disabled bool | ||
| 19 | hint string | 23 | 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 bool | 33 | + | Toggle bool |
| 24 | - | Key string | 34 | + | Key string |
| 25 | - | Value bool | 35 | + | Value bool |
| 26 | - | Close bool | 36 | + | 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 = items | 46 | d.items = items | ||
| 36 | d.cursor = 0 | 47 | d.cursor = 0 | ||
| 48 | + | d.selecting = false | |||
| 49 | + | d.optionCursor = 0 | |||
| 37 | d.active = true | 50 | d.active = true | ||
| 38 | return true | 51 | 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.value | 126 | it.value = !it.value | ||
| 77 | d.items[d.cursor] = it | 127 | 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 nil | 152 | return nil | ||
| 84 | } | 153 | } | ||
| 154 | + | if d.selecting { | |||
| 155 | + | return d.renderOptions(th, width) | |||
| 156 | + | } | |||
| 85 | var lines []string | 157 | 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.label | 165 | 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 lines | 188 | 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 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | + | import "github.com/patriceckhart/zot/internal/provider" | |
| 4 | + | ||
| 3 | 5 | type configSettingsStore struct{} | |
| 4 | 6 | ||
| 5 | 7 | func (configSettingsStore) SetInlineImages(enabled bool) error { | |
| @@ -20,6 +22,15 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error { | |||
| 20 | 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 | 34 | // AutoSwarmEnabled reads the current auto-swarm flag from config. | |
| 24 | 35 | // Used by the swarm_spawn tool at call time to gate execution. | |
| 25 | 36 | func AutoSwarmEnabled() bool { | |
| @@ -1,5 +1,7 @@ | |||||
| 1 | package agent | 1 | 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) { | |||
| 249 | 249 | if req.Reasoning != "" && m.Reasoning { | |
| 250 | 250 | budget := anthropicReasoningBudget(req.Reasoning) | |
| 251 | 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 | 258 | out.Thinking = &anthThinking{Type: "enabled", BudgetTokens: budget} | |
| 253 | - | // Reasoning requires max_tokens > budget. | |
| 254 | 259 | 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 | + | } | |
| 256 | 264 | } | |
| 257 | 265 | } | |
| 258 | 266 | } | |
| @@ -361,16 +369,7 @@ func markLastBlockEphemeral(blocks []interface{}) { | |||
| 361 | 369 | } | |
| 362 | 370 | ||
| 363 | 371 | 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) | |
| 374 | 373 | } | |
| 375 | 374 | ||
| 376 | 375 | 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 + 4096 | 260 | + | 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 { | |||
| 354 | 354 | // generation: 2.5 family uses thinkingBudget (tokens), 3.x uses | |
| 355 | 355 | // thinkingLevel (enum). Returns nil when the level is unrecognised. | |
| 356 | 356 | func geminiThinkingConfig(modelID, level string) *gemThinkingConfig { | |
| 357 | - | level = strings.ToLower(level) | |
| 357 | + | level = NormalizeReasoning(level) | |
| 358 | 358 | id := strings.ToLower(modelID) | |
| 359 | 359 | ||
| 360 | 360 | // Gemini 3.x: enum-based thinkingLevel. Pro can't go below LOW. | |
| @@ -362,6 +362,12 @@ func geminiThinkingConfig(modelID, level string) *gemThinkingConfig { | |||
| 362 | 362 | isPro := strings.Contains(id, "-pro") | |
| 363 | 363 | var lvl string | |
| 364 | 364 | switch level { | |
| 365 | + | case "minimum": | |
| 366 | + | if isPro { | |
| 367 | + | lvl = "LOW" | |
| 368 | + | } else { | |
| 369 | + | lvl = "MINIMAL" | |
| 370 | + | } | |
| 365 | 371 | case "low": | |
| 366 | 372 | lvl = "LOW" | |
| 367 | 373 | case "medium": | |
| @@ -370,7 +376,7 @@ func geminiThinkingConfig(modelID, level string) *gemThinkingConfig { | |||
| 370 | 376 | } else { | |
| 371 | 377 | lvl = "MEDIUM" | |
| 372 | 378 | } | |
| 373 | - | case "high": | |
| 379 | + | case "high", "maximum": | |
| 374 | 380 | lvl = "HIGH" | |
| 375 | 381 | default: | |
| 376 | 382 | return nil | |
| @@ -379,33 +385,18 @@ func geminiThinkingConfig(modelID, level string) *gemThinkingConfig { | |||
| 379 | 385 | } | |
| 380 | 386 | ||
| 381 | 387 | // Gemini 2.5 family: token-budget per-model. | |
| 382 | - | var budget int | |
| 388 | + | budget := ReasoningBudget(level) | |
| 383 | 389 | switch { | |
| 384 | 390 | 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 { | |
| 391 | 392 | budget = 32768 | |
| 392 | 393 | } | |
| 393 | 394 | 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 { | |
| 400 | 396 | budget = 24576 | |
| 401 | 397 | } | |
| 402 | 398 | 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 { | |
| 409 | 400 | budget = 24576 | |
| 410 | 401 | } | |
| 411 | 402 | default: | |
| @@ -354,7 +354,7 @@ func convertGemToolResultParts(blocks []Content) []gemPart { | |||||
| 354 | // generation: 2.5 family uses thinkingBudget (tokens), 3.x uses | 354 | // 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 string | 363 | 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 nil | 382 | 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 int | 388 | + | 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 = 32768 | 392 | 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 = 24576 | 396 | 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 = 24576 | 400 | 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) { | |||
| 264 | 264 | {"gemini-3-pro", "low", "LOW", 0}, | |
| 265 | 265 | {"gemini-3-pro", "medium", "HIGH", 0}, // Pro can't go below LOW; medium → HIGH | |
| 266 | 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 | 270 | {"gemini-2.5-flash", "low", "", 2048}, | |
| 269 | 271 | {"gemini-2.0-flash", "high", "", 0}, // 2.0 has no thinking → nil | |
| 270 | 272 | } | |
| @@ -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 → HIGH | 265 | {"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 → nil | 271 | {"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) { | |||
| 185 | 185 | } | |
| 186 | 186 | if m.Reasoning { | |
| 187 | 187 | 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 | |
| 190 | 190 | } | |
| 191 | 191 | } else { | |
| 192 | 192 | 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 = &maxTok | 187 | 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 = &maxTok | 192 | out.MaxTokens = &maxTok | ||
M internal/provider/openai_codex.go +19 -9
| @@ -131,16 +131,21 @@ type codexTool struct { | |||
| 131 | 131 | Parameters json.RawMessage `json:"parameters"` | |
| 132 | 132 | } | |
| 133 | 133 | ||
| 134 | + | type codexReasoningConfig struct { | |
| 135 | + | Effort string `json:"effort,omitempty"` | |
| 136 | + | } | |
| 137 | + | ||
| 134 | 138 | 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"` | |
| 144 | 149 | } | |
| 145 | 150 | ||
| 146 | 151 | // ---- Request building ---- | |
| @@ -163,6 +168,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 163 | 168 | ParallelToolCalls: true, | |
| 164 | 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 | 176 | if len(req.Tools) > 0 { | |
| 167 | 177 | body.ToolChoice = "auto" | |
| 168 | 178 | 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 { | |||
| 216 | 216 | Tools []Tool | |
| 217 | 217 | MaxTokens int | |
| 218 | 218 | 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. | |
| 222 | 223 | Reasoning string | |
| 223 | 224 | } | |
| 224 | 225 | ||
| @@ -216,9 +216,10 @@ type Request struct { | |||||
| 216 | Tools []Tool | 216 | Tools []Tool | ||
| 217 | MaxTokens int | 217 | MaxTokens int | ||
| 218 | Temperature *float32 | 218 | 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 string | 223 | 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) { | |||
| 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 | 189 | func TestStatusBarNoYoloTagPrecedesCWD(t *testing.T) { | |
| 61 | 190 | lines := StatusBar(StatusBarParams{ | |
| 62 | 191 | 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 { | |||
| 1973 | 1973 | Theme Theme | |
| 1974 | 1974 | Provider string | |
| 1975 | 1975 | Model string | |
| 1976 | + | Reasoning string // "" means thinking off | |
| 1976 | 1977 | Busy bool | |
| 1977 | 1978 | BusyPrefix string // spinner + funny line when busy | |
| 1978 | 1979 | CWD string | |
| @@ -2069,7 +2070,20 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2069 | 2070 | const pad = " " // 2 spaces | |
| 2070 | 2071 | ||
| 2071 | 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 | 2088 | var leftBuilder strings.Builder | |
| 2075 | 2089 | if p.BusyPrefix != "" { | |
| @@ -2122,8 +2136,7 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2122 | 2136 | modelLine := pad + th.FG256(th.Muted, left) | |
| 2123 | 2137 | lines := []string{busyLine} | |
| 2124 | 2138 | 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) | |
| 2127 | 2140 | } else { | |
| 2128 | 2141 | var infoBuilder strings.Builder | |
| 2129 | 2142 | infoBuilder.WriteString(modelLine) | |
| @@ -2144,10 +2157,8 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2144 | 2157 | // avoids the terminal's hard wrap cutting the stats or pushing cwd | |
| 2145 | 2158 | // into an awkward position on small widths. | |
| 2146 | 2159 | 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) | |
| 2151 | 2162 | if cwd != "" { | |
| 2152 | 2163 | lines = append(lines, pad+th.FG256(th.Muted, cwd)) | |
| 2153 | 2164 | } | |
| @@ -2164,6 +2175,42 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2164 | 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 | 2214 | // contextUsage renders the "N%/ctxMax" fragment, returning the | |
| 2168 | 2215 | // rendered string plus the colour to wrap it in. | |
| 2169 | 2216 | func contextUsage(th Theme, used, max int) (string, int) { | |
| @@ -1973,6 +1973,7 @@ type StatusBarParams struct { | |||||
| 1973 | Theme Theme | 1973 | Theme Theme | ||
| 1974 | Provider string | 1974 | Provider string | ||
| 1975 | Model string | 1975 | Model string | ||
| 1976 | + | Reasoning string // "" means thinking off | |||
| 1976 | Busy bool | 1977 | Busy bool | ||
| 1977 | BusyPrefix string // spinner + funny line when busy | 1978 | BusyPrefix string // spinner + funny line when busy | ||
| 1978 | CWD string | 1979 | CWD string | ||
| @@ -2069,7 +2070,20 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 2069 | const pad = " " // 2 spaces | 2070 | 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.Builder | 2088 | 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.Builder | 2141 | 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 cwd | 2157 | // 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 the | 2214 | // 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) { | ||