diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 9967c7e317..6605f56813 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -4363,7 +4363,7 @@ impl A + Send + Sync> UI .and_then(|str| ConversationId::from_str(str.as_str()).ok()); // Make IO calls in parallel - let (model_id, conversation) = tokio::join!( + let (model_id, conversation, reasoning_effort) = tokio::join!( async { self.api.get_session_config().await.map(|c| c.model) }, async { if let Some(cid) = cid { @@ -4371,7 +4371,8 @@ impl A + Send + Sync> UI } else { None } - } + }, + async { self.api.get_reasoning_effort().await.ok().flatten() } ); // Calculate total cost including related conversations @@ -4392,6 +4393,14 @@ impl A + Send + Sync> UI .map(|val| val == "1") .unwrap_or(true); // Default to true + // Read terminal width from COLUMNS (propagated by the zsh shell plugin) + // so the rprompt can pick a compact or full-length reasoning effort + // label. Missing or unparseable values fall back to the full-length + // form in the renderer. + let terminal_width = std::env::var("COLUMNS") + .ok() + .and_then(|s| s.parse::().ok()); + let rprompt = ZshRPrompt::from_config(&self.config) .agent( std::env::var("_FORGE_ACTIVE_AGENT") @@ -4402,6 +4411,8 @@ impl A + Send + Sync> UI .model(model_id) .token_count(conversation.and_then(|conversation| conversation.token_count())) .cost(cost) + .reasoning_effort(reasoning_effort) + .terminal_width(terminal_width) .use_nerd_font(use_nerd_font); Some(rprompt.to_string()) diff --git a/crates/forge_main/src/zsh/rprompt.rs b/crates/forge_main/src/zsh/rprompt.rs index 58cbfd7813..9df786ac8b 100644 --- a/crates/forge_main/src/zsh/rprompt.rs +++ b/crates/forge_main/src/zsh/rprompt.rs @@ -1,29 +1,48 @@ //! ZSH right prompt implementation. //! //! Provides the right prompt (RPROMPT) display for the ZSH shell integration, -//! showing agent name, model, and token count information. +//! showing agent name, model, token count and reasoning effort information. +//! +//! The reasoning effort label is rendered in one of two forms depending on +//! the available terminal width: a three-letter abbreviation (e.g. `MED`, +//! `HIG`) on narrow terminals and the full uppercase label (e.g. `MEDIUM`, +//! `HIGH`) on wider terminals. See [`WIDE_TERMINAL_THRESHOLD`]. use std::fmt::{self, Display}; use convert_case::{Case, Casing}; use derive_setters::Setters; use forge_config::ForgeConfig; -use forge_domain::{AgentId, ModelId, TokenCount}; +use forge_domain::{AgentId, Effort, ModelId, TokenCount}; use super::style::{ZshColor, ZshStyle}; use crate::utils::humanize_number; -/// ZSH right prompt displaying agent, model, and token count. +/// ZSH right prompt displaying agent, model, token count and reasoning effort. /// /// Formats shell prompt information with appropriate colors: /// - Inactive state (no tokens): dimmed colors -/// - Active state (has tokens): bright white/cyan colors +/// - Active state (has tokens): bright white/cyan/yellow colors +/// +/// The reasoning effort label adapts to the available terminal width: on +/// narrow terminals (< [`WIDE_TERMINAL_THRESHOLD`] columns) it is rendered +/// as a three-letter abbreviation, otherwise the full uppercase label is +/// shown. When [`ZshRPrompt::terminal_width`] is unset the full-length form +/// is used as a safe default. #[derive(Setters)] pub struct ZshRPrompt { agent: Option, model: Option, token_count: Option, cost: Option, + /// Currently configured reasoning effort level for the active model. + /// Rendered to the right of the model when set. + reasoning_effort: Option, + /// Terminal width in columns, used to pick between the compact + /// three-letter label and the full-length uppercase label for + /// reasoning effort. When `None`, the prompt falls back to the + /// full-length form. + terminal_width: Option, /// Controls whether to render nerd font symbols. Defaults to `true`. #[setters(into)] use_nerd_font: bool, @@ -52,6 +71,8 @@ impl Default for ZshRPrompt { model: None, token_count: None, cost: None, + reasoning_effort: None, + terminal_width: None, use_nerd_font: true, currency_symbol: "\u{f155}".to_string(), conversion_ratio: 1.0, @@ -62,6 +83,17 @@ impl Default for ZshRPrompt { const AGENT_SYMBOL: &str = "\u{f167a}"; const MODEL_SYMBOL: &str = "\u{ec19}"; +/// Terminal width (in columns) at which the reasoning effort label switches +/// from the compact three-letter form to the full uppercase label. +/// +/// Widths greater than or equal to this threshold render the full label +/// (e.g. `MEDIUM`, `HIGH`); widths below it collapse to the first three +/// characters (e.g. `MED`, `HIG`). The value is intentionally a coarse +/// static threshold — typical RPROMPT content is around 40-50 visible +/// cells, so 100 columns leaves enough room on the left for most LPROMPTs +/// and comfortable typing space once the full label is shown. +const WIDE_TERMINAL_THRESHOLD: usize = 100; + impl Display for ZshRPrompt { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let active = *self.token_count.unwrap_or_default() > 0usize; @@ -121,12 +153,47 @@ impl Display for ZshRPrompt { 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(()) } } #[cfg(test)] mod tests { + use pretty_assertions::assert_eq; + use super::*; #[test] @@ -213,4 +280,161 @@ mod tests { let expected = " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %B%F{2}€0.01%f%b %F{134}\u{ec19} gpt-4%f"; assert_eq!(actual, expected); } + + #[test] + fn test_rprompt_with_reasoning_effort_active() { + // Active state (tokens > 0) renders reasoning effort in YELLOW to the + // right of the model. + 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)) + .to_string(); + + let expected = + " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}HIGH%f"; + assert_eq!(actual, expected); + } + + #[test] + fn test_rprompt_with_reasoning_effort_init_state() { + // Inactive state (no tokens) renders reasoning effort DIMMED. + let actual = ZshRPrompt::default() + .agent(Some(AgentId::new("forge"))) + .model(Some(ModelId::new("gpt-4"))) + .reasoning_effort(Some(Effort::Medium)) + .to_string(); + + let expected = " %B%F{240}\u{f167a} FORGE%f%b %F{240}\u{ec19} gpt-4%f %F{240}MEDIUM%f"; + assert_eq!(actual, expected); + } + + #[test] + fn test_rprompt_with_reasoning_effort_without_nerdfonts() { + // Nerd fonts disabled: agent and model lose their glyph prefixes; + // the reasoning effort remains as a plain uppercase color-coded label. + 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::Low)) + .use_nerd_font(false) + .to_string(); + + let expected = " %B%F{15}FORGE%f%b %B%F{15}1.5k%f%b %F{134}gpt-4%f %F{3}LOW%f"; + assert_eq!(actual, expected); + } + + #[test] + fn test_rprompt_with_reasoning_effort_none_variant_is_hidden() { + // `Effort::None` is semantically "no reasoning" and carries no display + // value, so the rprompt suppresses it entirely. + 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::None)) + .to_string(); + + let expected = " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f"; + assert_eq!(actual, expected); + } + + #[test] + fn test_rprompt_without_reasoning_effort_is_hidden() { + // When no reasoning effort is set, nothing is appended after the model. + let actual = ZshRPrompt::default() + .agent(Some(AgentId::new("forge"))) + .model(Some(ModelId::new("gpt-4"))) + .token_count(Some(TokenCount::Actual(1500))) + .reasoning_effort(None) + .to_string(); + + let expected = " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f"; + assert_eq!(actual, expected); + } + + #[test] + fn test_rprompt_with_reasoning_effort_xhigh() { + // `Effort::XHigh` renders as the uppercase string "XHIGH". + 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::XHigh)) + .to_string(); + + let expected = + " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}XHIGH%f"; + assert_eq!(actual, expected); + } + + #[test] + fn test_rprompt_reasoning_effort_narrow_terminal_uses_short_form() { + // Below the wide-terminal threshold, the reasoning effort collapses + // to the first three characters uppercased ("MEDIUM" -> "MED"). + 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::Medium)) + .terminal_width(Some(80)) + .to_string(); + + let expected = + " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}MED%f"; + assert_eq!(actual, expected); + } + + #[test] + fn test_rprompt_reasoning_effort_wide_terminal_uses_full_form() { + // At or above the wide-terminal threshold, the full uppercase label + // is rendered (e.g. "MEDIUM" rather than "MED"). + 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::Medium)) + .terminal_width(Some(120)) + .to_string(); + + let expected = + " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}MEDIUM%f"; + assert_eq!(actual, expected); + } + + #[test] + fn test_rprompt_reasoning_effort_at_threshold_is_full_form() { + // The threshold is inclusive: a width of exactly + // `WIDE_TERMINAL_THRESHOLD` columns renders the full label. + 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)) + .terminal_width(Some(WIDE_TERMINAL_THRESHOLD)) + .to_string(); + + let expected = + " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}HIGH%f"; + assert_eq!(actual, expected); + } + + #[test] + fn test_rprompt_reasoning_effort_short_form_minimal() { + // The longest variant name ("MINIMAL", 7 chars) must truncate to + // exactly three characters ("MIN") in the compact form. + 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::Minimal)) + .terminal_width(Some(80)) + .to_string(); + + let expected = + " %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); + } } diff --git a/crates/forge_main/src/zsh/style.rs b/crates/forge_main/src/zsh/style.rs index 5cc9fe4590..fbfe5f47ad 100644 --- a/crates/forge_main/src/zsh/style.rs +++ b/crates/forge_main/src/zsh/style.rs @@ -20,6 +20,8 @@ impl ZshColor { pub const CYAN: Self = Self(134); /// Green (color 2) pub const GREEN: Self = Self(2); + /// Yellow (color 3) + pub const YELLOW: Self = Self(3); /// Dimmed gray (color 240) pub const DIMMED: Self = Self(240); diff --git a/shell-plugin/forge.theme.zsh b/shell-plugin/forge.theme.zsh index fab0e5de6c..adfe56791b 100644 --- a/shell-plugin/forge.theme.zsh +++ b/shell-plugin/forge.theme.zsh @@ -18,7 +18,7 @@ function _forge_prompt_info() { [[ -n "$_FORGE_SESSION_MODEL" ]] && local -x FORGE_SESSION__MODEL_ID="$_FORGE_SESSION_MODEL" [[ -n "$_FORGE_SESSION_PROVIDER" ]] && local -x FORGE_SESSION__PROVIDER_ID="$_FORGE_SESSION_PROVIDER" [[ -n "$_FORGE_SESSION_REASONING_EFFORT" ]] && local -x FORGE_REASONING__EFFORT="$_FORGE_SESSION_REASONING_EFFORT" - _FORGE_CONVERSATION_ID=$_FORGE_CONVERSATION_ID _FORGE_ACTIVE_AGENT=$_FORGE_ACTIVE_AGENT "${forge_cmd[@]}" 2>/dev/null + _FORGE_CONVERSATION_ID=$_FORGE_CONVERSATION_ID _FORGE_ACTIVE_AGENT=$_FORGE_ACTIVE_AGENT COLUMNS=$COLUMNS "${forge_cmd[@]}" 2>/dev/null } # Right prompt: agent and model with token count (uses single forge prompt command)