From a0836f0a9f434e752fe6599780cb3de46fb64b61 Mon Sep 17 00:00:00 2001 From: ts25504 Date: Sat, 16 May 2026 10:38:07 +0800 Subject: [PATCH 01/13] feat: add account balance status bar item --- crates/tui/src/config.rs | 22 ++++- crates/tui/src/config_ui.rs | 12 +++ crates/tui/src/pricing.rs | 33 +++++++ crates/tui/src/tui/app.rs | 4 + crates/tui/src/tui/footer_ui.rs | 36 ++++++++ crates/tui/src/tui/ui.rs | 44 ++++++++++ crates/tui/src/tui/views/status_picker.rs | 55 +++++++++++- crates/tui/src/tui/widgets/footer.rs | 102 ++++++++++++++++------ 8 files changed, 279 insertions(+), 29 deletions(-) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 282d2023..41adef59 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -779,6 +779,8 @@ pub enum StatusItem { RateLimit, /// Session token usage: input / cache-hit / output. Tokens, + /// DeepSeek account balance, refreshed once per turn completion. + Balance, } impl StatusItem { @@ -798,6 +800,7 @@ impl StatusItem { StatusItem::ReasoningReplay, StatusItem::Cache, StatusItem::Tokens, + StatusItem::Balance, ] } @@ -818,7 +821,11 @@ impl StatusItem { StatusItem::GitBranch => "git_branch", StatusItem::LastToolElapsed => "last_tool_elapsed", StatusItem::RateLimit => "rate_limit", +<<<<<<< HEAD StatusItem::Tokens => "tokens", +======= + StatusItem::Balance => "balance", +>>>>>>> 4bc823e6 (feat: add account balance status bar item) } } @@ -839,7 +846,11 @@ impl StatusItem { StatusItem::GitBranch => "Git branch", StatusItem::LastToolElapsed => "Last tool elapsed", StatusItem::RateLimit => "Rate-limit remaining", +<<<<<<< HEAD StatusItem::Tokens => "Session tokens", +======= + StatusItem::Balance => "Account balance", +>>>>>>> 4bc823e6 (feat: add account balance status bar item) } } @@ -861,7 +872,11 @@ 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)", +<<<<<<< HEAD StatusItem::Tokens => "input / cache-hit / output token totals", +======= + StatusItem::Balance => "topped-up + granted balance from DeepSeek", +>>>>>>> 4bc823e6 (feat: add account balance status bar item) } } @@ -883,6 +898,7 @@ impl StatusItem { StatusItem::LastToolElapsed, StatusItem::RateLimit, StatusItem::Tokens, + StatusItem::Balance, ] } @@ -891,7 +907,11 @@ 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 ) } } diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index 9cf8ecd2..b3da0253 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -278,7 +278,11 @@ pub enum StatusItemValue { GitBranch, LastToolElapsed, RateLimit, +<<<<<<< HEAD Tokens, +======= + Balance, +>>>>>>> 4bc823e6 (feat: add account balance status bar item) } pub fn parse_mode(arg: Option<&str>) -> Result { @@ -1001,7 +1005,11 @@ impl From for StatusItemValue { StatusItem::GitBranch => Self::GitBranch, StatusItem::LastToolElapsed => Self::LastToolElapsed, StatusItem::RateLimit => Self::RateLimit, +<<<<<<< HEAD StatusItem::Tokens => Self::Tokens, +======= + StatusItem::Balance => Self::Balance, +>>>>>>> 4bc823e6 (feat: add account balance status bar item) } } } @@ -1022,7 +1030,11 @@ impl From for StatusItem { StatusItemValue::GitBranch => Self::GitBranch, StatusItemValue::LastToolElapsed => Self::LastToolElapsed, StatusItemValue::RateLimit => Self::RateLimit, +<<<<<<< HEAD StatusItemValue::Tokens => Self::Tokens, +======= + StatusItemValue::Balance => Self::Balance, +>>>>>>> 4bc823e6 (feat: add account balance status bar item) } } } diff --git a/crates/tui/src/pricing.rs b/crates/tui/src/pricing.rs index eb78ed8b..b04d320a 100644 --- a/crates/tui/src/pricing.rs +++ b/crates/tui/src/pricing.rs @@ -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, +} + +/// 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 { + self.total_balance.parse::().ok() + } +} + /// Per-million-token pricing for a model. #[derive(Debug, Clone, Copy)] struct CurrencyPricing { diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 3c49df0e..bfe5ec58 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1402,6 +1402,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>>, /// Current runtime turn id (if known). pub runtime_turn_id: Option, /// Current runtime turn status (if known). @@ -1980,6 +1983,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, diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 1fc58d91..49c3a33d 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -473,6 +473,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 @@ -487,6 +492,7 @@ pub(crate) fn render_footer_from( reasoning_replay, cache, cost, + balance, ); if !has(S::Mode) { props.mode_label = ""; @@ -595,6 +601,36 @@ pub(crate) fn footer_cost_spans(app: &App) -> Vec> { spans } +pub(crate) fn footer_balance_spans(app: &App) -> Vec> { + 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" => "¥", + _ => "$", + }; + 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}") + }; + 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 } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index fb89de61..b8451e93 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -858,6 +858,32 @@ fn active_rlm_task_entries(app: &App) -> Vec { .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 { + let url = "https://api.deepseek.com/user/balance"; + let client = ::reqwest::Client::new(); + 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, @@ -1610,6 +1636,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; + } + }); + } + } + if app.mode == AppMode::Plan && app.plan_tool_used_in_turn && !app.plan_prompt_pending diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index 17b6173a..68280fda 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -9,6 +9,8 @@ //! The picker enumerates [`StatusItem::all`] so adding a new variant in //! `crates/tui/src/config.rs` automatically surfaces a new row here. +use std::cell::Cell; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ buffer::Buffer, @@ -35,6 +37,12 @@ pub struct StatusPickerView { cursor: usize, /// Snapshot of `app.status_items` at open time so Esc reverts cleanly. original: Vec, + /// First visible row index. Auto-adjusted by `render` and `move_*` so + /// every item stays reachable regardless of popup height. Uses `Cell` + /// because `render` takes `&self`. + scroll_offset: Cell, + /// Number of item rows that fit in the popup, updated on each `render`. + visible_rows: Cell, } impl StatusPickerView { @@ -47,6 +55,8 @@ impl StatusPickerView { selected, cursor: 0, original: active.to_vec(), + scroll_offset: Cell::new(0), + visible_rows: Cell::new(0), } } @@ -65,6 +75,7 @@ impl StatusPickerView { if self.cursor > 0 { self.cursor -= 1; } + self.scroll_to_cursor(); } fn move_down(&mut self) { @@ -72,6 +83,22 @@ impl StatusPickerView { if self.cursor < max { self.cursor += 1; } + self.scroll_to_cursor(); + } + + /// Keep the cursor row inside the visible window. + fn scroll_to_cursor(&self) { + let visible = self.visible_rows.get(); + if visible == 0 { + return; + } + let offset = self.scroll_offset.get(); + if self.cursor < offset { + self.scroll_offset.set(self.cursor); + } else if self.cursor >= offset + visible { + self.scroll_offset + .set(self.cursor.saturating_sub(visible.saturating_sub(1))); + } } fn toggle_current(&mut self) { @@ -155,8 +182,11 @@ impl ModalView for StatusPickerView { fn render(&self, area: Rect, buf: &mut Buffer) { let popup_width = 64.min(area.width.saturating_sub(4)).max(40); // Two header lines + one row per StatusItem + one footer hint line. + // When the full list is taller than the screen, cap the popup so it + // stays on-screen and let the scroll offset handle overflow. let needed_height = (self.rows.len() as u16).saturating_add(4); - let popup_height = needed_height.min(area.height.saturating_sub(4)).max(8); + let max_fit = area.height.saturating_sub(4).max(8); + let popup_height = needed_height.min(max_fit); let popup_area = Rect { x: area.x + (area.width.saturating_sub(popup_width)) / 2, @@ -194,17 +224,38 @@ impl ModalView for StatusPickerView { let inner = block.inner(popup_area); block.render(popup_area, buf); - let mut lines: Vec = Vec::with_capacity(self.rows.len() + 2); + // Two header lines ("Pick the chips…" + blank), rest is item rows. + let visible = (inner.height as usize).saturating_sub(2).max(1); + self.visible_rows.set(visible); + + // Auto-scroll so the cursor stays inside the visible window, + // then clamp to a valid range. + self.scroll_to_cursor(); + let offset = self + .scroll_offset + .get() + .min(self.rows.len().saturating_sub(visible)); + + let mut lines: Vec = Vec::with_capacity(visible + 2); lines.push(Line::from(Span::styled( "Pick the chips you want in the footer:", Style::default().fg(palette::TEXT_MUTED), ))); lines.push(Line::from("")); +<<<<<<< HEAD for (idx, item) in self.rows.iter().enumerate() { let checked = *self.selected.get(idx).unwrap_or(&false); let is_cursor = idx == self.cursor; let mark = if checked { "[✓]" } else { "[ ]" }; +======= + let end = (offset + visible).min(self.rows.len()); + for (idx, item) in self.rows[offset..end].iter().enumerate() { + let real_idx = offset + idx; + let checked = *self.selected.get(real_idx).unwrap_or(&false); + let is_cursor = real_idx == self.cursor; + let mark = if checked { "[x]" } else { "[ ]" }; +>>>>>>> 4bc823e6 (feat: add account balance status bar item) let row_style = if is_cursor { Style::default() diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index 27f13aa7..d402e1df 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -73,6 +73,9 @@ pub struct FooterProps { /// Rendered in the left cluster (after the model name) — cost is steady /// info, not a transient signal, so it lives with mode and model. pub cost: Vec>, + /// Account balance chip spans (empty when un fetched or zero). Rendered + /// in the left cluster right after cost. + pub balance: Vec>, /// Optional toast that, when present, replaces the left status line. pub toast: Option, /// When `Some(frame_idx)`, the gap between the left status line and the @@ -259,6 +262,7 @@ impl FooterProps { reasoning_replay: Vec>, cache: Vec>, cost: Vec>, + balance: Vec>, ) -> Self { let (mode_label, mode_color) = mode_style(app); // MCP chip (#502) — passive, derived from the user's existing @@ -293,6 +297,7 @@ impl FooterProps { mcp, worked, cost, + balance, toast, working_strip_frame: None, retry: crate::retry_status::snapshot(), @@ -371,15 +376,12 @@ impl FooterWidget { /// /// Priority order (highest to lowest — last to drop): /// 1. Mode label (always visible at any width; truncated only as a last resort) - /// 2. Model name (always visible; then truncated mid-word once status & cost are gone) - /// 3. Cost chip — drops second after status (steady-info still wants to be visible) - /// 4. Status label (e.g. "working", "draft") — drops first when space is tight + /// 2. Model name (always visible; then truncated mid-word once status, balance, & cost are gone) + /// 3. Cost chip — drops third (steady cost is more important than balance) + /// 4. Balance chip — drops second (after status, before cost) + /// 5. Status label (e.g. "working", "draft") — drops first when space is tight /// - /// At every width ≥40 cols the line never wraps mid-hint: the widget - /// chooses one of (`mode · model · cost · status`, `mode · model · cost`, - /// `mode · model`, `mode`) and renders that single line within - /// `max_width`. Cost lives between model and status so the eye finds - /// "what's this run going to cost me" without scanning past the wave. + /// At every width ≥40 cols the line never wraps mid-hint. fn status_line_spans(&self, max_width: usize) -> Vec> { if max_width == 0 { return Vec::new(); @@ -392,29 +394,61 @@ impl FooterWidget { let status_label = self.props.state_label.as_str(); let cost_text = spans_text(&self.props.cost); let show_cost = !cost_text.is_empty(); + let balance_text = spans_text(&self.props.balance); + let show_balance = !balance_text.is_empty(); let mode_w = mode_label.width(); let sep_w = sep.width(); let model_w = UnicodeWidthStr::width(model); - let status_w = status_label.width(); - let cost_w = cost_text.width(); + let status_w = if show_status { status_label.width() } else { 0 }; + let cost_w = if show_cost { cost_text.width() } else { 0 }; + let balance_w = if show_balance { + balance_text.width() + } else { + 0 + }; - // Tier 1: mode · model · cost · status — everything fits. + let extra_sep = |w: usize| if w > 0 { sep_w } else { 0 }; + + // Tier 1: mode · model · cost · balance · status let full_w = mode_w + sep_w + model_w - + if show_cost { sep_w + cost_w } else { 0 } - + if show_status { sep_w + status_w } else { 0 }; - if (show_cost || show_status) && full_w <= max_width { + + extra_sep(cost_w) + + cost_w + + extra_sep(balance_w) + + balance_w + + extra_sep(status_w) + + status_w; + if (show_cost || show_balance || show_status) && full_w <= max_width { return self.build_status_line_spans( mode_label, model.to_string(), show_cost.then(|| cost_text.clone()), + show_balance.then(|| balance_text.clone()), show_status.then_some(status_label), ); } - // Tier 2: mode · model · cost — drop status first. + // Tier 2: mode · model · cost · balance — drop status. + let with_balance_w = mode_w + + sep_w + + model_w + + extra_sep(cost_w) + + cost_w + + extra_sep(balance_w) + + balance_w; + if (show_cost || show_balance) && with_balance_w <= max_width { + return self.build_status_line_spans( + mode_label, + model.to_string(), + show_cost.then(|| cost_text.clone()), + show_balance.then(|| balance_text.clone()), + None, + ); + } + + // Tier 3: mode · model · cost — drop balance. if show_cost { let with_cost_w = mode_w + sep_w + model_w + sep_w + cost_w; if with_cost_w <= max_width { @@ -423,17 +457,18 @@ impl FooterWidget { model.to_string(), Some(cost_text.clone()), None, + None, ); } } - // Tier 3: mode · model — drop cost too. + // Tier 4: mode · model — drop cost too. let mode_model_w = mode_w + sep_w + model_w; if mode_model_w <= max_width { - return self.build_status_line_spans(mode_label, model.to_string(), None, None); + return self.build_status_line_spans(mode_label, model.to_string(), None, None, None); } - // Tier 4: mode · — keep both labels visible by + // Tier 5: mode · — keep both labels visible by // ellipsizing the model name. Only do this when there is enough room // for at least the ellipsis ("..."). Below that we drop to mode-only. let prefix_w = mode_w + sep_w; @@ -442,13 +477,12 @@ impl FooterWidget { if model_budget >= 4 { let truncated = truncate_to_width(model, model_budget); if !truncated.is_empty() { - return self.build_status_line_spans(mode_label, truncated, None, None); + return self.build_status_line_spans(mode_label, truncated, None, None, None); } } } - // Tier 5: mode-only. If even the mode label cannot fit, truncate it - // so the footer never wraps to a second row. + // Tier 6: mode-only. if mode_w <= max_width { return vec![Span::styled( mode_label.to_string(), @@ -466,21 +500,17 @@ impl FooterWidget { mode_label: &'static str, model_label: String, cost: Option, + balance: Option, status: Option<&str>, ) -> Vec> { let sep = " \u{00B7} "; let mut spans: Vec> = Vec::new(); - // Skip the mode chip when the user has toggled it off via - // `/statusline`. The widget no longer assumes mode is always - // present so an opt-out user doesn't see a stray separator. if !mode_label.is_empty() { spans.push(Span::styled( mode_label.to_string(), Style::default().fg(self.props.mode_color), )); } - // Same treatment for the model label — gating both keeps the bar - // visually tidy when only auxiliary chips remain. if !model_label.is_empty() { if !spans.is_empty() { spans.push(Span::styled( @@ -505,6 +535,18 @@ impl FooterWidget { Style::default().fg(self.props.text_muted_color), )); } + if let Some(balance_text) = balance { + if !spans.is_empty() { + spans.push(Span::styled( + sep.to_string(), + Style::default().fg(self.props.text_dim_color), + )); + } + spans.push(Span::styled( + balance_text, + Style::default().fg(self.props.text_muted_color), + )); + } if let Some(status_label) = status { if !spans.is_empty() { spans.push(Span::styled( @@ -704,6 +746,7 @@ mod tests { Vec::>::new(), Vec::>::new(), Vec::>::new(), + Vec::>::new(), ); // `from_app` reads the process-wide retry-status surface; pin // `Idle` so footer tests don't pick up state set by retry-banner @@ -816,6 +859,7 @@ mod tests { Vec::>::new(), Vec::>::new(), Vec::>::new(), + Vec::>::new(), ); assert!(props.state_label.starts_with("thinking")); @@ -891,6 +935,7 @@ mod tests { Vec::>::new(), Vec::>::new(), Vec::>::new(), + Vec::>::new(), ); let widget = FooterWidget::new(props); let area = ratatui::layout::Rect::new(0, 0, 60, 1); @@ -1153,6 +1198,7 @@ mod tests { Vec::>::new(), Vec::>::new(), Vec::>::new(), + Vec::>::new(), ) } @@ -1249,6 +1295,7 @@ mod tests { Vec::>::new(), Vec::>::new(), vec![Span::styled(cost.to_string(), Style::default())], + Vec::>::new(), ) } @@ -1269,6 +1316,7 @@ mod tests { Vec::>::new(), long_cache, Vec::>::new(), + Vec::>::new(), ); let line = render_at_width(props, 40); @@ -1301,6 +1349,7 @@ mod tests { Vec::>::new(), cache, Vec::>::new(), + Vec::>::new(), ); let line = render_at_width(props, 80); @@ -1365,6 +1414,7 @@ mod tests { Vec::>::new(), Vec::>::new(), Vec::>::new(), + Vec::>::new(), ); let widget = FooterWidget::new(props); From e774c940aa76ecf7d6ad1cb758d5e0289f97b921 Mon Sep 17 00:00:00 2001 From: ts25504 Date: Sat, 16 May 2026 10:58:56 +0800 Subject: [PATCH 02/13] test: add balance status item unit and integration tests --- crates/tui/src/pricing.rs | 73 +++++++++++++++++++++++ crates/tui/src/tui/ui/tests.rs | 104 ++++++++++++++++++++++++++++++++- 2 files changed, 175 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/pricing.rs b/crates/tui/src/pricing.rs index b04d320a..2cb77451 100644 --- a/crates/tui/src/pricing.rs +++ b/crates/tui/src/pricing.rs @@ -390,4 +390,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); + } } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 4f0baa5b..f55ca152 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -9,8 +9,8 @@ use crate::tui::file_mention::{ try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries, }; use crate::tui::footer_ui::{ - active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans, - footer_state_label, footer_status_line_spans, format_context_budget, + active_tool_status_label, footer_auxiliary_spans, footer_balance_spans, footer_cache_spans, + footer_coherence_spans, footer_state_label, footer_status_line_spans, format_context_budget, format_token_count_compact, friendly_subagent_progress, render_footer_from, }; use crate::tui::history::{ @@ -5782,6 +5782,106 @@ fn render_footer_from_git_branch_item_renders_workspace_branch() { assert_eq!(spans_text(&props.cache), "feature/statusline"); } +// ── Balance footer chip tests ───────────────────────────────────── + +#[test] +fn footer_balance_spans_empty_when_cell_is_none() { + let app = create_test_app(); + let spans = footer_balance_spans(&app); + assert!(spans.is_empty()); +} + +#[test] +fn footer_balance_spans_empty_when_balance_is_zero() { + let app = create_test_app(); + let info = crate::pricing::BalanceInfo { + currency: "USD".into(), + total_balance: "0".into(), + ..Default::default() + }; + *app.balance_cell.lock().unwrap() = Some(info); + let spans = footer_balance_spans(&app); + assert!(spans.is_empty()); +} + +#[test] +fn footer_balance_spans_formats_cny() { + let app = create_test_app(); + let info = crate::pricing::BalanceInfo { + currency: "CNY".into(), + total_balance: "123.45".into(), + ..Default::default() + }; + *app.balance_cell.lock().unwrap() = Some(info); + let spans = footer_balance_spans(&app); + assert_eq!(spans_text(&spans), "bal ¥123.5"); +} + +#[test] +fn footer_balance_spans_formats_usd() { + let app = create_test_app(); + let info = crate::pricing::BalanceInfo { + currency: "USD".into(), + total_balance: "0.50".into(), + ..Default::default() + }; + *app.balance_cell.lock().unwrap() = Some(info); + let spans = footer_balance_spans(&app); + assert_eq!(spans_text(&spans), "bal $0.50"); +} + +#[test] +fn footer_balance_spans_rounds_large_amount() { + let app = create_test_app(); + let info = crate::pricing::BalanceInfo { + currency: "USD".into(), + total_balance: "1234.56".into(), + ..Default::default() + }; + *app.balance_cell.lock().unwrap() = Some(info); + let spans = footer_balance_spans(&app); + assert_eq!(spans_text(&spans), "bal $1235"); +} + +#[test] +fn footer_balance_spans_treats_unknown_currency_as_usd() { + let app = create_test_app(); + let info = crate::pricing::BalanceInfo { + currency: "EUR".into(), + total_balance: "10.00".into(), + ..Default::default() + }; + *app.balance_cell.lock().unwrap() = Some(info); + let spans = footer_balance_spans(&app); + assert_eq!(spans_text(&spans), "bal $10.0"); +} + +#[test] +fn render_footer_from_with_balance_item_shows_balance() { + let app = create_test_app(); + let info = crate::pricing::BalanceInfo { + currency: "USD".into(), + total_balance: "42.50".into(), + ..Default::default() + }; + *app.balance_cell.lock().unwrap() = Some(info); + let props = render_footer_from(&app, &[crate::config::StatusItem::Balance], None); + assert_eq!(spans_text(&props.balance), "bal $42.5"); +} + +#[test] +fn render_footer_from_without_balance_item_hides_balance() { + let app = create_test_app(); + let info = crate::pricing::BalanceInfo { + currency: "USD".into(), + total_balance: "99.99".into(), + ..Default::default() + }; + *app.balance_cell.lock().unwrap() = Some(info); + let props = render_footer_from(&app, &[], None); + assert!(spans_text(&props.balance).is_empty()); +} + /// Regression for issue #244: visible session spend must not decrease. /// Sub-agent token usage events arrive out of order and may be reconciled /// later (cache adjustments, provisional → final swap). The displayed total From 6b70d27cc892d57da41e290ff02ae440621a13e7 Mon Sep 17 00:00:00 2001 From: ts25504 Date: Sat, 16 May 2026 11:12:03 +0800 Subject: [PATCH 03/13] refactor: gate balance status item behind DeepSeek provider --- crates/tui/src/config.rs | 42 +++++++++++++++++------ crates/tui/src/tui/ui.rs | 1 + crates/tui/src/tui/ui/tests.rs | 6 +++- crates/tui/src/tui/views/status_picker.rs | 32 ++++++++++++----- 4 files changed, 61 insertions(+), 20 deletions(-) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 41adef59..1da34b8a 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -800,7 +800,6 @@ impl StatusItem { StatusItem::ReasoningReplay, StatusItem::Cache, StatusItem::Tokens, - StatusItem::Balance, ] } @@ -821,11 +820,8 @@ impl StatusItem { StatusItem::GitBranch => "git_branch", StatusItem::LastToolElapsed => "last_tool_elapsed", StatusItem::RateLimit => "rate_limit", -<<<<<<< HEAD StatusItem::Tokens => "tokens", -======= StatusItem::Balance => "balance", ->>>>>>> 4bc823e6 (feat: add account balance status bar item) } } @@ -846,11 +842,8 @@ impl StatusItem { StatusItem::GitBranch => "Git branch", StatusItem::LastToolElapsed => "Last tool elapsed", StatusItem::RateLimit => "Rate-limit remaining", -<<<<<<< HEAD StatusItem::Tokens => "Session tokens", -======= StatusItem::Balance => "Account balance", ->>>>>>> 4bc823e6 (feat: add account balance status bar item) } } @@ -872,11 +865,8 @@ 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)", -<<<<<<< HEAD StatusItem::Tokens => "input / cache-hit / output token totals", -======= StatusItem::Balance => "topped-up + granted balance from DeepSeek", ->>>>>>> 4bc823e6 (feat: add account balance status bar item) } } @@ -914,6 +904,19 @@ impl StatusItem { | 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. @@ -7555,4 +7558,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)); + } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b8451e93..1954494b 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -4908,6 +4908,7 @@ async fn apply_command_result( app.view_stack .push(crate::tui::views::status_picker::StatusPickerView::new( &app.status_items, + app.api_provider, )); } } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index f55ca152..6ab8ae5d 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5683,13 +5683,17 @@ fn render_footer_from_with_default_items_renders_mode_and_model() { } #[test] -fn default_footer_keeps_prefix_stability_opt_in() { +fn default_footer_excludes_provider_specific_diagnostic_chips() { let items = crate::config::StatusItem::default_footer(); assert!( !items.contains(&crate::config::StatusItem::PrefixStability), "prefix stability is a diagnostic chip and should not crowd the default footer" ); + assert!( + !items.contains(&crate::config::StatusItem::Balance), + "balance is DeepSeek-only and should not crowd the default footer for non-DeepSeek users" + ); assert!( items.contains(&crate::config::StatusItem::Cache), "default footer should still include provider-reported cache hit rate" diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index 68280fda..adfbae58 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -20,7 +20,7 @@ use ratatui::{ widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, }; -use crate::config::StatusItem; +use crate::config::{ApiProvider, StatusItem}; use crate::palette; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; @@ -47,8 +47,12 @@ pub struct StatusPickerView { impl StatusPickerView { #[must_use] - pub fn new(active: &[StatusItem]) -> Self { - let rows: Vec = StatusItem::all().to_vec(); + pub fn new(active: &[StatusItem], provider: ApiProvider) -> Self { + let rows: Vec = StatusItem::all() + .iter() + .filter(|item| item.is_available_for(provider)) + .copied() + .collect(); let selected: Vec = rows.iter().map(|item| active.contains(item)).collect(); Self { rows, @@ -297,14 +301,14 @@ mod tests { #[test] fn opens_with_active_items_pre_selected() { let active = StatusItem::default_footer(); - let view = StatusPickerView::new(&active); + let view = StatusPickerView::new(&active, ApiProvider::Deepseek); assert_eq!(view.current_selection(), active); } #[test] fn space_toggles_current_row_and_emits_live_preview() { let active = StatusItem::default_footer(); - let mut view = StatusPickerView::new(&active); + let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek); // Cursor starts at row 0 = StatusItem::Mode (currently checked). let action = view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); match action { @@ -319,7 +323,7 @@ mod tests { #[test] fn enter_emits_final_save() { let active = StatusItem::default_footer(); - let mut view = StatusPickerView::new(&active); + let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek); let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match action { ViewAction::EmitAndClose(ViewEvent::StatusItemsUpdated { final_save, .. }) => { @@ -332,7 +336,7 @@ mod tests { #[test] fn esc_reverts_to_snapshot() { let active = StatusItem::default_footer(); - let mut view = StatusPickerView::new(&active); + let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek); // Toggle a few items off so the working set diverges from snapshot. view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); view.move_down(); @@ -350,7 +354,7 @@ mod tests { #[test] fn select_all_and_select_none_keys_work() { let active: Vec = Vec::new(); - let mut view = StatusPickerView::new(&active); + let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek); let action = view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); match action { ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, .. }) => { @@ -370,7 +374,7 @@ mod tests { #[test] fn arrow_keys_move_cursor_within_bounds() { let active = StatusItem::default_footer(); - let mut view = StatusPickerView::new(&active); + let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek); assert_eq!(view.cursor, 0); view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); assert_eq!(view.cursor, 1); @@ -382,4 +386,14 @@ mod tests { } assert_eq!(view.cursor, StatusItem::all().len() - 1); } + + #[test] + fn balance_excluded_for_non_deepseek_provider() { + let active = StatusItem::default_footer(); + let view = StatusPickerView::new(&active, ApiProvider::Openrouter); + // Balance should not appear as a row for non-DeepSeek providers. + assert!(!view.rows.contains(&StatusItem::Balance)); + // Mode should still be present. + assert!(view.rows.contains(&StatusItem::Mode)); + } } From 6241d52e79d0bea92eb9b8ba30573c870b50d06e Mon Sep 17 00:00:00 2001 From: ts25504 Date: Sat, 16 May 2026 11:15:46 +0800 Subject: [PATCH 04/13] refactor: place Balance after Cost in status item ordering --- crates/tui/src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 1da34b8a..ffea91cd 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -877,6 +877,7 @@ impl StatusItem { StatusItem::Mode, StatusItem::Model, StatusItem::Cost, + StatusItem::Balance, StatusItem::Status, StatusItem::Coherence, StatusItem::Agents, @@ -888,7 +889,6 @@ impl StatusItem { StatusItem::LastToolElapsed, StatusItem::RateLimit, StatusItem::Tokens, - StatusItem::Balance, ] } From 51cbcda5ffdd22a2dcd941e8ec08e71521304808 Mon Sep 17 00:00:00 2001 From: ts25504 Date: Sat, 16 May 2026 12:49:01 +0800 Subject: [PATCH 05/13] fix(footer): keep stale balance on fetch failure; localize "bal" prefix --- crates/tui/src/localization.rs | 8 ++++++++ crates/tui/src/tui/footer_ui.rs | 8 +++++--- crates/tui/src/tui/ui.rs | 7 ++++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 21809260..618b9d02 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -336,6 +336,7 @@ pub enum MessageId { FooterAgentsPlural, FooterPressCtrlCAgain, FooterWorking, + FooterBalancePrefix, HelpSectionActions, HelpSectionClipboard, HelpSectionEditing, @@ -575,6 +576,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::FooterAgentsPlural, MessageId::FooterPressCtrlCAgain, MessageId::FooterWorking, + MessageId::FooterBalancePrefix, MessageId::HelpSectionActions, MessageId::HelpSectionClipboard, MessageId::HelpSectionEditing, @@ -1047,6 +1049,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", @@ -1241,6 +1244,7 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> { MessageId::TranslationInProgress => "正在翻譯助理輸出...", MessageId::TranslationComplete => "翻譯完成", MessageId::TranslationFailed => "翻譯失敗", + MessageId::FooterBalancePrefix => "餘額", other => chinese_simplified(other)?, }) } @@ -1433,6 +1437,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 => "入力編集", @@ -1767,6 +1772,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 => "输入编辑", @@ -2117,6 +2123,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", @@ -2513,6 +2520,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", diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 49c3a33d..1162ef41 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -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; @@ -618,12 +619,13 @@ pub(crate) fn footer_balance_spans(app: &App) -> Vec> { "CNY" | "cny" => "¥", _ => "$", }; + let prefix = app.tr(MessageId::FooterBalancePrefix); let label = if total >= 1000.0 { - format!("bal {currency}{total:.0}") + format!("{prefix} {currency}{total:.0}") } else if total >= 10.0 { - format!("bal {currency}{total:.1}") + format!("{prefix} {currency}{total:.1}") } else { - format!("bal {currency}{total:.2}") + format!("{prefix} {currency}{total:.2}") }; vec![Span::styled( label, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 1954494b..d76028c9 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1646,9 +1646,10 @@ async fn run_event_loop( 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; + if let Some(info) = fetch_deepseek_balance(&api_key).await { + if let Ok(mut guard) = cell.lock() { + *guard = Some(info); + } } }); } From 75f7ea6dcdf4fbbfb8ba0513dea239a0fa48d1a0 Mon Sep 17 00:00:00 2001 From: ts25504 Date: Sat, 16 May 2026 13:29:15 +0800 Subject: [PATCH 06/13] fix(ui): reuse reqwest client for balance fetches; fix status picker visible rows --- crates/tui/src/tui/ui.rs | 8 ++++++-- crates/tui/src/tui/views/status_picker.rs | 5 +++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index d76028c9..4c475d18 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -5,7 +5,7 @@ use std::io::{self, Stdout, Write}; use std::path::PathBuf; #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] use std::process::{Command, Stdio}; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; use anyhow::Result; @@ -858,13 +858,17 @@ fn active_rlm_task_entries(app: &App) -> Vec { .collect() } +/// Shared `reqwest::Client` for balance fetches so connection pools are +/// reused across successive background polls. +static BALANCE_CLIENT: LazyLock<::reqwest::Client> = LazyLock::new(|| ::reqwest::Client::new()); + /// 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 { let url = "https://api.deepseek.com/user/balance"; - let client = ::reqwest::Client::new(); + let client = &*BALANCE_CLIENT; let response = client .get(url) .header("Authorization", format!("Bearer {api_key}")) diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index adfbae58..ef16c1d9 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -228,8 +228,9 @@ impl ModalView for StatusPickerView { let inner = block.inner(popup_area); block.render(popup_area, buf); - // Two header lines ("Pick the chips…" + blank), rest is item rows. - let visible = (inner.height as usize).saturating_sub(2).max(1); + // Four non-item lines (header, blank, footer hint, and one for + // the bottom border decoration), rest is item rows. + let visible = (inner.height as usize).saturating_sub(4).max(1); self.visible_rows.set(visible); // Auto-scroll so the cursor stays inside the visible window, From c2590d16bacd35f803eed6dff4efa87a5bd75c38 Mon Sep 17 00:00:00 2001 From: ts25504 Date: Sun, 24 May 2026 13:01:12 +0800 Subject: [PATCH 07/13] fix(balance): add 10s timeout to BALANCE_CLIENT; show chip at zero balance --- crates/tui/src/tui/footer_ui.rs | 2 +- crates/tui/src/tui/ui.rs | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 1162ef41..bc535f54 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -612,7 +612,7 @@ pub(crate) fn footer_balance_spans(app: &App) -> Vec> { None => return Vec::new(), }; let total = match info.total_balance_f64() { - Some(total) if total > 0.0 => total, + Some(total) if total >= 0.0 => total, _ => return Vec::new(), }; let currency = match info.currency.as_str() { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 4c475d18..8a824add 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -860,7 +860,12 @@ fn active_rlm_task_entries(app: &App) -> Vec { /// Shared `reqwest::Client` for balance fetches so connection pools are /// reused across successive background polls. -static BALANCE_CLIENT: LazyLock<::reqwest::Client> = LazyLock::new(|| ::reqwest::Client::new()); +static BALANCE_CLIENT: LazyLock<::reqwest::Client> = LazyLock::new(|| { + ::reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .unwrap_or_default() +}); /// Fetch the DeepSeek account balance from the balance API. /// From 8d092588abe2188b1c20e5c8c28a83dddeeadfd2 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Wed, 27 May 2026 12:59:30 +0800 Subject: [PATCH 08/13] fix: resolve rebase conflicts and clippy warnings - Merge upstream Tokens status item with Balance from PR #1970 - Keep Balance opt-in (not in default_footer) - Fix clippy: collapsible if, useless format!, redundant closure - Show balance only when total > 0 Co-Authored-By: MoriTang Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/commands/config.rs | 6 +++--- crates/tui/src/config_ui.rs | 9 --------- crates/tui/src/runtime_log.rs | 2 +- crates/tui/src/tui/footer_ui.rs | 2 +- crates/tui/src/tui/ui.rs | 8 ++++---- crates/tui/src/tui/views/status_picker.rs | 7 ------- 6 files changed, 9 insertions(+), 25 deletions(-) diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 651b4d5d..192f1697 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -473,9 +473,9 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> Err(err) => return CommandResult::error(format!("Failed to save: {err}")), } } - return CommandResult::error(format!( - "base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving." - )); + return CommandResult::error( + "base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.".to_string(), + ); } _ => {} } diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index b3da0253..4724715f 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -278,11 +278,8 @@ pub enum StatusItemValue { GitBranch, LastToolElapsed, RateLimit, -<<<<<<< HEAD Tokens, -======= Balance, ->>>>>>> 4bc823e6 (feat: add account balance status bar item) } pub fn parse_mode(arg: Option<&str>) -> Result { @@ -1005,11 +1002,8 @@ impl From for StatusItemValue { StatusItem::GitBranch => Self::GitBranch, StatusItem::LastToolElapsed => Self::LastToolElapsed, StatusItem::RateLimit => Self::RateLimit, -<<<<<<< HEAD StatusItem::Tokens => Self::Tokens, -======= StatusItem::Balance => Self::Balance, ->>>>>>> 4bc823e6 (feat: add account balance status bar item) } } } @@ -1030,11 +1024,8 @@ impl From for StatusItem { StatusItemValue::GitBranch => Self::GitBranch, StatusItemValue::LastToolElapsed => Self::LastToolElapsed, StatusItemValue::RateLimit => Self::RateLimit, -<<<<<<< HEAD StatusItemValue::Tokens => Self::Tokens, -======= StatusItemValue::Balance => Self::Balance, ->>>>>>> 4bc823e6 (feat: add account balance status bar item) } } } diff --git a/crates/tui/src/runtime_log.rs b/crates/tui/src/runtime_log.rs index 48373d4b..2edd53ad 100644 --- a/crates/tui/src/runtime_log.rs +++ b/crates/tui/src/runtime_log.rs @@ -174,7 +174,7 @@ fn log_directory() -> Option { { return resolve(userprofile); } - dirs::home_dir().and_then(|h| resolve(h)) + dirs::home_dir().and_then(resolve) } fn log_file_name(date: &str, pid: u32) -> String { diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index bc535f54..1162ef41 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -612,7 +612,7 @@ pub(crate) fn footer_balance_spans(app: &App) -> Vec> { None => return Vec::new(), }; let total = match info.total_balance_f64() { - Some(total) if total >= 0.0 => total, + Some(total) if total > 0.0 => total, _ => return Vec::new(), }; let currency = match info.currency.as_str() { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 8a824add..6b01958f 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1655,10 +1655,10 @@ async fn run_event_loop( let api_key = config.deepseek_api_key().unwrap_or_default(); if !api_key.is_empty() { tokio::spawn(async move { - if let Some(info) = fetch_deepseek_balance(&api_key).await { - if let Ok(mut guard) = cell.lock() { - *guard = Some(info); - } + if let Some(info) = fetch_deepseek_balance(&api_key).await + && let Ok(mut guard) = cell.lock() + { + *guard = Some(info); } }); } diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index ef16c1d9..d47a5ffa 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -248,19 +248,12 @@ impl ModalView for StatusPickerView { ))); lines.push(Line::from("")); -<<<<<<< HEAD - for (idx, item) in self.rows.iter().enumerate() { - let checked = *self.selected.get(idx).unwrap_or(&false); - let is_cursor = idx == self.cursor; - let mark = if checked { "[✓]" } else { "[ ]" }; -======= let end = (offset + visible).min(self.rows.len()); for (idx, item) in self.rows[offset..end].iter().enumerate() { let real_idx = offset + idx; let checked = *self.selected.get(real_idx).unwrap_or(&false); let is_cursor = real_idx == self.cursor; let mark = if checked { "[x]" } else { "[ ]" }; ->>>>>>> 4bc823e6 (feat: add account balance status bar item) let row_style = if is_cursor { Style::default() From f9516921b1ec89a1f79889a0757baf8f70a59d66 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Wed, 27 May 2026 13:18:54 +0800 Subject: [PATCH 09/13] fix(balance): fetch balance on startup and provider switch Previously the balance chip only appeared after a completed turn. Now it also fetches: - On first frame (startup) for DeepSeek/DeepSeekCN providers - After switching to DeepSeek, and clears when switching away Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/tui/app.rs | 3 +++ crates/tui/src/tui/ui.rs | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index bfe5ec58..ca3f48f4 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1405,6 +1405,8 @@ pub struct App { /// 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>>, + /// Tracks whether the initial balance fetch has been attempted for this session. + pub balance_initiated: bool, /// Current runtime turn id (if known). pub runtime_turn_id: Option, /// Current runtime turn status (if known). @@ -1984,6 +1986,7 @@ impl App { turn_started_at: None, cumulative_turn_duration: std::time::Duration::ZERO, balance_cell: std::sync::Arc::new(std::sync::Mutex::new(None)), + balance_initiated: false, runtime_turn_id: None, runtime_turn_status: None, dispatch_started_at: None, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 6b01958f..d826c282 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -974,6 +974,27 @@ async fn run_event_loop( }) }); + // Fire a one-shot initial balance fetch for DeepSeek providers + // so the footer chip shows balance on the first frame without + // waiting for a turn to complete. + if !app.balance_initiated + && (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 { + if let Some(info) = fetch_deepseek_balance(&api_key).await + && let Ok(mut guard) = cell.lock() + { + *guard = Some(info); + } + }); + } + app.balance_initiated = true; + } + loop { // Drain the version-check handle once; re-assign None so we // don't poll it again. @@ -4816,6 +4837,27 @@ async fn apply_command_result( } AppAction::SwitchProvider { provider, model } => { switch_provider(app, engine_handle, config, provider, model).await; + // Refresh balance after provider switch. + 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 { + if let Some(info) = fetch_deepseek_balance(&api_key).await + && let Ok(mut guard) = cell.lock() + { + *guard = Some(info); + } + }); + } + } else { + // Clear balance when switching to a non-DeepSeek provider. + if let Ok(mut guard) = app.balance_cell.lock() { + *guard = None; + } + } } AppAction::UpdateCompaction(compaction) => { apply_model_and_compaction_update(engine_handle, compaction).await; From c57b83024920c2e0d5ecb28c0dc0624ec5b3baa1 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Wed, 27 May 2026 13:47:33 +0800 Subject: [PATCH 10/13] fix(balance): render balance before cost in footer priority order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Balance (account remaining) is more actionable than session cost, so it should drop later when the footer is width-constrained. Tier order: status → cost → balance → model (was: status → balance → cost → model) Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/tui/widgets/footer.rs | 42 ++++++++++++++-------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index d402e1df..6f847f6d 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -376,9 +376,9 @@ impl FooterWidget { /// /// Priority order (highest to lowest — last to drop): /// 1. Mode label (always visible at any width; truncated only as a last resort) - /// 2. Model name (always visible; then truncated mid-word once status, balance, & cost are gone) - /// 3. Cost chip — drops third (steady cost is more important than balance) - /// 4. Balance chip — drops second (after status, before cost) + /// 2. Model name (always visible; then truncated mid-word once all hints are gone) + /// 3. Balance chip — drops third (account balance is more actionable than session cost) + /// 4. Cost chip — drops fourth /// 5. Status label (e.g. "working", "draft") — drops first when space is tight /// /// At every width ≥40 cols the line never wraps mid-hint. @@ -410,59 +410,59 @@ impl FooterWidget { let extra_sep = |w: usize| if w > 0 { sep_w } else { 0 }; - // Tier 1: mode · model · cost · balance · status + // Tier 1: mode · model · balance · cost · status let full_w = mode_w + sep_w + model_w - + extra_sep(cost_w) - + cost_w + extra_sep(balance_w) + balance_w + + extra_sep(cost_w) + + cost_w + extra_sep(status_w) + status_w; - if (show_cost || show_balance || show_status) && full_w <= max_width { + if (show_balance || show_cost || show_status) && full_w <= max_width { return self.build_status_line_spans( mode_label, model.to_string(), - show_cost.then(|| cost_text.clone()), show_balance.then(|| balance_text.clone()), + show_cost.then(|| cost_text.clone()), show_status.then_some(status_label), ); } - // Tier 2: mode · model · cost · balance — drop status. - let with_balance_w = mode_w + // Tier 2: mode · model · balance · cost — drop status. + let with_cost_w = mode_w + sep_w + model_w - + extra_sep(cost_w) - + cost_w + extra_sep(balance_w) - + balance_w; - if (show_cost || show_balance) && with_balance_w <= max_width { + + balance_w + + extra_sep(cost_w) + + cost_w; + if (show_balance || show_cost) && with_cost_w <= max_width { return self.build_status_line_spans( mode_label, model.to_string(), - show_cost.then(|| cost_text.clone()), show_balance.then(|| balance_text.clone()), + show_cost.then(|| cost_text.clone()), None, ); } - // Tier 3: mode · model · cost — drop balance. - if show_cost { - let with_cost_w = mode_w + sep_w + model_w + sep_w + cost_w; - if with_cost_w <= max_width { + // Tier 3: mode · model · balance — drop cost. + if show_balance { + let with_balance_w = mode_w + sep_w + model_w + sep_w + balance_w; + if with_balance_w <= max_width { return self.build_status_line_spans( mode_label, model.to_string(), - Some(cost_text.clone()), + Some(balance_text.clone()), None, None, ); } } - // Tier 4: mode · model — drop cost too. + // Tier 4: mode · model — drop balance too. let mode_model_w = mode_w + sep_w + model_w; if mode_model_w <= max_width { return self.build_status_line_spans(mode_label, model.to_string(), None, None, None); From 1ad0a17ab6a8560ba9a8b37ebc016d534888b283 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Wed, 27 May 2026 14:08:50 +0800 Subject: [PATCH 11/13] fix(config): silently skip unknown status_items instead of crashing When a dev build writes new status item variants (e.g. "balance") to config.toml, the stable build must not crash with "unknown variant". Add a tolerant deserializer that filters unrecognized keys via StatusItem::from_key() and logs a warning. Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/config.rs | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index ffea91cd..dde0168e 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -467,6 +467,28 @@ pub struct RetryConfig { pub exponential_base: Option, } +/// Deserialize `status_items` tolerantly: skip keys unknown to this build +/// instead of erroring with "unknown variant". This lets a dev build write +/// `"balance"` (or any future item) while the stable build still parses the +/// config file successfully. +fn deser_status_items<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let raw: Option> = Option::deserialize(deserializer)?; + Ok(raw.map(|strings| { + strings + .into_iter() + .filter_map(|s| { + StatusItem::from_key(&s).or_else(|| { + tracing::warn!("ignoring unknown status item {s:?} in config"); + None + }) + }) + .collect() + })) +} + /// UI configuration loaded from config files. #[derive(Debug, Clone, Deserialize, Default)] pub struct TuiConfig { @@ -481,6 +503,7 @@ pub struct TuiConfig { /// /// Edited interactively via `/statusline`; persisted to `tui.status_items` /// in `~/.deepseek/config.toml`. + #[serde(deserialize_with = "deser_status_items")] pub status_items: Option>, /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, @@ -825,6 +848,31 @@ impl StatusItem { } } + /// Reverse of [`key`](Self::key): parse a config string back to a variant. + /// Returns `None` for unknown keys so the config parser can silently skip + /// items added by newer versions rather than crashing with "unknown variant". + #[must_use] + pub fn from_key(key: &str) -> Option { + match key { + "mode" => Some(Self::Mode), + "model" => Some(Self::Model), + "cost" => Some(Self::Cost), + "status" => Some(Self::Status), + "coherence" => Some(Self::Coherence), + "agents" => Some(Self::Agents), + "reasoning_replay" => Some(Self::ReasoningReplay), + "prefix_stability" => Some(Self::PrefixStability), + "cache" => Some(Self::Cache), + "context_percent" => Some(Self::ContextPercent), + "git_branch" => Some(Self::GitBranch), + "last_tool_elapsed" => Some(Self::LastToolElapsed), + "rate_limit" => Some(Self::RateLimit), + "tokens" => Some(Self::Tokens), + "balance" => Some(Self::Balance), + _ => None, + } + } + /// Human-readable label for the picker. #[must_use] pub fn label(self) -> &'static str { @@ -7577,4 +7625,22 @@ model = "deepseek-ai/deepseek-v4-pro" // Other StatusItem variants should be available everywhere. assert!(StatusItem::Mode.is_available_for(ApiProvider::Ollama)); } + + #[test] + fn status_items_deser_ignores_unknown_variants() { + // Simulate a stable build reading config written by a dev build that + // knows about items the stable build doesn't (e.g. "balance" or a + // future "cost_saving" chip). + let toml_str = r#" + alternate_screen = "auto" + status_items = ["mode", "model", "unknown_future_item", "cost", "another_unknown", "status"] + "#; + let tui: TuiConfig = toml::from_str(toml_str).expect("should parse without error"); + let items = tui.status_items.expect("status_items should be Some"); + assert_eq!(items.len(), 4, "unknown items should be silently dropped"); + assert_eq!(items[0], StatusItem::Mode); + assert_eq!(items[1], StatusItem::Model); + assert_eq!(items[2], StatusItem::Cost); + assert_eq!(items[3], StatusItem::Status); + } } From 13b182bf4779439111ea5db78588f3ead5ba080a Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Wed, 27 May 2026 14:15:20 +0800 Subject: [PATCH 12/13] chore: bump version to 0.8.47 (dev) Co-Authored-By: Claude Opus 4.7 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index dae89151..c82fe624 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.8.46" +version = "0.8.47" edition = "2024" # Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the # codebase relies on extensively. Cargo enforces this so users on older From a95d65861e955561ae6af34e80191f9129a7bc52 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Wed, 27 May 2026 22:49:21 +0800 Subject: [PATCH 13/13] fix(balance): address review feedback on balance status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use config.deepseek_base_url() instead of hardcoded api.deepseek.com - Add 60-second debounce (BALANCE_FETCH_COOLDOWN) to prevent rapid consecutive balance API calls during provider switches - Fix [x] → [✓] regression in status_picker Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/tui/app.rs | 3 ++ crates/tui/src/tui/ui.rs | 35 +++++++++++++++++------ crates/tui/src/tui/views/status_picker.rs | 2 +- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index ca3f48f4..5059a2fb 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1407,6 +1407,8 @@ pub struct App { pub balance_cell: std::sync::Arc>>, /// Tracks whether the initial balance fetch has been attempted for this session. pub balance_initiated: bool, + /// Timestamp of the last balance fetch, used to debounce rapid requests. + pub last_balance_fetch: Option, /// Current runtime turn id (if known). pub runtime_turn_id: Option, /// Current runtime turn status (if known). @@ -1987,6 +1989,7 @@ impl App { cumulative_turn_duration: std::time::Duration::ZERO, balance_cell: std::sync::Arc::new(std::sync::Mutex::new(None)), balance_initiated: false, + last_balance_fetch: None, runtime_turn_id: None, runtime_turn_status: None, dispatch_started_at: None, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index d826c282..06f0cddc 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -858,6 +858,9 @@ fn active_rlm_task_entries(app: &App) -> Vec { .collect() } +/// Minimum interval between balance API fetches to avoid flooding. +const BALANCE_FETCH_COOLDOWN: Duration = Duration::from_secs(60); + /// Shared `reqwest::Client` for balance fetches so connection pools are /// reused across successive background polls. static BALANCE_CLIENT: LazyLock<::reqwest::Client> = LazyLock::new(|| { @@ -871,8 +874,8 @@ static BALANCE_CLIENT: LazyLock<::reqwest::Client> = LazyLock::new(|| { /// /// 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 { - let url = "https://api.deepseek.com/user/balance"; +async fn fetch_deepseek_balance(api_key: &str, base_url: &str) -> Option { + let url = format!("{}/user/balance", base_url.trim_end_matches('/')); let client = &*BALANCE_CLIENT; let response = client .get(url) @@ -983,9 +986,11 @@ async fn run_event_loop( { let cell = app.balance_cell.clone(); let api_key = config.deepseek_api_key().unwrap_or_default(); + let base_url = config.deepseek_base_url(); if !api_key.is_empty() { + app.last_balance_fetch = Some(Instant::now()); tokio::spawn(async move { - if let Some(info) = fetch_deepseek_balance(&api_key).await + if let Some(info) = fetch_deepseek_balance(&api_key, &base_url).await && let Ok(mut guard) = cell.lock() { *guard = Some(info); @@ -1669,14 +1674,20 @@ async fn run_event_loop( // 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 balance_cooldown_expired = app + .last_balance_fetch + .map_or(true, |t| t.elapsed() >= BALANCE_FETCH_COOLDOWN); + if balance_cooldown_expired + && (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(); + let base_url = config.deepseek_base_url(); if !api_key.is_empty() { + app.last_balance_fetch = Some(Instant::now()); tokio::spawn(async move { - if let Some(info) = fetch_deepseek_balance(&api_key).await + if let Some(info) = fetch_deepseek_balance(&api_key, &base_url).await && let Ok(mut guard) = cell.lock() { *guard = Some(info); @@ -4838,14 +4849,20 @@ async fn apply_command_result( AppAction::SwitchProvider { provider, model } => { switch_provider(app, engine_handle, config, provider, model).await; // Refresh balance after provider switch. - if app.api_provider == ApiProvider::Deepseek - || app.api_provider == ApiProvider::DeepseekCN + let balance_cooldown_expired = app + .last_balance_fetch + .map_or(true, |t| t.elapsed() >= BALANCE_FETCH_COOLDOWN); + if balance_cooldown_expired + && (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(); + let base_url = config.deepseek_base_url(); if !api_key.is_empty() { + app.last_balance_fetch = Some(Instant::now()); tokio::spawn(async move { - if let Some(info) = fetch_deepseek_balance(&api_key).await + if let Some(info) = fetch_deepseek_balance(&api_key, &base_url).await && let Ok(mut guard) = cell.lock() { *guard = Some(info); diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index d47a5ffa..e00502be 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -253,7 +253,7 @@ impl ModalView for StatusPickerView { let real_idx = offset + idx; let checked = *self.selected.get(real_idx).unwrap_or(&false); let is_cursor = real_idx == self.cursor; - let mark = if checked { "[x]" } else { "[ ]" }; + let mark = if checked { "[✓]" } else { "[ ]" }; let row_style = if is_cursor { Style::default()