diff --git a/CHANGELOG.md b/CHANGELOG.md index 64324028..f3c3642e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 concrete model now updates existing session metadata, and resumed sessions recompute the `auto` flag from the saved model instead of falling back to the startup default. +- **The `/model` picker persists thinking effort across restarts.** Selecting + Pro/Flash plus `high`/`max`/`auto` now writes both `default_model` and + `reasoning_effort` to `settings.toml`, and startup restores the saved effort + before falling back to `config.toml`. +- **The footer water strip is visible by default again.** `fancy_animations` + now defaults to `true`, while `NO_ANIMATIONS`, SSH/Termius, VS Code, Ghostty, + and legacy terminal overrides still disable the animated strip where it is + known to flicker. ### Changed diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 64324028..f3c3642e 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -109,6 +109,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 concrete model now updates existing session metadata, and resumed sessions recompute the `auto` flag from the saved model instead of falling back to the startup default. +- **The `/model` picker persists thinking effort across restarts.** Selecting + Pro/Flash plus `high`/`max`/`auto` now writes both `default_model` and + `reasoning_effort` to `settings.toml`, and startup restores the saved effort + before falling back to `config.toml`. +- **The footer water strip is visible by default again.** `fancy_animations` + now defaults to `true`, while `NO_ANIMATIONS`, SSH/Termius, VS Code, Ghostty, + and legacy terminal overrides still disable the animated strip where it is + known to flicker. ### Changed diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index f18c5746..c84afec1 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -213,6 +213,7 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { .default_model .unwrap_or_else(|| "(default)".to_string()) }), + "reasoning_effort" | "effort" => Some(app.reasoning_effort.as_setting().to_string()), "prefer_external_pdftotext" | "external_pdftotext" | "pdftotext" => Settings::load() .ok() .map(|settings| settings.prefer_external_pdftotext.to_string()), @@ -557,6 +558,19 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> action = Some(AppAction::UpdateCompaction(app.compaction_config())); } } + "reasoning_effort" | "effort" => { + app.reasoning_effort = if app.auto_model { + ReasoningEffort::Auto + } else { + settings + .reasoning_effort + .as_deref() + .map_or_else(ReasoningEffort::default, ReasoningEffort::from_setting) + }; + app.last_effective_reasoning_effort = None; + app.update_model_compaction_budget(); + action = Some(AppAction::UpdateCompaction(app.compaction_config())); + } "sidebar_width" | "sidebar" => { app.sidebar_width_percent = settings.sidebar_width_percent; app.mark_history_updated(); @@ -580,6 +594,10 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> .background_color .clone() .unwrap_or_else(|| "default".to_string()), + "reasoning_effort" | "effort" => settings + .reasoning_effort + .clone() + .unwrap_or_else(|| "config/default".to_string()), "composer_vim_mode" | "vim_mode" | "vim" => settings.composer_vim_mode.clone(), _ => value.to_string(), }; diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 45cea706..c0cfb830 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -230,6 +230,9 @@ pub struct Settings { pub default_provider: Option, /// Default model to use pub default_model: Option, + /// Default reasoning effort selected from the TUI model picker. + /// `None` falls back to `config.toml` and then the runtime default. + pub reasoning_effort: Option, /// Per-provider model overrides. Key is provider name (e.g. "openai"), /// value is the model id. Takes precedence over `default_model`. pub provider_models: Option>, @@ -287,7 +290,7 @@ impl Default for Settings { auto_compact: false, calm_mode: false, low_motion: false, - fancy_animations: false, + fancy_animations: true, bracketed_paste: true, paste_burst_detection: true, show_thinking: true, @@ -307,6 +310,7 @@ impl Default for Settings { max_input_history: 100, default_provider: None, default_model: None, + reasoning_effort: None, provider_models: None, status_indicator: "whale".to_string(), synchronized_output: "auto".to_string(), @@ -360,6 +364,10 @@ impl Settings { s.background_color = normalize_optional_background_color(s.background_color.as_deref()); s.theme = normalize_settings_theme(&s.theme).to_string(); s.default_model = s.default_model.as_deref().and_then(normalize_default_model); + s.reasoning_effort = s + .reasoning_effort + .as_deref() + .and_then(|value| normalize_reasoning_effort_setting(value).ok().flatten()); s }; settings.apply_env_overrides(); @@ -648,6 +656,9 @@ impl Settings { }; self.default_model = Some(model); } + "reasoning_effort" | "effort" => { + self.reasoning_effort = normalize_reasoning_effort_setting(value)?; + } _ => { anyhow::bail!("Failed to update setting: unknown setting '{key}'."); } @@ -704,6 +715,12 @@ impl Settings { " default_model: {}", self.default_model.as_deref().unwrap_or("(default)") )); + lines.push(format!( + " reasoning_effort: {}", + self.reasoning_effort + .as_deref() + .unwrap_or("(config/default)") + )); lines.push(String::new()); lines.push(format!( "{} {}", @@ -793,6 +810,10 @@ impl Settings { "default_model", "Default model: auto or any DeepSeek model ID (e.g. deepseek-v4-pro)", ), + ( + "reasoning_effort", + "Default thinking effort: auto, off, low, medium, high, max, or default", + ), ] } @@ -823,6 +844,33 @@ fn normalize_default_model(value: &str) -> Option { } } +fn normalize_reasoning_effort_setting(value: &str) -> Result> { + let trimmed = value.trim(); + if trimmed.is_empty() + || matches!( + trimmed.to_ascii_lowercase().as_str(), + "default" | "(default)" | "config" | "configured" | "unset" + ) + { + return Ok(None); + } + + let normalized = match trimmed.to_ascii_lowercase().as_str() { + "off" | "disabled" | "none" | "false" => "off", + "low" | "minimal" => "low", + "medium" | "mid" => "medium", + "high" => "high", + "auto" | "automatic" => "auto", + "max" | "maximum" | "xhigh" => "max", + _ => { + anyhow::bail!( + "Failed to update setting: invalid reasoning_effort '{value}'. Expected: auto, off, low, medium, high, max, or default." + ); + } + }; + Ok(Some(normalized.to_string())) +} + /// Parse a boolean value from various formats fn parse_bool(value: &str) -> Result { match value.to_lowercase().as_str() { @@ -1016,6 +1064,25 @@ mod tests { assert!(!settings.auto_compact); } + #[test] + fn default_settings_show_footer_water_strip() { + let settings = Settings::default(); + assert!(settings.fancy_animations); + } + + #[test] + fn reasoning_effort_setting_normalizes_and_clears() { + let mut settings = Settings::default(); + settings + .set("reasoning_effort", "xhigh") + .expect("normalize xhigh"); + assert_eq!(settings.reasoning_effort.as_deref(), Some("max")); + settings + .set("reasoning_effort", "default") + .expect("clear effort"); + assert!(settings.reasoning_effort.is_none()); + } + #[test] fn paste_burst_detection_is_configurable_independent_of_bracketed_paste() { let mut settings = Settings::default(); @@ -1197,7 +1264,7 @@ mod tests { } let mut settings = Settings::default(); assert!(!settings.low_motion, "default is animated"); - assert!(!settings.fancy_animations, "default is animated"); + assert!(settings.fancy_animations, "default shows the water strip"); settings.apply_env_overrides(); assert!(settings.low_motion, "NO_ANIMATIONS=1 forces low_motion"); assert!( @@ -1536,6 +1603,7 @@ mod tests { let mut settings = Settings::default(); assert!(!settings.low_motion, "default is animated"); + assert!(settings.fancy_animations, "default shows the water strip"); assert_eq!(settings.synchronized_output, "auto"); settings.apply_env_overrides(); assert!(settings.low_motion); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 9fcf00d6..9977d6d1 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1472,21 +1472,23 @@ impl App { }) .unwrap_or(model); let auto_model = model.trim().eq_ignore_ascii_case("auto"); + let configured_reasoning_effort = settings + .reasoning_effort + .as_deref() + .or_else(|| config.reasoning_effort()); let threshold_model = if auto_model { DEFAULT_TEXT_MODEL } else { model.as_str() }; let compact_threshold = - compaction_threshold_for_model_and_effort(threshold_model, config.reasoning_effort()); + compaction_threshold_for_model_and_effort(threshold_model, configured_reasoning_effort); let reasoning_effort = if auto_model { ReasoningEffort::Auto } else { - config - .reasoning_effort() - .map_or_else(ReasoningEffort::default, |s| { - ReasoningEffort::from_setting(s) - }) + configured_reasoning_effort.map_or_else(ReasoningEffort::default, |s| { + ReasoningEffort::from_setting(s) + }) }; // Start in YOLO mode if --yolo flag was passed diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 05893409..3cf028a4 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -4126,22 +4126,19 @@ async fn apply_model_picker_choice( // Best-effort persist; surface a status warning if the settings file // can't be written rather than aborting the in-memory change. let mut persist_warning: Option = None; - match crate::settings::Settings::load() { - Ok(mut settings) => { - if model_changed { - let _ = settings.set("default_model", &model); - settings.set_model_for_provider(app.api_provider.as_str(), &model); - } - if effort_changed { - let _ = settings.set("reasoning_effort", effort.as_setting()); - } - if let Err(err) = settings.save() { - persist_warning = Some(format!("(not persisted: {err})")); - } + let persist_result = (|| -> anyhow::Result<()> { + let mut settings = crate::settings::Settings::load()?; + if model_changed { + settings.set("default_model", &model)?; + settings.set_model_for_provider(app.api_provider.as_str(), &model); } - Err(err) => { - persist_warning = Some(format!("(not persisted: {err})")); + if effort_changed { + settings.set("reasoning_effort", effort.as_setting())?; } + settings.save() + })(); + if let Err(err) = persist_result { + persist_warning = Some(format!("(not persisted: {err})")); } if model_changed { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index cd9ef941..e9f3b4db 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -3970,6 +3970,78 @@ fn apply_loaded_session_restores_auto_model_mode() { assert_eq!(app.model_selection_for_persistence(), "auto"); } +#[test] +fn app_new_restores_saved_model_and_reasoning_effort() { + let _guard = ConfigPathEnvGuard::new(); + let mut settings = crate::settings::Settings::default(); + settings.default_model = Some("deepseek-v4-pro".to_string()); + settings.reasoning_effort = Some("high".to_string()); + settings.save().expect("save settings"); + + let options = TuiOptions { + model: "auto".to_string(), + workspace: PathBuf::from("."), + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: PathBuf::from("."), + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.txt"), + mcp_config_path: PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: true, + skip_onboarding: false, + yolo: false, + resume_session_id: None, + initial_input: None, + }; + let mut config = Config::default(); + config.reasoning_effort = Some("max".to_string()); + + let app = App::new(options, &config); + + assert!(!app.auto_model); + assert_eq!(app.model, "deepseek-v4-pro"); + assert_eq!(app.reasoning_effort, ReasoningEffort::High); +} + +#[tokio::test] +async fn model_picker_persists_model_and_reasoning_effort() { + let _guard = ConfigPathEnvGuard::new(); + let mut app = create_test_app(); + app.set_model_selection("auto".to_string()); + app.reasoning_effort = ReasoningEffort::Auto; + let engine = mock_engine_handle(); + + apply_model_picker_choice( + &mut app, + &engine.handle, + "deepseek-v4-pro".to_string(), + ReasoningEffort::High, + "auto".to_string(), + ReasoningEffort::Auto, + ) + .await; + + let settings = crate::settings::Settings::load().expect("load settings"); + assert_eq!(settings.default_model.as_deref(), Some("deepseek-v4-pro")); + assert_eq!( + settings + .provider_models + .as_ref() + .and_then(|models| models.get("deepseek")) + .map(String::as_str), + Some("deepseek-v4-pro") + ); + assert_eq!(settings.reasoning_effort.as_deref(), Some("high")); + assert!(!app.auto_model); + assert_eq!(app.reasoning_effort, ReasoningEffort::High); +} + #[test] fn apply_loaded_session_restores_artifact_registry() { let mut app = create_test_app(); diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 0b3367b3..c88af6b6 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -598,6 +598,17 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Model, + key: "reasoning_effort".to_string(), + value: settings + .reasoning_effort + .as_deref() + .unwrap_or("(config/default)") + .to_string(), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Permissions, key: "approval_mode".to_string(), @@ -1067,7 +1078,9 @@ impl ConfigView { }; let key = row.key.clone(); let original_value = row.value.clone(); - let initial_value = if key == "default_model" && original_value == "(default)" { + let initial_value = if (key == "default_model" && original_value == "(default)") + || (key == "reasoning_effort" && original_value == "(config/default)") + { String::new() } else { original_value.clone() @@ -1114,6 +1127,7 @@ fn config_hint_for_key(key: &str) -> &'static str { "sidebar_focus" => "auto | work | tasks | agents | context | hidden", "max_history" => "integer (0 allowed)", "default_model" => "deepseek-v4-pro | deepseek-v4-flash | deepseek-* | none/default", + "reasoning_effort" => "auto | off | low | medium | high | max | default", "mcp_config_path" => "path to mcp.json", _ => "", } @@ -2134,6 +2148,7 @@ mod tests { .map(|row| row.key.as_str()) .collect::>(); assert!(keys.contains(&"model")); + assert!(keys.contains(&"reasoning_effort")); assert!(keys.contains(&"approval_mode")); assert!(keys.contains(&"theme")); assert!(keys.contains(&"locale")); diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md index 3ac5fb68..207ea575 100644 --- a/docs/ACCESSIBILITY.md +++ b/docs/ACCESSIBILITY.md @@ -10,8 +10,8 @@ visual motion and density for screen-reader and low-motion users. | Toggle | Default | Effect | | --- | --- | --- | | `NO_ANIMATIONS=1` env var | unset | At startup, forces `low_motion = true` and `fancy_animations = false`. Overrides whatever's saved in `settings.toml`. | -| `low_motion` setting | `false` | Suppresses spinners' motion, transcript fade-ins, footer drift, the header status-indicator cycle, and the active-cell pulse. The frame-rate limiter also slows down idle redraws so the cursor doesn't blink as aggressively. | -| `fancy_animations` setting | `false` | Footer water-spout strip and pulsing sub-agent counter. Off by default. | +| `low_motion` setting | `false` | Uses calmer streaming pacing and a lower redraw cadence so cursor/status motion is less aggressive. The footer water strip is controlled separately by `fancy_animations`. | +| `fancy_animations` setting | `true` | Footer water-spout strip and pulsing sub-agent counter. Set to `false` to keep live-turn chrome still. | | `status_indicator` setting | `whale` | Header status chip. Set to `dots` for the compact dot cycle or `off` to hide it. | | `calm_mode` setting | `false` | Collapses tool-output details by default and trims status messages. Useful for screen readers that announce every redraw. | | `show_thinking` setting | `true` | Set to `false` to hide model `reasoning_content` blocks entirely. |