Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
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
36 changes: 36 additions & 0 deletions crates/tui/src/tui/footer_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,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 +403,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 +493,36 @@ 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 label = if total >= 1000.0 {
format!("bal {currency}{total:.0}")
} else if total >= 10.0 {
format!("bal {currency}{total:.1}")
} else {
format!("bal {currency}{total:.2}")
Comment thread
MoriTang marked this conversation as resolved.
Outdated
};
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
45 changes: 45 additions & 0 deletions crates/tui/src/tui/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,32 @@ fn active_rlm_task_entries(app: &App) -> Vec<TaskPanelEntry> {
.collect()
}

/// Fetch the DeepSeek account balance from the balance API.
///
/// Returns `None` on any error (network, auth, parse) — callers should treat
/// a `None` return as "balance unknown" and keep the previous value.
async fn fetch_deepseek_balance(api_key: &str) -> Option<crate::pricing::BalanceInfo> {
let url = "https://api.deepseek.com/user/balance";
let client = ::reqwest::Client::new();
Comment thread
MoriTang marked this conversation as resolved.
Outdated
let response = client
.get(url)
.header("Authorization", format!("Bearer {api_key}"))
.send()
.await
.ok()?;
if !response.status().is_success() {
tracing::debug!(
"balance API returned {}: {}",
response.status().as_u16(),
response.text().await.unwrap_or_default()
);
return None;
}
let body: crate::pricing::BalanceResponse = response.json().await.ok()?;
// Return the first balance entry (typically the user's primary currency).
body.balance_infos.into_iter().next()
}

#[allow(clippy::too_many_lines)]
async fn run_event_loop(
terminal: &mut AppTerminal,
Expand Down Expand Up @@ -1401,6 +1427,24 @@ async fn run_event_loop(
}
persistence_actor::persist(PersistRequest::ClearCheckpoint);

// Refresh DeepSeek account balance after each completed
// turn so the footer balance chip stays current without
// adding latency to any request path.
if app.api_provider == ApiProvider::Deepseek
|| app.api_provider == ApiProvider::DeepseekCN
{
let cell = app.balance_cell.clone();
let api_key = config.deepseek_api_key().unwrap_or_default();
if !api_key.is_empty() {
tokio::spawn(async move {
let info = fetch_deepseek_balance(&api_key).await;
if let Ok(mut guard) = cell.lock() {
*guard = info;
}
});
Comment thread
MoriTang marked this conversation as resolved.
}
}

if app.mode == AppMode::Plan
&& app.plan_tool_used_in_turn
&& !app.plan_prompt_pending
Expand Down Expand Up @@ -4562,6 +4606,7 @@ async fn apply_command_result(
app.view_stack
.push(crate::tui::views::status_picker::StatusPickerView::new(
&app.status_items,
app.api_provider,
));
}
}
Expand Down
Loading