fix(tui): persist model picker effort

This commit is contained in:
Hunter Bown
2026-05-21 09:45:03 +08:00
parent 26c66079ba
commit 396b0e822b
9 changed files with 213 additions and 25 deletions
+8
View File
@@ -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
+8
View File
@@ -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
+18
View File
@@ -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(),
};
+70 -2
View File
@@ -230,6 +230,9 @@ pub struct Settings {
pub default_provider: Option<String>,
/// Default model to use
pub default_model: Option<String>,
/// Default reasoning effort selected from the TUI model picker.
/// `None` falls back to `config.toml` and then the runtime default.
pub reasoning_effort: Option<String>,
/// 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<std::collections::HashMap<String, String>>,
@@ -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<String> {
}
}
fn normalize_reasoning_effort_setting(value: &str) -> Result<Option<String>> {
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<bool> {
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);
+8 -6
View File
@@ -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
+11 -14
View File
@@ -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<String> = 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 {
+72
View File
@@ -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();
+16 -1
View File
@@ -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::<Vec<_>>();
assert!(keys.contains(&"model"));
assert!(keys.contains(&"reasoning_effort"));
assert!(keys.contains(&"approval_mode"));
assert!(keys.contains(&"theme"));
assert!(keys.contains(&"locale"));
+2 -2
View File
@@ -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. |