feat(cost): support yuan display (#806)
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)));
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user