fix(tui): persist model picker effort
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user