Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/forge_config/.forge.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions crates/forge_config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
84 changes: 59 additions & 25 deletions crates/forge_main/src/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ pub struct ForgePrompt {
/// suppressed (see [`ForgePrompt::render_prompt_right`]).
pub reasoning_effort: Option<Effort>,
pub git_branch: Option<String>,
/// 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 {
Expand All @@ -56,6 +60,7 @@ impl ForgePrompt {
model: None,
reasoning_effort: None,
git_branch,
show_model: true,
}
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -287,6 +294,7 @@ mod tests {
model: None,
reasoning_effort: None,
git_branch: None,
show_model: true,
}
}
}
Expand Down Expand Up @@ -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"));
}
}
1 change: 1 addition & 0 deletions crates/forge_main/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> 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);
}
Expand Down
136 changes: 92 additions & 44 deletions crates/forge_main/src/zsh/rprompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}

Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -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::<String>()
.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::<String>()
.to_uppercase()
};
let styled = if active {
effort_label.zsh().fg(ZshColor::YELLOW)
} else {
effort_label.zsh().fg(ZshColor::DIMMED)
};
write!(f, " {}", styled)?;
}
}

Ok(())
Expand Down Expand Up @@ -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"));
}
}
5 changes: 5 additions & 0 deletions forge.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down