Skip to content
Closed
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
47 changes: 46 additions & 1 deletion crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,8 @@ pub enum StatusItem {
LastToolElapsed,
/// Remaining rate-limit budget (placeholder until wired).
RateLimit,
/// DeepSeek account balance, refreshed once per turn completion.
Balance,
}

impl StatusItem {
Expand All @@ -678,6 +680,9 @@ impl StatusItem {
StatusItem::Agents,
StatusItem::ReasoningReplay,
StatusItem::Cache,
// Balance is provider-specific (DeepSeek / DeepSeek CN only) and
// stays opt-in via `/statusline` — it should not crowd the default
// footer for users on other providers.
]
}

Expand All @@ -698,6 +703,7 @@ impl StatusItem {
StatusItem::GitBranch => "git_branch",
StatusItem::LastToolElapsed => "last_tool_elapsed",
StatusItem::RateLimit => "rate_limit",
StatusItem::Balance => "balance",
}
}

Expand All @@ -718,6 +724,7 @@ impl StatusItem {
StatusItem::GitBranch => "Git branch",
StatusItem::LastToolElapsed => "Last tool elapsed",
StatusItem::RateLimit => "Rate-limit remaining",
StatusItem::Balance => "Account balance",
}
}

Expand All @@ -739,6 +746,7 @@ impl StatusItem {
StatusItem::GitBranch => "current workspace branch",
StatusItem::LastToolElapsed => "ms of the most recent tool call (placeholder)",
StatusItem::RateLimit => "remaining requests in the budget (placeholder)",
StatusItem::Balance => "topped-up + granted balance from DeepSeek",
}
}

Expand All @@ -749,6 +757,7 @@ impl StatusItem {
StatusItem::Mode,
StatusItem::Model,
StatusItem::Cost,
StatusItem::Balance,
StatusItem::Status,
StatusItem::Coherence,
StatusItem::Agents,
Expand All @@ -767,9 +776,26 @@ impl StatusItem {
pub fn is_left_cluster(self) -> bool {
matches!(
self,
StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status
StatusItem::Mode
| StatusItem::Model
| StatusItem::Cost
| StatusItem::Status
| StatusItem::Balance
)
}

/// Whether this item is relevant for `provider`. Provider-specific
/// items return `false` for unsupported providers so the picker doesn't
/// offer toggles that can never show useful data.
#[must_use]
pub fn is_available_for(self, provider: ApiProvider) -> bool {
match self {
StatusItem::Balance => {
matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
}
_ => true,
}
}
}

/// Resolved retry policy with defaults applied.
Expand Down Expand Up @@ -6073,4 +6099,23 @@ model = "deepseek-ai/deepseek-v4-pro"
let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();
assert_eq!(cap, deserialized);
}

#[test]
fn status_item_balance_available_only_for_deepseek_providers() {
// Balance item should only be offered for DeepSeek / DeepSeekCN.
assert!(StatusItem::Balance.is_available_for(ApiProvider::Deepseek));
assert!(StatusItem::Balance.is_available_for(ApiProvider::DeepseekCN));
// Sanity: all other known providers should hide the Balance toggle.
assert!(!StatusItem::Balance.is_available_for(ApiProvider::Openrouter));
assert!(!StatusItem::Balance.is_available_for(ApiProvider::Novita));
assert!(!StatusItem::Balance.is_available_for(ApiProvider::NvidiaNim));
assert!(!StatusItem::Balance.is_available_for(ApiProvider::Fireworks));
assert!(!StatusItem::Balance.is_available_for(ApiProvider::Sglang));
assert!(!StatusItem::Balance.is_available_for(ApiProvider::Vllm));
assert!(!StatusItem::Balance.is_available_for(ApiProvider::Ollama));
assert!(!StatusItem::Balance.is_available_for(ApiProvider::Openai));
assert!(!StatusItem::Balance.is_available_for(ApiProvider::Atlascloud));
// Other StatusItem variants should be available everywhere.
assert!(StatusItem::Mode.is_available_for(ApiProvider::Ollama));
}
}
3 changes: 3 additions & 0 deletions crates/tui/src/config_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ pub enum StatusItemValue {
GitBranch,
LastToolElapsed,
RateLimit,
Balance,
}

pub fn parse_mode(arg: Option<&str>) -> Result<ConfigUiMode, String> {
Expand Down Expand Up @@ -996,6 +997,7 @@ impl From<StatusItem> for StatusItemValue {
StatusItem::GitBranch => Self::GitBranch,
StatusItem::LastToolElapsed => Self::LastToolElapsed,
StatusItem::RateLimit => Self::RateLimit,
StatusItem::Balance => Self::Balance,
}
}
}
Expand All @@ -1016,6 +1018,7 @@ impl From<StatusItemValue> for StatusItem {
StatusItemValue::GitBranch => Self::GitBranch,
StatusItemValue::LastToolElapsed => Self::LastToolElapsed,
StatusItemValue::RateLimit => Self::RateLimit,
StatusItemValue::Balance => Self::Balance,
}
}
}
Expand Down
8 changes: 8 additions & 0 deletions crates/tui/src/localization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ pub enum MessageId {
FooterAgentsPlural,
FooterPressCtrlCAgain,
FooterWorking,
FooterBalancePrefix,
HelpSectionActions,
HelpSectionClipboard,
HelpSectionEditing,
Expand Down Expand Up @@ -569,6 +570,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::FooterAgentsPlural,
MessageId::FooterPressCtrlCAgain,
MessageId::FooterWorking,
MessageId::FooterBalancePrefix,
MessageId::HelpSectionActions,
MessageId::HelpSectionClipboard,
MessageId::HelpSectionEditing,
Expand Down Expand Up @@ -1037,6 +1039,7 @@ fn english(id: MessageId) -> &'static str {
MessageId::FooterAgentsPlural => "{count} agents",
MessageId::FooterPressCtrlCAgain => "Press Ctrl+C again to quit",
MessageId::FooterWorking => "working",
MessageId::FooterBalancePrefix => "bal",
MessageId::HelpSectionActions => "Actions",
MessageId::HelpSectionClipboard => "Clipboard",
MessageId::HelpSectionEditing => "Input editing",
Expand Down Expand Up @@ -1230,6 +1233,7 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> {
MessageId::TranslationInProgress => "正在翻譯助理輸出...",
MessageId::TranslationComplete => "翻譯完成",
MessageId::TranslationFailed => "翻譯失敗",
MessageId::FooterBalancePrefix => "餘額",
other => chinese_simplified(other)?,
})
}
Expand Down Expand Up @@ -1419,6 +1423,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
MessageId::FooterAgentsPlural => "{count} エージェント",
MessageId::FooterPressCtrlCAgain => "もう一度 Ctrl+C で終了",
MessageId::FooterWorking => "処理中",
MessageId::FooterBalancePrefix => "残高",
MessageId::HelpSectionActions => "操作",
MessageId::HelpSectionClipboard => "クリップボード",
MessageId::HelpSectionEditing => "入力編集",
Expand Down Expand Up @@ -1749,6 +1754,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
MessageId::FooterAgentsPlural => "{count} 个子代理",
MessageId::FooterPressCtrlCAgain => "再次按 Ctrl+C 退出",
MessageId::FooterWorking => "工作中",
MessageId::FooterBalancePrefix => "余额",
MessageId::HelpSectionActions => "操作",
MessageId::HelpSectionClipboard => "剪贴板",
MessageId::HelpSectionEditing => "输入编辑",
Expand Down Expand Up @@ -2095,6 +2101,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
MessageId::FooterAgentsPlural => "{count} sub-agentes",
MessageId::FooterPressCtrlCAgain => "Pressione Ctrl+C novamente para sair",
MessageId::FooterWorking => "trabalhando",
MessageId::FooterBalancePrefix => "saldo",
MessageId::HelpSectionActions => "Ações",
MessageId::HelpSectionClipboard => "Área de transferência",
MessageId::HelpSectionEditing => "Edição de entrada",
Expand Down Expand Up @@ -2485,6 +2492,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> {
MessageId::FooterAgentsPlural => "{count} sub-agentes",
MessageId::FooterPressCtrlCAgain => "Presiona Ctrl+C de nuevo para salir",
MessageId::FooterWorking => "trabajando",
MessageId::FooterBalancePrefix => "saldo",
MessageId::HelpSectionActions => "Acciones",
MessageId::HelpSectionClipboard => "Portapapeles",
MessageId::HelpSectionEditing => "Edición de entrada",
Expand Down
106 changes: 106 additions & 0 deletions crates/tui/src/pricing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,39 @@ impl CostEstimate {
}
}

// === DeepSeek Account Balance ===

/// Response from `GET https://api.deepseek.com/user/balance`.
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct BalanceResponse {
#[allow(dead_code)]
pub is_available: bool,
pub balance_infos: Vec<BalanceInfo>,
}

/// Per-currency balance entry from the balance API.
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct BalanceInfo {
pub currency: String,
#[serde(default)]
pub total_balance: String,
#[serde(default)]
#[allow(dead_code)]
pub topped_up_balance: String,
#[serde(default)]
#[allow(dead_code)]
pub granted_balance: String,
}

impl BalanceInfo {
/// Parse the `total_balance` field as an f64. Returns `None` on parse
/// failure or empty string.
#[must_use]
pub fn total_balance_f64(&self) -> Option<f64> {
self.total_balance.parse::<f64>().ok()
}
}

/// Per-million-token pricing for a model.
#[derive(Debug, Clone, Copy)]
struct CurrencyPricing {
Expand Down Expand Up @@ -338,4 +371,77 @@ mod tests {
"¥0.1234"
);
}

// ── BalanceResponse / BalanceInfo ──────────────────────────────

#[test]
fn balance_response_deserializes_from_json() {
let json = r#"{
"is_available": true,
"balance_infos": [
{
"currency": "CNY",
"total_balance": "123.45",
"topped_up_balance": "100.00",
"granted_balance": "23.45"
}
]
}"#;
let resp: BalanceResponse = serde_json::from_str(json).expect("valid JSON");
assert!(resp.is_available);
assert_eq!(resp.balance_infos.len(), 1);
let info = &resp.balance_infos[0];
assert_eq!(info.currency, "CNY");
assert_eq!(info.total_balance, "123.45");
assert_eq!(info.topped_up_balance, "100.00");
assert_eq!(info.granted_balance, "23.45");
}

#[test]
fn balance_response_defaults_empty_balance_infos_when_unavailable() {
let json = r#"{"is_available": false, "balance_infos": []}"#;
let resp: BalanceResponse = serde_json::from_str(json).expect("valid JSON");
assert!(!resp.is_available);
assert!(resp.balance_infos.is_empty());
}

#[test]
fn balance_response_empty_list_is_valid() {
let json = r#"{"is_available": true, "balance_infos": []}"#;
let resp: BalanceResponse = serde_json::from_str(json).expect("valid JSON");
assert!(resp.is_available);
assert!(resp.balance_infos.is_empty());
}

// ── BalanceInfo::total_balance_f64 ─────────────────────────────

#[test]
fn total_balance_f64_parses_decimal() {
let info = BalanceInfo {
currency: "CNY".into(),
total_balance: "123.45".into(),
..Default::default()
};
assert_eq!(info.total_balance_f64(), Some(123.45));
}

#[test]
fn total_balance_f64_returns_none_on_empty() {
let info = BalanceInfo {
currency: "USD".into(),
total_balance: String::new(),
..Default::default()
};
assert_eq!(info.total_balance_f64(), None);
}

#[test]
fn total_balance_f64_returns_none_on_invalid() {
let info = BalanceInfo {
currency: "USD".into(),
total_balance: "not-a-number".into(),
..Default::default()
};
assert_eq!(info.total_balance_f64(), None);
}
}
4 changes: 4 additions & 0 deletions crates/tui/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,9 @@ pub struct App {
/// Incremented on `TurnComplete` from the elapsed time of the
/// just-finished turn. Resets per launch.
pub cumulative_turn_duration: std::time::Duration,
/// DeepSeek account balance, refreshed once per turn completion.
/// Shared cell updated by background fetch tasks; read lock in the UI thread.
pub balance_cell: std::sync::Arc<std::sync::Mutex<Option<crate::pricing::BalanceInfo>>>,
/// Current runtime turn id (if known).
pub runtime_turn_id: Option<String>,
/// Current runtime turn status (if known).
Expand Down Expand Up @@ -1608,6 +1611,7 @@ impl App {
submit_pending_steers_after_interrupt: false,
turn_started_at: None,
cumulative_turn_duration: std::time::Duration::ZERO,
balance_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
runtime_turn_id: None,
runtime_turn_status: None,
dispatch_started_at: None,
Expand Down
38 changes: 38 additions & 0 deletions crates/tui/src/tui/footer_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::time::Instant;
use unicode_width::UnicodeWidthStr;

use crate::core::coherence::CoherenceState;
use crate::localization::MessageId;
use crate::palette;
use crate::tools::subagent::SubAgentStatus;
use crate::tui::app::App;
Expand Down Expand Up @@ -384,6 +385,11 @@ pub(crate) fn render_footer_from(
} else {
Vec::new()
};
let balance = if has(S::Balance) {
footer_balance_spans(app)
} else {
Vec::new()
};

// Build the props; `Mode` and `Model` toggles modulate downstream by
// blanking the rendered text rather than restructuring the widget — the
Expand All @@ -398,6 +404,7 @@ pub(crate) fn render_footer_from(
reasoning_replay,
cache,
cost,
balance,
);
if !has(S::Mode) {
props.mode_label = "";
Expand Down Expand Up @@ -487,6 +494,37 @@ pub(crate) fn footer_cost_spans(app: &App) -> Vec<Span<'static>> {
)]
}

pub(crate) fn footer_balance_spans(app: &App) -> Vec<Span<'static>> {
let balance = match app.balance_cell.lock() {
Ok(guard) => guard,
Err(_) => return Vec::new(),
};
let info = match balance.as_ref() {
Some(info) => info,
None => return Vec::new(),
};
let total = match info.total_balance_f64() {
Some(total) if total > 0.0 => total,
_ => return Vec::new(),
};
let currency = match info.currency.as_str() {
"CNY" | "cny" => "¥",
_ => "$",
Comment thread
MoriTang marked this conversation as resolved.
};
let prefix = app.tr(MessageId::FooterBalancePrefix);
let label = if total >= 1000.0 {
format!("{prefix} {currency}{total:.0}")
} else if total >= 10.0 {
format!("{prefix} {currency}{total:.1}")
} else {
format!("{prefix} {currency}{total:.2}")
};
vec![Span::styled(
label,
Style::default().fg(palette::TEXT_MUTED),
)]
}

pub(crate) fn should_show_footer_cost(displayed_cost: f64) -> bool {
displayed_cost.is_finite() && displayed_cost > 0.0
}
Expand Down
Loading