Skip to content
Merged
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
15 changes: 13 additions & 2 deletions crates/forge_main/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4363,15 +4363,16 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> 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 {
self.api.conversation(&cid).await.ok().flatten()
} else {
None
}
}
},
async { self.api.get_reasoning_effort().await.ok().flatten() }
);

// Calculate total cost including related conversations
Expand All @@ -4392,6 +4393,14 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> 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::<usize>().ok());

let rprompt = ZshRPrompt::from_config(&self.config)
.agent(
std::env::var("_FORGE_ACTIVE_AGENT")
Expand All @@ -4402,6 +4411,8 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> 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())
Expand Down
232 changes: 228 additions & 4 deletions crates/forge_main/src/zsh/rprompt.rs
Original file line number Diff line number Diff line change
@@ -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<AgentId>,
model: Option<ModelId>,
token_count: Option<TokenCount>,
cost: Option<f64>,
/// Currently configured reasoning effort level for the active model.
/// Rendered to the right of the model when set.
reasoning_effort: Option<Effort>,
/// 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<usize>,
/// Controls whether to render nerd font symbols. Defaults to `true`.
#[setters(into)]
use_nerd_font: bool,
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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::<String>()
.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]
Expand Down Expand Up @@ -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);
}
}
2 changes: 2 additions & 0 deletions crates/forge_main/src/zsh/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion shell-plugin/forge.theme.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading