From c719cc5f7f2da0fc14090b897d8636e4a465d7f8 Mon Sep 17 00:00:00 2001 From: fauzan171 Date: Sat, 16 May 2026 00:50:14 +0700 Subject: [PATCH] feat: add show_model_in_prompt config option Add a new configuration option that allows users to hide the model name from the shell prompt (both ZSH rprompt and interactive REPL prompt). This addresses privacy concerns where users may not want others to see which AI model they are using. When set to , both the model name and reasoning effort segments are hidden from the status line. Defaults to to preserve existing behavior. Usage in forge.toml: show_model_in_prompt = false Or via environment variable: FORGE_SHOW_MODEL_IN_PROMPT=false Closes #3317 --- crates/forge_config/.forge.toml | 1 + crates/forge_config/src/config.rs | 10 ++ crates/forge_main/src/prompt.rs | 84 ++++++++++++----- crates/forge_main/src/ui.rs | 1 + crates/forge_main/src/zsh/rprompt.rs | 136 ++++++++++++++++++--------- forge.schema.json | 5 + 6 files changed, 168 insertions(+), 69 deletions(-) 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",