diff --git a/crates/forge_config/.forge.toml b/crates/forge_config/.forge.toml index 2104408aa7..59b8c774ce 100644 --- a/crates/forge_config/.forge.toml +++ b/crates/forge_config/.forge.toml @@ -34,6 +34,7 @@ currency_conversion_rate = 1.0 subagents = true use_forge_committer = true use_text_patch_fallback = false +show_model_in_prompt = true [retry] backoff_factor = 2 diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 9bb4d62091..17af9c0bb0 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -329,6 +329,16 @@ pub struct ForgeConfig { /// user or assistant turns (e.g. vLLM, NVIDIA NIM). #[serde(default)] pub merge_system_messages: bool, + + /// Whether to show the model name in the shell prompt (right prompt). + /// When set to `false`, the model name is hidden from both the ZSH + /// rprompt and the interactive REPL prompt. Defaults to `true`. + #[serde(default = "default_true")] + pub show_model_in_prompt: bool, +} + +fn default_true() -> bool { + true } impl ForgeConfig { diff --git a/crates/forge_main/src/prompt.rs b/crates/forge_main/src/prompt.rs index fce8ba4388..2e8cfd2d10 100644 --- a/crates/forge_main/src/prompt.rs +++ b/crates/forge_main/src/prompt.rs @@ -42,6 +42,10 @@ pub struct ForgePrompt { /// suppressed (see [`ForgePrompt::render_prompt_right`]). pub reasoning_effort: Option, pub git_branch: Option, + /// Whether to render the model name in the right prompt. When `false`, + /// both the model and reasoning effort segments are suppressed. + /// Defaults to `true`. + pub show_model: bool, } impl ForgePrompt { @@ -56,6 +60,7 @@ impl ForgePrompt { model: None, reasoning_effort: None, git_branch, + show_model: true, } } @@ -177,32 +182,34 @@ impl Prompt for ForgePrompt { } // Model with nerd font symbol - if let Some(model) = self.model.as_ref() { - let model_str = model.to_string(); - let short_model = model_str.split('/').next_back().unwrap_or(model.as_str()); - let model_label = format!("{MODEL_SYMBOL} {short_model}"); - let color = if active { - Color::LightMagenta - } else { - Color::DarkGray - }; - write!(result, " {}", Style::new().fg(color).paint(&model_label)).unwrap(); - } + if self.show_model { + if let Some(model) = self.model.as_ref() { + let model_str = model.to_string(); + let short_model = model_str.split('/').next_back().unwrap_or(model.as_str()); + let model_label = format!("{MODEL_SYMBOL} {short_model}"); + let color = if active { + Color::LightMagenta + } else { + Color::DarkGray + }; + write!(result, " {}", Style::new().fg(color).paint(&model_label)).unwrap(); + } - // Reasoning effort — rendered to the right of the model, matching the - // ZSH rprompt. `Effort::None` is suppressed (see zsh/rprompt.rs). On - // narrow terminals the label collapses to its first three characters - // so the prompt stays compact. - if let Some(ref effort) = self.reasoning_effort - && !matches!(effort, Effort::None) - { - let effort_label = effort_label(effort, term_width()); - let color = if active { - Color::Yellow - } else { - Color::DarkGray - }; - write!(result, " {}", Style::new().fg(color).paint(&effort_label)).unwrap(); + // Reasoning effort — rendered to the right of the model, matching the + // ZSH rprompt. `Effort::None` is suppressed (see zsh/rprompt.rs). On + // narrow terminals the label collapses to its first three characters + // so the prompt stays compact. + if let Some(ref effort) = self.reasoning_effort + && !matches!(effort, Effort::None) + { + let effort_label = effort_label(effort, term_width()); + let color = if active { + Color::Yellow + } else { + Color::DarkGray + }; + write!(result, " {}", Style::new().fg(color).paint(&effort_label)).unwrap(); + } } Cow::Owned(result) @@ -287,6 +294,7 @@ mod tests { model: None, reasoning_effort: None, git_branch: None, + show_model: true, } } } @@ -466,4 +474,30 @@ mod tests { "MEDIUM" ); } + + #[test] + fn test_render_prompt_right_hide_model() { + // When show_model is false, model and reasoning effort are hidden + let mut prompt = ForgePrompt::default(); + let _ = prompt.model(ModelId::new("gpt-4")); + let _ = prompt.reasoning_effort(Effort::High); + prompt.show_model = false; + + let actual = prompt.render_prompt_right(); + assert!(!actual.contains("gpt-4")); + assert!(!actual.contains("HIGH")); + assert!(!actual.contains("HIG")); + // Agent should still be visible + assert!(actual.contains("FORGE")); + } + + #[test] + fn test_render_prompt_right_show_model_default_true() { + // By default show_model is true, so model is visible + let mut prompt = ForgePrompt::default(); + let _ = prompt.model(ModelId::new("gpt-4")); + + let actual = prompt.render_prompt_right(); + assert!(actual.contains("gpt-4")); + } } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 7d2034f26d..f212675e12 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -323,6 +323,7 @@ impl A + Send + Sync> UI .await; let reasoning_effort = self.api.get_reasoning_effort().await.ok().flatten(); let mut forge_prompt = ForgePrompt::new(self.state.cwd.clone(), agent_id); + forge_prompt.show_model(self.config.show_model_in_prompt); if let Some(u) = usage { forge_prompt.usage(u); } diff --git a/crates/forge_main/src/zsh/rprompt.rs b/crates/forge_main/src/zsh/rprompt.rs index 9df786ac8b..4009b72dde 100644 --- a/crates/forge_main/src/zsh/rprompt.rs +++ b/crates/forge_main/src/zsh/rprompt.rs @@ -53,6 +53,10 @@ pub struct ZshRPrompt { /// Conversion ratio for cost display. Cost is multiplied by this value. /// Defaults to 1.0. conversion_ratio: f64, + /// Whether to render the model name in the prompt. Defaults to `true`. + /// When `false`, both the model and reasoning effort segments are + /// suppressed. + show_model: bool, } impl ZshRPrompt { /// Constructs a [`ZshRPrompt`] with currency settings populated from the @@ -61,6 +65,7 @@ impl ZshRPrompt { Self::default() .currency_symbol(config.currency_symbol.clone()) .conversion_ratio(config.currency_conversion_rate.value()) + .show_model(config.show_model_in_prompt) } } @@ -76,6 +81,7 @@ impl Default for ZshRPrompt { use_nerd_font: true, currency_symbol: "\u{f155}".to_string(), conversion_ratio: 1.0, + show_model: true, } } } @@ -139,51 +145,53 @@ impl Display for ZshRPrompt { } // Add model - if let Some(ref model_id) = self.model { - let model_id = if self.use_nerd_font { - format!("{MODEL_SYMBOL} {}", model_id) - } else { - model_id.to_string() - }; - let styled = if active { - model_id.zsh().fg(ZshColor::CYAN) - } else { - model_id.zsh().fg(ZshColor::DIMMED) - }; - write!(f, " {}", styled)?; - } + if self.show_model { + if let Some(ref model_id) = self.model { + let model_id = if self.use_nerd_font { + format!("{MODEL_SYMBOL} {}", model_id) + } else { + model_id.to_string() + }; + let styled = if active { + model_id.zsh().fg(ZshColor::CYAN) + } else { + model_id.zsh().fg(ZshColor::DIMMED) + }; + write!(f, " {}", styled)?; + } - // Add reasoning effort (rendered to the right of the model). - // `Effort::None` is suppressed because it carries no useful information - // for the user to see in the prompt. Below `WIDE_TERMINAL_THRESHOLD` - // columns the label collapses to its first three characters so the - // prompt stays compact on narrow terminals; above the threshold the - // full uppercase label is rendered for readability. - if let Some(ref effort) = self.reasoning_effort - && !matches!(effort, Effort::None) - { - let is_wide = - self.terminal_width.unwrap_or(WIDE_TERMINAL_THRESHOLD) >= WIDE_TERMINAL_THRESHOLD; - // Use `chars().take(3).collect()` rather than `&label[..3]` to - // satisfy the `clippy::string_slice` lint that is denied in CI. - // `Effort` serializes as lowercase ASCII, so taking the first - // three chars is always well-defined. - let effort_label = if is_wide { - effort.to_string().to_uppercase() - } else { - effort - .to_string() - .chars() - .take(3) - .collect::() - .to_uppercase() - }; - let styled = if active { - effort_label.zsh().fg(ZshColor::YELLOW) - } else { - effort_label.zsh().fg(ZshColor::DIMMED) - }; - write!(f, " {}", styled)?; + // Add reasoning effort (rendered to the right of the model). + // `Effort::None` is suppressed because it carries no useful information + // for the user to see in the prompt. Below `WIDE_TERMINAL_THRESHOLD` + // columns the label collapses to its first three characters so the + // prompt stays compact on narrow terminals; above the threshold the + // full uppercase label is rendered for readability. + if let Some(ref effort) = self.reasoning_effort + && !matches!(effort, Effort::None) + { + let is_wide = + self.terminal_width.unwrap_or(WIDE_TERMINAL_THRESHOLD) >= WIDE_TERMINAL_THRESHOLD; + // Use `chars().take(3).collect()` rather than `&label[..3]` to + // satisfy the `clippy::string_slice` lint that is denied in CI. + // `Effort` serializes as lowercase ASCII, so taking the first + // three chars is always well-defined. + let effort_label = if is_wide { + effort.to_string().to_uppercase() + } else { + effort + .to_string() + .chars() + .take(3) + .collect::() + .to_uppercase() + }; + let styled = if active { + effort_label.zsh().fg(ZshColor::YELLOW) + } else { + effort_label.zsh().fg(ZshColor::DIMMED) + }; + write!(f, " {}", styled)?; + } } Ok(()) @@ -437,4 +445,44 @@ mod tests { " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}MIN%f"; assert_eq!(actual, expected); } + + #[test] + fn test_rprompt_hide_model() { + // When show_model is false, model and reasoning effort are hidden + let actual = ZshRPrompt::default() + .agent(Some(AgentId::new("forge"))) + .model(Some(ModelId::new("gpt-4"))) + .token_count(Some(TokenCount::Actual(1500))) + .reasoning_effort(Some(Effort::High)) + .show_model(false) + .to_string(); + + let expected = " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b"; + assert_eq!(actual, expected); + } + + #[test] + fn test_rprompt_hide_model_init_state() { + // Inactive state with show_model false: no model, no reasoning effort + let actual = ZshRPrompt::default() + .agent(Some(AgentId::new("forge"))) + .model(Some(ModelId::new("gpt-4"))) + .reasoning_effort(Some(Effort::Medium)) + .show_model(false) + .to_string(); + + let expected = " %B%F{240}\u{f167a} FORGE%f%b"; + assert_eq!(actual, expected); + } + + #[test] + fn test_rprompt_show_model_default_true() { + // By default show_model is true, so model is visible + let actual = ZshRPrompt::default() + .agent(Some(AgentId::new("forge"))) + .model(Some(ModelId::new("gpt-4"))) + .to_string(); + + assert!(actual.contains("gpt-4")); + } } diff --git a/forge.schema.json b/forge.schema.json index 541c4ed6f8..032abd963b 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -290,6 +290,11 @@ } ] }, + "show_model_in_prompt": { + "description": "Whether to show the model name in the shell prompt (right prompt).\nWhen set to `false`, the model name is hidden from both the ZSH\nrprompt and the interactive REPL prompt. Defaults to `true`.", + "type": "boolean", + "default": true + }, "subagents": { "description": "Enables subagent support via the task tool; when true the forge agent\ngains access to the `task` tool for delegating work to specialised\nsub-agents, and the `sage` research-only agent tool is removed.\nWhen false the `task` tool is disabled and `sage` is available instead.", "type": "boolean",