feat(cost): support yuan display (#806)

This commit is contained in:
Hunter Bown
2026-05-06 01:36:46 -05:00
committed by GitHub
parent 1981c09970
commit a2ca64018e
15 changed files with 396 additions and 92 deletions
+6
View File
@@ -366,6 +366,11 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
app.ui_locale = resolve_locale(&settings.locale);
app.needs_redraw = true;
}
"cost_currency" | "currency" => {
app.cost_currency = crate::pricing::CostCurrency::from_setting(&settings.cost_currency)
.unwrap_or(crate::pricing::CostCurrency::Usd);
app.needs_redraw = true;
}
"composer_density" | "composer" => {
app.composer_density =
crate::tui::app::ComposerDensity::from_setting(&settings.composer_density);
@@ -420,6 +425,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
let display_value = match key.as_str() {
"default_mode" | "mode" => settings.default_mode.clone(),
"cost_currency" | "currency" => settings.cost_currency.clone(),
_ => value.to_string(),
};
+8 -3
View File
@@ -71,7 +71,10 @@ pub fn tokens(app: &mut App) -> CommandResult {
)
.replace("{cache}", &cache_summary(app, locale))
.replace("{total}", &app.session.total_tokens.to_string())
.replace("{cost}", &format!("{:.4}", app.session.session_cost))
.replace(
"{cost}",
&app.format_cost_amount_precise(app.session_cost_for_currency(app.cost_currency)),
)
.replace("{api_messages}", &message_count.to_string())
.replace("{chat_messages}", &chat_count.to_string())
.replace("{model}", &app.model);
@@ -80,8 +83,10 @@ pub fn tokens(app: &mut App) -> CommandResult {
/// Show session cost breakdown
pub fn cost(app: &mut App) -> CommandResult {
let report = tr(app.ui_locale, MessageId::CmdCostReport)
.replace("{cost}", &format!("{:.4}", app.session.session_cost));
let report = tr(app.ui_locale, MessageId::CmdCostReport).replace(
"{cost}",
&app.format_cost_amount_precise(app.session_cost_for_currency(app.cost_currency)),
);
CommandResult::message(report)
}
+2 -2
View File
@@ -398,7 +398,7 @@ pub enum StatusItem {
Mode,
/// Model identifier (e.g. `deepseek-v4-pro`).
Model,
/// Session cost in USD ("$0.42").
/// Session cost in the configured display currency.
Cost,
/// Activity label: "ready" / "draft" / "working".
Status,
@@ -483,7 +483,7 @@ impl StatusItem {
match self {
StatusItem::Mode => "agent · yolo · plan",
StatusItem::Model => "the model id you'll send to",
StatusItem::Cost => "running USD total for this session",
StatusItem::Cost => "running total for this session",
StatusItem::Status => "what the agent is doing right now",
StatusItem::Coherence => "shown only when the engine intervenes",
StatusItem::Agents => "agents or RLM work in progress",
+26 -20
View File
@@ -10,7 +10,7 @@
//! Mirrors the [`crate::retry_status`] pattern: background callers
//! call [`report`] after each `client.create_message`, the TUI
//! render loop calls [`drain`] every frame, and any drained amount
//! gets folded into `App::accrue_subagent_cost`.
//! gets folded into `App::accrue_subagent_cost_estimate`.
//!
//! Why a side-channel and not a plumbed callback: the leaky callers
//! (`compaction.rs`, `seam_manager.rs`, `cycle_manager.rs`) are
@@ -23,37 +23,39 @@
use std::sync::{Mutex, OnceLock};
use crate::models::Usage;
use crate::pricing::CostEstimate;
static PENDING: OnceLock<Mutex<f64>> = OnceLock::new();
static PENDING: OnceLock<Mutex<CostEstimate>> = OnceLock::new();
fn cell() -> &'static Mutex<f64> {
PENDING.get_or_init(|| Mutex::new(0.0))
fn cell() -> &'static Mutex<CostEstimate> {
PENDING.get_or_init(|| Mutex::new(CostEstimate::default()))
}
/// Background callers report their LLM usage here. Computes the
/// cost via [`crate::pricing::calculate_turn_cost_from_usage`] and
/// cost via [`crate::pricing::calculate_turn_cost_estimate_from_usage`] and
/// adds it to the pending pool. Cheap; takes a short-lived lock
/// and returns. No-op on models the pricing table doesn't know.
pub fn report(model: &str, usage: &Usage) {
let Some(cost) = crate::pricing::calculate_turn_cost_from_usage(model, usage) else {
let Some(cost) = crate::pricing::calculate_turn_cost_estimate_from_usage(model, usage) else {
return;
};
if cost <= 0.0 {
if !cost.is_positive() {
return;
}
if let Ok(mut pending) = cell().lock() {
*pending += cost;
pending.usd += cost.usd;
pending.cny += cost.cny;
}
}
/// Drain the pending cost. Returns the accumulated amount and resets
/// the pool to zero. Called by the TUI render / event loop on each
/// frame; any non-zero result gets folded into `accrue_subagent_cost`.
pub fn drain() -> f64 {
/// frame; any non-zero result gets folded into `accrue_subagent_cost_estimate`.
pub fn drain() -> CostEstimate {
let Ok(mut pending) = cell().lock() else {
return 0.0;
return CostEstimate::default();
};
std::mem::replace(&mut *pending, 0.0)
std::mem::take(&mut *pending)
}
/// Reset the pool to zero without consuming. Test-only helper for
@@ -62,7 +64,7 @@ pub fn drain() -> f64 {
#[cfg(test)]
pub fn reset_for_tests() {
if let Ok(mut pending) = cell().lock() {
*pending = 0.0;
*pending = CostEstimate::default();
}
}
@@ -94,9 +96,10 @@ mod tests {
reset_for_tests();
report("deepseek-v4-flash", &small_usage());
let first = drain();
assert!(first > 0.0, "expected positive cost, got {first}");
assert!(first.usd > 0.0, "expected positive USD cost, got {first:?}");
assert!(first.cny > 0.0, "expected positive CNY cost, got {first:?}");
let second = drain();
assert_eq!(second, 0.0, "drain must zero the pool");
assert_eq!(second, CostEstimate::default(), "drain must zero the pool");
}
#[test]
@@ -105,7 +108,7 @@ mod tests {
reset_for_tests();
// NIM-hosted models intentionally have no DeepSeek pricing.
report("deepseek-ai/deepseek-v4-pro", &small_usage());
assert_eq!(drain(), 0.0);
assert_eq!(drain(), CostEstimate::default());
}
#[test]
@@ -116,9 +119,12 @@ mod tests {
report("deepseek-v4-flash", &small_usage());
let total = drain();
// Two equal reports — total must be 2× a single report.
let single =
crate::pricing::calculate_turn_cost_from_usage("deepseek-v4-flash", &small_usage())
.unwrap();
assert!((total - 2.0 * single).abs() < 1e-12);
let single = crate::pricing::calculate_turn_cost_estimate_from_usage(
"deepseek-v4-flash",
&small_usage(),
)
.unwrap();
assert!((total.usd - 2.0 * single.usd).abs() < 1e-12);
assert!((total.cny - 2.0 * single.cny).abs() < 1e-12);
}
}
+8 -8
View File
@@ -810,7 +810,7 @@ fn english(id: MessageId) -> &'static str {
MessageId::CmdCostReport => {
"Session Cost:\n\
\n\
Approx total spent: ${cost}\n\n\
Approx total spent: {cost}\n\n\
Cost estimates are approximate and use provider usage telemetry when available.\n\n\
DeepSeek API Pricing:\n\
\n\
@@ -841,7 +841,7 @@ fn english(id: MessageId) -> &'static str {
Last API output: {output}\n\
Cache hit/miss: {cache} (telemetry/cost only)\n\
Cumulative tokens: {total} (session usage telemetry)\n\
Approx session cost: ${cost}\n\
Approx session cost: {cost}\n\
API messages: {api_messages}\n\
Chat messages: {chat_messages}\n\
Model: {model}"
@@ -1089,7 +1089,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
MessageId::CmdCostReport => {
"セッション費用:\n\
\n\
: ${cost}\n\n\
: {cost}\n\n\
使使\n\n\
DeepSeek API :\n\
\n\
@@ -1120,7 +1120,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
API : {output}\n\
/: {cache}/\n\
: {total}使\n\
: ${cost}\n\
: {cost}\n\
API : {api_messages}\n\
: {chat_messages}\n\
: {model}"
@@ -1337,7 +1337,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
MessageId::CmdCostReport => {
"会话费用:\n\
\n\
${cost}\n\n\
{cost}\n\n\
使\n\n\
DeepSeek API \n\
\n\
@@ -1368,7 +1368,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
API {output}\n\
/ {cache}/\n\
{total}\n\
${cost}\n\
{cost}\n\
API {api_messages}\n\
{chat_messages}\n\
{model}"
@@ -1611,7 +1611,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
MessageId::CmdCostReport => {
"Custo da sessão:\n\
\n\
Total aproximado: ${cost}\n\n\
Total aproximado: {cost}\n\n\
Estimativas de custo são aproximadas e usam a telemetria de uso do provedor quando disponível.\n\n\
Preços da API DeepSeek:\n\
\n\
@@ -1642,7 +1642,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
Última saída da API: {output}\n\
Hit/miss do cache: {cache} (apenas para telemetria/custo)\n\
Tokens acumulados: {total} (telemetria de uso da sessão)\n\
Custo aproximado: ${cost}\n\
Custo aproximado: {cost}\n\
Mensagens da API: {api_messages}\n\
Mensagens do chat: {chat_messages}\n\
Modelo: {model}"
+199 -36
View File
@@ -6,13 +6,70 @@ use chrono::{DateTime, TimeZone, Utc};
use crate::models::Usage;
/// Cost display currency.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CostCurrency {
Usd,
Cny,
}
impl CostCurrency {
pub fn from_setting(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"usd" | "dollar" | "dollars" | "$" => Some(Self::Usd),
"cny" | "rmb" | "yuan" | "¥" => Some(Self::Cny),
_ => None,
}
}
fn symbol(self) -> &'static str {
match self {
Self::Usd => "$",
Self::Cny => "¥",
}
}
}
/// Cost estimate in the two official DeepSeek pricing currencies.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct CostEstimate {
pub usd: f64,
pub cny: f64,
}
impl CostEstimate {
#[allow(dead_code)]
pub fn usd_only(usd: f64) -> Self {
Self { usd, cny: 0.0 }
}
pub fn is_positive(self) -> bool {
self.usd > 0.0 || self.cny > 0.0
}
pub fn amount(self, currency: CostCurrency) -> f64 {
match currency {
CostCurrency::Usd => self.usd,
CostCurrency::Cny => self.cny,
}
}
}
/// Per-million-token pricing for a model.
struct ModelPricing {
#[derive(Debug, Clone, Copy)]
struct CurrencyPricing {
input_cache_hit_per_million: f64,
input_cache_miss_per_million: f64,
output_per_million: f64,
}
/// Per-million-token pricing for a model in both official currencies.
#[derive(Debug, Clone, Copy)]
struct ModelPricing {
usd: CurrencyPricing,
cny: CurrencyPricing,
}
fn v4_pro_discount_ends_at() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 5, 31, 15, 59, 0)
.single()
@@ -39,22 +96,43 @@ fn pricing_for_model_at(model: &str, now: DateTime<Utc>) -> Option<ModelPricing>
// DeepSeek lists these as a limited-time 75% discount through
// 2026-05-31 15:59 UTC.
return Some(ModelPricing {
input_cache_hit_per_million: 0.003625,
input_cache_miss_per_million: 0.435,
output_per_million: 0.87,
usd: CurrencyPricing {
input_cache_hit_per_million: 0.003625,
input_cache_miss_per_million: 0.435,
output_per_million: 0.87,
},
cny: CurrencyPricing {
input_cache_hit_per_million: 0.025,
input_cache_miss_per_million: 3.0,
output_per_million: 6.0,
},
});
}
Some(ModelPricing {
input_cache_hit_per_million: 0.0145,
input_cache_miss_per_million: 1.74,
output_per_million: 3.48,
usd: CurrencyPricing {
input_cache_hit_per_million: 0.0145,
input_cache_miss_per_million: 1.74,
output_per_million: 3.48,
},
cny: CurrencyPricing {
input_cache_hit_per_million: 0.1,
input_cache_miss_per_million: 12.0,
output_per_million: 24.0,
},
})
} else {
// deepseek-v4-flash pricing.
Some(ModelPricing {
input_cache_hit_per_million: 0.0028,
input_cache_miss_per_million: 0.14,
output_per_million: 0.28,
usd: CurrencyPricing {
input_cache_hit_per_million: 0.0028,
input_cache_miss_per_million: 0.14,
output_per_million: 0.28,
},
cny: CurrencyPricing {
input_cache_hit_per_million: 0.02,
input_cache_miss_per_million: 1.0,
output_per_million: 2.0,
},
})
}
}
@@ -63,16 +141,25 @@ fn pricing_for_model_at(model: &str, now: DateTime<Utc>) -> Option<ModelPricing>
#[must_use]
#[allow(dead_code)]
pub fn calculate_turn_cost(model: &str, input_tokens: u32, output_tokens: u32) -> Option<f64> {
calculate_turn_cost_estimate(model, input_tokens, output_tokens).map(|estimate| estimate.usd)
}
/// Calculate cost for a turn in both official currencies.
#[must_use]
pub fn calculate_turn_cost_estimate(
model: &str,
input_tokens: u32,
output_tokens: u32,
) -> Option<CostEstimate> {
let pricing = pricing_for_model(model)?;
Some(calculate_turn_cost_with_pricing(
pricing,
input_tokens,
output_tokens,
))
Some(CostEstimate {
usd: calculate_turn_cost_with_pricing(pricing.usd, input_tokens, output_tokens),
cny: calculate_turn_cost_with_pricing(pricing.cny, input_tokens, output_tokens),
})
}
fn calculate_turn_cost_with_pricing(
pricing: ModelPricing,
pricing: CurrencyPricing,
input_tokens: u32,
output_tokens: u32,
) -> f64 {
@@ -84,11 +171,20 @@ fn calculate_turn_cost_with_pricing(
/// Calculate cost from provider usage, honoring DeepSeek context-cache fields.
#[must_use]
pub fn calculate_turn_cost_from_usage(model: &str, usage: &Usage) -> Option<f64> {
let pricing = pricing_for_model(model)?;
Some(calculate_turn_cost_from_usage_with_pricing(pricing, usage))
calculate_turn_cost_estimate_from_usage(model, usage).map(|estimate| estimate.usd)
}
fn calculate_turn_cost_from_usage_with_pricing(pricing: ModelPricing, usage: &Usage) -> f64 {
/// Calculate cost from provider usage in both official currencies.
#[must_use]
pub fn calculate_turn_cost_estimate_from_usage(model: &str, usage: &Usage) -> Option<CostEstimate> {
let pricing = pricing_for_model(model)?;
Some(CostEstimate {
usd: calculate_turn_cost_from_usage_with_pricing(pricing.usd, usage),
cny: calculate_turn_cost_from_usage_with_pricing(pricing.cny, usage),
})
}
fn calculate_turn_cost_from_usage_with_pricing(pricing: CurrencyPricing, usage: &Usage) -> f64 {
let hit_tokens = usage.prompt_cache_hit_tokens.unwrap_or(0);
let miss_tokens = usage
.prompt_cache_miss_tokens
@@ -107,17 +203,39 @@ fn calculate_turn_cost_from_usage_with_pricing(pricing: ModelPricing, usage: &Us
#[must_use]
#[allow(dead_code)]
pub fn format_cost(cost: f64) -> String {
format_cost_amount(cost, CostCurrency::Usd)
}
/// Format a cost amount for compact display in the chosen currency.
#[must_use]
pub fn format_cost_amount(cost: f64, currency: CostCurrency) -> String {
let symbol = currency.symbol();
if cost < 0.0001 {
"<$0.0001".to_string()
format!("<{symbol}0.0001")
} else if cost < 0.01 {
format!("${:.4}", cost)
} else if cost < 1.0 {
format!("${:.3}", cost)
format!("{symbol}{cost:.4}")
} else {
format!("${:.2}", cost)
format!("{symbol}{cost:.2}")
}
}
/// Format a cost amount for detailed reports in the chosen currency.
#[must_use]
pub fn format_cost_amount_precise(cost: f64, currency: CostCurrency) -> String {
let symbol = currency.symbol();
if cost < 0.0001 {
format!("<{symbol}0.0001")
} else {
format!("{symbol}{cost:.4}")
}
}
/// Format a dual-currency estimate using the selected display currency.
#[must_use]
pub fn format_cost_estimate(estimate: CostEstimate, currency: CostCurrency) -> String {
format_cost_amount(estimate.amount(currency), currency)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -135,9 +253,12 @@ mod tests {
.unwrap();
let pricing = pricing_for_model_at("deepseek-v4-pro", before_expiry).unwrap();
assert_eq!(pricing.input_cache_hit_per_million, 0.003625);
assert_eq!(pricing.input_cache_miss_per_million, 0.435);
assert_eq!(pricing.output_per_million, 0.87);
assert_eq!(pricing.usd.input_cache_hit_per_million, 0.003625);
assert_eq!(pricing.usd.input_cache_miss_per_million, 0.435);
assert_eq!(pricing.usd.output_per_million, 0.87);
assert_eq!(pricing.cny.input_cache_hit_per_million, 0.025);
assert_eq!(pricing.cny.input_cache_miss_per_million, 3.0);
assert_eq!(pricing.cny.output_per_million, 6.0);
}
#[test]
@@ -148,9 +269,12 @@ mod tests {
.unwrap();
let pricing = pricing_for_model_at("deepseek-v4-pro", after_expiry).unwrap();
assert_eq!(pricing.input_cache_hit_per_million, 0.0145);
assert_eq!(pricing.input_cache_miss_per_million, 1.74);
assert_eq!(pricing.output_per_million, 3.48);
assert_eq!(pricing.usd.input_cache_hit_per_million, 0.0145);
assert_eq!(pricing.usd.input_cache_miss_per_million, 1.74);
assert_eq!(pricing.usd.output_per_million, 3.48);
assert_eq!(pricing.cny.input_cache_hit_per_million, 0.1);
assert_eq!(pricing.cny.input_cache_miss_per_million, 12.0);
assert_eq!(pricing.cny.output_per_million, 24.0);
}
#[test]
@@ -159,9 +283,9 @@ mod tests {
let after_old_expiry = Utc.with_ymd_and_hms(2026, 5, 6, 0, 0, 0).single().unwrap();
let pricing = pricing_for_model_at("deepseek-v4-pro", after_old_expiry).unwrap();
assert_eq!(pricing.input_cache_hit_per_million, 0.003625);
assert_eq!(pricing.input_cache_miss_per_million, 0.435);
assert_eq!(pricing.output_per_million, 0.87);
assert_eq!(pricing.usd.input_cache_hit_per_million, 0.003625);
assert_eq!(pricing.usd.input_cache_miss_per_million, 0.435);
assert_eq!(pricing.usd.output_per_million, 0.87);
}
#[test]
@@ -169,8 +293,47 @@ mod tests {
let now = Utc.with_ymd_and_hms(2026, 4, 25, 0, 0, 0).single().unwrap();
let pricing = pricing_for_model_at("deepseek-v4-flash", now).unwrap();
assert_eq!(pricing.input_cache_hit_per_million, 0.0028);
assert_eq!(pricing.input_cache_miss_per_million, 0.14);
assert_eq!(pricing.output_per_million, 0.28);
assert_eq!(pricing.usd.input_cache_hit_per_million, 0.0028);
assert_eq!(pricing.usd.input_cache_miss_per_million, 0.14);
assert_eq!(pricing.usd.output_per_million, 0.28);
assert_eq!(pricing.cny.input_cache_hit_per_million, 0.02);
assert_eq!(pricing.cny.input_cache_miss_per_million, 1.0);
assert_eq!(pricing.cny.output_per_million, 2.0);
}
#[test]
fn cost_estimate_calculates_usd_and_cny() {
let estimate = calculate_turn_cost_estimate("deepseek-v4-flash", 1_000_000, 500_000)
.expect("estimate");
assert_eq!(estimate.usd, 0.28);
assert_eq!(estimate.cny, 2.0);
}
#[test]
fn cost_currency_accepts_yuan_aliases() {
assert_eq!(CostCurrency::from_setting("usd"), Some(CostCurrency::Usd));
assert_eq!(CostCurrency::from_setting("yuan"), Some(CostCurrency::Cny));
assert_eq!(CostCurrency::from_setting("rmb"), Some(CostCurrency::Cny));
assert_eq!(CostCurrency::from_setting("cny"), Some(CostCurrency::Cny));
assert_eq!(CostCurrency::from_setting("eur"), None);
}
#[test]
fn format_cost_amount_uses_selected_symbol() {
assert_eq!(format_cost_amount(0.42, CostCurrency::Usd), "$0.42");
assert_eq!(format_cost_amount(2.0, CostCurrency::Cny), "¥2.00");
}
#[test]
fn format_cost_amount_precise_keeps_report_precision() {
assert_eq!(
format_cost_amount_precise(0.1234, CostCurrency::Usd),
"$0.1234"
);
assert_eq!(
format_cost_amount_precise(0.1234, CostCurrency::Cny),
"¥0.1234"
);
}
}
+34
View File
@@ -204,6 +204,8 @@ pub struct Settings {
/// Enable the session-context panel (#504). Shows working set, tokens,
/// cost, MCP/LSP status, cycle count, and memory info.
pub context_panel: bool,
/// Cost display currency: usd or cny.
pub cost_currency: String,
/// Maximum number of input history entries to save
pub max_input_history: usize,
/// Default model to use
@@ -239,6 +241,7 @@ impl Default for Settings {
sidebar_width_percent: 28,
sidebar_focus: "auto".to_string(),
context_panel: false,
cost_currency: "usd".to_string(),
max_input_history: 100,
default_model: None,
}
@@ -424,6 +427,18 @@ impl Settings {
};
self.sidebar_focus = normalized.to_string();
}
"cost_currency" | "currency" => {
let Some(currency) = crate::pricing::CostCurrency::from_setting(value) else {
anyhow::bail!(
"Failed to update setting: invalid cost currency '{value}'. Expected: usd, cny, rmb, yuan."
);
};
self.cost_currency = match currency {
crate::pricing::CostCurrency::Usd => "usd",
crate::pricing::CostCurrency::Cny => "cny",
}
.to_string();
}
"max_history" | "history" => {
let max: usize = value.parse().map_err(|_| {
anyhow::anyhow!(
@@ -486,6 +501,7 @@ impl Settings {
self.sidebar_width_percent
));
lines.push(format!(" sidebar_focus: {}", self.sidebar_focus));
lines.push(format!(" cost_currency: {}", self.cost_currency));
lines.push(format!(" max_history: {}", self.max_input_history));
lines.push(format!(
" default_model: {}",
@@ -546,6 +562,7 @@ impl Settings {
"sidebar_focus",
"Sidebar focus: auto, plan, todos, tasks, agents",
),
("cost_currency", "Cost display currency: usd, cny"),
("max_history", "Max input history entries"),
(
"default_model",
@@ -688,6 +705,23 @@ mod tests {
assert!(err.to_string().contains("invalid locale"));
}
#[test]
fn cost_currency_normalizes_yuan_aliases_and_rejects_unknowns() {
let mut settings = Settings::default();
assert_eq!(settings.cost_currency, "usd");
settings.set("cost_currency", "yuan").expect("set yuan");
assert_eq!(settings.cost_currency, "cny");
settings.set("currency", "rmb").expect("set rmb");
assert_eq!(settings.cost_currency, "cny");
let err = settings
.set("cost_currency", "eur")
.expect_err("unsupported currency");
assert!(err.to_string().contains("invalid cost currency"));
}
#[test]
fn display_localizes_header_and_config_file_label() {
let settings = Settings::default();
+69 -4
View File
@@ -19,6 +19,7 @@ use crate::hooks::{HookContext, HookEvent, HookExecutor, HookResult};
use crate::localization::{Locale, MessageId, resolve_locale, tr};
use crate::models::{Message, SystemPrompt, compaction_threshold_for_model_and_effort};
use crate::palette::{self, UiTheme};
use crate::pricing::{CostCurrency, CostEstimate};
use crate::session_manager::SessionContextReference;
use crate::settings::Settings;
use crate::tools::plan::{SharedPlanState, new_shared_plan_state};
@@ -562,9 +563,12 @@ pub struct GoalState {
#[derive(Debug, Clone)]
pub struct SessionState {
pub session_cost: f64,
pub session_cost_cny: f64,
pub subagent_cost: f64,
pub subagent_cost_cny: f64,
pub subagent_cost_event_seqs: HashSet<u64>,
pub displayed_cost_high_water: f64,
pub displayed_cost_high_water_cny: f64,
pub last_prompt_tokens: Option<u32>,
pub last_completion_tokens: Option<u32>,
pub last_prompt_cache_hit_tokens: Option<u32>,
@@ -579,9 +583,12 @@ impl Default for SessionState {
fn default() -> Self {
Self {
session_cost: 0.0,
session_cost_cny: 0.0,
subagent_cost: 0.0,
subagent_cost_cny: 0.0,
subagent_cost_event_seqs: HashSet::new(),
displayed_cost_high_water: 0.0,
displayed_cost_high_water_cny: 0.0,
last_prompt_tokens: None,
last_completion_tokens: None,
last_prompt_cache_hit_tokens: None,
@@ -670,6 +677,7 @@ pub struct App {
pub show_thinking: bool,
pub show_tool_details: bool,
pub ui_locale: Locale,
pub cost_currency: CostCurrency,
pub composer_density: ComposerDensity,
pub composer_border: bool,
pub transcript_spacing: TranscriptSpacing,
@@ -1073,6 +1081,8 @@ impl App {
let show_thinking = settings.show_thinking;
let show_tool_details = settings.show_tool_details;
let ui_locale = resolve_locale(&settings.locale);
let cost_currency =
CostCurrency::from_setting(&settings.cost_currency).unwrap_or(CostCurrency::Usd);
let composer_density = ComposerDensity::from_setting(&settings.composer_density);
let composer_border = settings.composer_border;
let composer_vim_enabled = settings
@@ -1219,6 +1229,7 @@ impl App {
show_thinking,
show_tool_details,
ui_locale,
cost_currency,
composer_density,
composer_border,
transcript_spacing,
@@ -1521,15 +1532,29 @@ impl App {
/// Add `delta` to the parent-turn session cost and bump the displayed
/// high-water mark so the footer total never reverses (#244).
#[allow(dead_code)]
pub fn accrue_session_cost(&mut self, delta: f64) {
self.session.session_cost += delta;
self.accrue_session_cost_estimate(CostEstimate::usd_only(delta));
}
/// Add a dual-currency parent-turn cost estimate.
pub fn accrue_session_cost_estimate(&mut self, estimate: CostEstimate) {
self.session.session_cost += estimate.usd;
self.session.session_cost_cny += estimate.cny;
self.refresh_displayed_cost_high_water();
}
/// Add `delta` to the running sub-agent cost and bump the displayed
/// high-water mark so the footer total never reverses (#244).
#[allow(dead_code)]
pub fn accrue_subagent_cost(&mut self, delta: f64) {
self.session.subagent_cost += delta;
self.accrue_subagent_cost_estimate(CostEstimate::usd_only(delta));
}
/// Add a dual-currency sub-agent/background cost estimate.
pub fn accrue_subagent_cost_estimate(&mut self, estimate: CostEstimate) {
self.session.subagent_cost += estimate.usd;
self.session.subagent_cost_cny += estimate.cny;
self.refresh_displayed_cost_high_water();
}
@@ -1540,14 +1565,54 @@ impl App {
if current > self.session.displayed_cost_high_water {
self.session.displayed_cost_high_water = current;
}
let current_cny = self.session.session_cost_cny + self.session.subagent_cost_cny;
if current_cny > self.session.displayed_cost_high_water_cny {
self.session.displayed_cost_high_water_cny = current_cny;
}
}
/// Read the visible session+sub-agent cost. Guaranteed monotonic across
/// reconciliation events (cache adjustments, provisional → final swaps)
/// for the lifetime of one session (#244).
#[allow(dead_code)]
pub fn displayed_session_cost(&self) -> f64 {
let current = self.session.session_cost + self.session.subagent_cost;
current.max(self.session.displayed_cost_high_water)
self.displayed_session_cost_for_currency(CostCurrency::Usd)
}
/// Read the visible session+sub-agent cost in the chosen currency.
pub fn displayed_session_cost_for_currency(&self, currency: CostCurrency) -> f64 {
match currency {
CostCurrency::Usd => {
let current = self.session.session_cost + self.session.subagent_cost;
current.max(self.session.displayed_cost_high_water)
}
CostCurrency::Cny => {
let current = self.session.session_cost_cny + self.session.subagent_cost_cny;
current.max(self.session.displayed_cost_high_water_cny)
}
}
}
pub fn session_cost_for_currency(&self, currency: CostCurrency) -> f64 {
match currency {
CostCurrency::Usd => self.session.session_cost,
CostCurrency::Cny => self.session.session_cost_cny,
}
}
pub fn subagent_cost_for_currency(&self, currency: CostCurrency) -> f64 {
match currency {
CostCurrency::Usd => self.session.subagent_cost,
CostCurrency::Cny => self.session.subagent_cost_cny,
}
}
pub fn format_cost_amount(&self, amount: f64) -> String {
crate::pricing::format_cost_amount(amount, self.cost_currency)
}
pub fn format_cost_amount_precise(&self, amount: f64) -> String {
crate::pricing::format_cost_amount_precise(amount, self.cost_currency)
}
/// Fold the oldest [`Self::HISTORY_FOLD_BATCH`] cells into a single
+7 -3
View File
@@ -669,11 +669,15 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &App) {
)));
// ── Session cost ─────────────────────────────────────────────
let total_cost = app.session.session_cost + app.session.subagent_cost;
let total_cost = app.displayed_session_cost_for_currency(app.cost_currency);
let session_cost = app.session_cost_for_currency(app.cost_currency);
let agent_cost = app.subagent_cost_for_currency(app.cost_currency);
lines.push(Line::from(Span::styled(
format!(
"cost: ${total_cost:.4} (session ${:.4} + agents ${:.4})",
app.session.session_cost, app.session.subagent_cost
"cost: {} (session {} + agents {})",
app.format_cost_amount(total_cost),
app.format_cost_amount(session_cost),
app.format_cost_amount(agent_cost)
),
Style::default().fg(palette::TEXT_MUTED),
)));
+3 -2
View File
@@ -94,9 +94,10 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox
// Accumulate sub-agent token costs for the real-time footer counter (#166).
if let MailboxMessage::TokenUsage { model, usage, .. } = message {
if app.session.subagent_cost_event_seqs.insert(seq)
&& let Some(cost) = crate::pricing::calculate_turn_cost_from_usage(model, usage)
&& let Some(cost) =
crate::pricing::calculate_turn_cost_estimate_from_usage(model, usage)
{
app.accrue_subagent_cost(cost);
app.accrue_subagent_cost_estimate(cost);
}
return; // No card visual change needed; the footer handles display.
}
+2 -2
View File
@@ -380,8 +380,8 @@ fn accrue_child_token_cost_if_any(app: &mut App, result: &Result<ToolResult, Too
reasoning_replay_tokens: None,
server_tool_use: None,
};
if let Some(cost) = crate::pricing::calculate_turn_cost_from_usage(model, &usage) {
app.accrue_subagent_cost(cost);
if let Some(cost) = crate::pricing::calculate_turn_cost_estimate_from_usage(model, &usage) {
app.accrue_subagent_cost_estimate(cost);
}
}
+16 -10
View File
@@ -929,10 +929,12 @@ async fn run_event_loop(
} else {
&app.model
};
let turn_cost =
crate::pricing::calculate_turn_cost_from_usage(pricing_model, &usage);
let turn_cost = crate::pricing::calculate_turn_cost_estimate_from_usage(
pricing_model,
&usage,
);
if let Some(cost) = turn_cost {
app.accrue_session_cost(cost);
app.accrue_session_cost_estimate(cost);
}
// Emit OSC 9 / BEL desktop notification for long turns.
@@ -952,7 +954,11 @@ async fn run_event_loop(
crate::tui::notifications::humanize_duration(turn_elapsed);
match turn_cost {
Some(c) => {
format!("deepseek: turn complete ({human}, ${c:.2})")
let cost = crate::pricing::format_cost_estimate(
c,
app.cost_currency,
);
format!("deepseek: turn complete ({human}, {cost})")
}
None => format!("deepseek: turn complete ({human})"),
}
@@ -1393,8 +1399,8 @@ async fn run_event_loop(
// the pool once per loop iteration so the footer chip matches
// the DeepSeek website's billing.
let pending_bg_cost = crate::cost_status::drain();
if pending_bg_cost > 0.0 {
app.accrue_subagent_cost(pending_bg_cost);
if pending_bg_cost.is_positive() {
app.accrue_subagent_cost_estimate(pending_bg_cost);
app.needs_redraw = true;
}
// Expire the "Press Ctrl+C again to quit" prompt silently after its
@@ -6237,10 +6243,10 @@ fn render_footer_from(
} else {
Vec::new()
};
let displayed_cost = app.displayed_session_cost();
let displayed_cost = app.displayed_session_cost_for_currency(app.cost_currency);
let cost = if has(S::Cost) && displayed_cost > 0.001 {
vec![Span::styled(
format!("${displayed_cost:.2}"),
app.format_cost_amount(displayed_cost),
Style::default().fg(palette::TEXT_MUTED),
)]
} else {
@@ -6333,10 +6339,10 @@ fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec<Span<'static>> {
crate::tui::widgets::footer_agents_chip(running_agent_count(app), app.ui_locale);
let replay_spans = footer_reasoning_replay_spans(app);
let cache_spans = footer_cache_spans(app);
let displayed_cost = app.displayed_session_cost();
let displayed_cost = app.displayed_session_cost_for_currency(app.cost_currency);
let cost_spans = if displayed_cost > 0.001 {
vec![Span::styled(
format!("${displayed_cost:.2}"),
app.format_cost_amount(displayed_cost),
Style::default().fg(palette::TEXT_MUTED),
)]
} else {
+11
View File
@@ -1064,6 +1064,17 @@ fn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {
);
}
#[test]
fn footer_auxiliary_spans_use_configured_cost_currency() {
let mut app = create_test_app();
app.cost_currency = crate::pricing::CostCurrency::Cny;
app.session.session_cost_cny = 2.5;
let roomy = spans_text(&footer_auxiliary_spans(&app, 32));
assert!(roomy.contains("¥2.50"));
assert!(!roomy.contains('$'));
}
#[test]
fn footer_auxiliary_spans_show_reasoning_replay_chip() {
// Issue #30: when a thinking-mode tool-calling turn replays prior
+1 -1
View File
@@ -29,7 +29,7 @@ pub struct HeaderData<'a> {
pub total_tokens: u32,
/// Context window size for the model (if known).
pub context_window: Option<u32>,
/// Accumulated session cost in USD.
/// Accumulated session cost in the active display currency.
pub session_cost: f64,
/// Active context input tokens used for context utilization. Callers should
/// pass a sanitized live-context estimate, not cumulative API usage.
+4 -1
View File
@@ -242,6 +242,9 @@ Common settings keys:
locale. `auto` checks `LC_ALL`, `LC_MESSAGES`, then `LANG`; unsupported or
missing locales fall back to English. This does not force model output
language.
- `cost_currency` (`usd`, `cny`; default `usd`): currency used by the footer,
context panel, `/cost`, `/tokens`, and long-turn notification summaries. The
aliases `rmb` and `yuan` normalize to `cny`.
- `default_mode` (agent, plan, yolo; legacy `normal` is accepted and normalized to `agent`)
- `max_history` (number of submitted input history entries; cleared drafts are
also kept locally for composer history search)
@@ -389,7 +392,7 @@ If you are upgrading from older releases:
notification.
- `[notifications].include_summary` (bool, optional): defaults to
`false`. When `true`, the notification body includes the elapsed
duration and the turn's USD cost.
duration and the turn's cost in the configured display currency.
- `tui.alternate_screen` (string, optional): `auto`, `always`, or `never`. `auto` disables the alternate screen in Zellij; `--no-alt-screen` forces inline mode. Set `never` or run with `--no-alt-screen` when you want real terminal scrollback.
- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and `false` on Windows when the alternate screen is active): enable internal mouse scrolling, transcript selection, and right-click context actions. TUI-owned drag selection copies only user/assistant transcript text. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in on Windows.
- `tui.terminal_probe_timeout_ms` (int, optional, default `500`): startup terminal-mode probe timeout in milliseconds. Values are clamped to `100..=5000`; timeout emits a warning and aborts startup instead of hanging indefinitely.