diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 6080cb8..0bd32e7 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -18,7 +18,10 @@ tool work continues concurrently. The composer status row shows the current project and, for Git repositories, the current local branch. An unsaved draft can be moved with the project menu; saved chats keep their original project. Use the branch menu to switch local -branches when that project's chats are idle. +branches when that project's chats are idle. The thinking menu is preselected +to the model's current mode: **Thinking** is the DS4 default, while **Direct** +answers without hidden reasoning. **Think Max** is available when the context +window is at least 393216 tokens, matching DS4's long-context requirement. The **Git** pane shows added, changed, and deleted files from the current worktree, including separate staged and worktree markers. Select files to stage, diff --git a/src/app.rs b/src/app.rs index 33c7519..2f1f296 100644 --- a/src/app.rs +++ b/src/app.rs @@ -449,6 +449,7 @@ pub(crate) enum Message { CreateSession(i32), DraftProjectChanged(i32), SwitchGitBranch(String), + ReasoningModeChanged(ReasoningMode), PermissionModeChanged(PermissionMode), ToggleGitFile(PathBuf), OpenGitDiff(PathBuf), @@ -1522,6 +1523,34 @@ impl App { } Message::DraftProjectChanged(project_id) => self.move_draft_to_project(project_id), Message::SwitchGitBranch(branch) => self.switch_git_branch(&branch), + Message::ReasoningModeChanged(mode) => { + if self.active_chat_count() > 0 { + self.error = + Some("Stop all active generations before changing thinking mode.".into()); + } else if !self + .config + .generation + .supported_reasoning_modes() + .contains(&mode) + { + self.error = Some( + "Think Max requires a context window of at least 393216 tokens.".into(), + ); + } else { + let mut config = self.config.clone(); + config.generation.reasoning_mode = mode; + match config.save(&config_path()) { + Ok(()) => { + self.config = config; + self.preference_draft.reasoning_mode = mode; + self.error = None; + #[cfg(target_os = "macos")] + preferences::update_runtime_config(&self.runtime_config, &self.config); + } + Err(error) => self.error = Some(error), + } + } + } Message::PermissionModeChanged(mode) => { #[cfg(target_os = "macos")] if self.active_tools.is_some() { diff --git a/src/app/preferences.rs b/src/app/preferences.rs index bdd69ef..8137be9 100644 --- a/src/app/preferences.rs +++ b/src/app/preferences.rs @@ -345,7 +345,7 @@ fn optional_string(value: Option) -> String { value.map_or_else(String::new, |value| value.to_string()) } -fn update_runtime_config(runtime_config: &RwLock, config: &Config) { +pub(super) fn update_runtime_config(runtime_config: &RwLock, config: &Config) { *runtime_config .write() .unwrap_or_else(|poisoned| poisoned.into_inner()) = config.clone(); diff --git a/src/app/view/chat.rs b/src/app/view/chat.rs index 4faafa0..ceb45c4 100644 --- a/src/app/view/chat.rs +++ b/src/app/view/chat.rs @@ -305,6 +305,19 @@ impl App { .text_size(12) .padding([2, 6]) }); + let reasoning_mode = self.config.generation.effective_reasoning_mode(); + let reasoning_control: Element<'_, Message> = if self.active_chat_count() > 0 { + text(reasoning_mode.to_string()).size(12).into() + } else { + pick_list( + self.config.generation.supported_reasoning_modes(), + Some(reasoning_mode), + Message::ReasoningModeChanged, + ) + .text_size(12) + .padding([2, 6]) + .into() + }; composer_content = composer_content.push( row![ @@ -341,6 +354,7 @@ impl App { ) .text_size(12) .padding([2, 6]), + reasoning_control, icon(ICON_MODEL, 16), text(self.config.model.to_string()).size(12), action, diff --git a/src/settings.rs b/src/settings.rs index 7aeecf2..8dd3613 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -8,6 +8,7 @@ pub(crate) const REASONING_MODES: [ReasoningMode; 3] = [ ReasoningMode::Max, ReasoningMode::Direct, ]; +const STANDARD_REASONING_MODES: [ReasoningMode; 2] = [ReasoningMode::High, ReasoningMode::Direct]; const THINK_MAX_MIN_CONTEXT: i32 = 393_216; const MAX_CPU_THREADS: u32 = 32; const MAX_MTP_DRAFT_TOKENS: i32 = 16; @@ -555,6 +556,23 @@ impl Default for GenerationPreferences { } impl GenerationPreferences { + pub(crate) fn effective_reasoning_mode(&self) -> ReasoningMode { + if self.reasoning_mode == ReasoningMode::Max && self.context_tokens < THINK_MAX_MIN_CONTEXT + { + ReasoningMode::High + } else { + self.reasoning_mode + } + } + + pub(crate) fn supported_reasoning_modes(&self) -> &'static [ReasoningMode] { + if self.context_tokens >= THINK_MAX_MIN_CONTEXT { + &REASONING_MODES + } else { + &STANDARD_REASONING_MODES + } + } + pub(crate) fn validate(&self) -> Result<(), String> { if self.context_tokens <= 0 { return Err("Context tokens must be a positive whole number.".into()); @@ -588,13 +606,7 @@ impl GenerationPreferences { top_k: 0, stops: Vec::new(), seed: self.seed, - reasoning_mode: if self.reasoning_mode == ReasoningMode::Max - && self.context_tokens < THINK_MAX_MIN_CONTEXT - { - ReasoningMode::High - } else { - self.reasoning_mode - }, + reasoning_mode: self.effective_reasoning_mode(), } } } @@ -680,6 +692,11 @@ mod tests { let glm = defaults.turn_settings(ModelChoice::Glm52, cache); assert_eq!((glm.temperature, glm.top_p, glm.min_p), (1.0, 0.95, 0.0)); + assert_eq!( + defaults.supported_reasoning_modes(), + &[ReasoningMode::High, ReasoningMode::Direct] + ); + assert_eq!(defaults.effective_reasoning_mode(), ReasoningMode::High); let explicit = GenerationPreferences { top_p: Some(0.4), @@ -690,6 +707,38 @@ mod tests { let effective = explicit.turn_settings(ModelChoice::Glm52, cache); assert_eq!((effective.top_p, effective.min_p), (0.4, 0.2)); assert_eq!(effective.reasoning_mode, ReasoningMode::High); + + let long_context = GenerationPreferences { + context_tokens: THINK_MAX_MIN_CONTEXT, + reasoning_mode: ReasoningMode::Max, + ..GenerationPreferences::default() + }; + assert_eq!(long_context.supported_reasoning_modes(), &REASONING_MODES); + assert_eq!(long_context.effective_reasoning_mode(), ReasoningMode::Max); + } + + #[test] + fn reasoning_selection_reaches_every_model_turn() { + let cache = KvCachePreferences::default().settings(); + for model in crate::model::MODEL_CHOICES { + for mode in [ReasoningMode::Direct, ReasoningMode::High] { + let generation = GenerationPreferences { + reasoning_mode: mode, + ..GenerationPreferences::default() + }; + assert_eq!(generation.turn_settings(model, cache).reasoning_mode, mode); + } + + let generation = GenerationPreferences { + context_tokens: THINK_MAX_MIN_CONTEXT, + reasoning_mode: ReasoningMode::Max, + ..GenerationPreferences::default() + }; + assert_eq!( + generation.turn_settings(model, cache).reasoning_mode, + ReasoningMode::Max + ); + } } #[test]