diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 077bdd17a..e6fa17064 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -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 { @@ -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. ] } @@ -698,6 +703,7 @@ impl StatusItem { StatusItem::GitBranch => "git_branch", StatusItem::LastToolElapsed => "last_tool_elapsed", StatusItem::RateLimit => "rate_limit", + StatusItem::Balance => "balance", } } @@ -718,6 +724,7 @@ impl StatusItem { StatusItem::GitBranch => "Git branch", StatusItem::LastToolElapsed => "Last tool elapsed", StatusItem::RateLimit => "Rate-limit remaining", + StatusItem::Balance => "Account balance", } } @@ -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", } } @@ -749,6 +757,7 @@ impl StatusItem { StatusItem::Mode, StatusItem::Model, StatusItem::Cost, + StatusItem::Balance, StatusItem::Status, StatusItem::Coherence, StatusItem::Agents, @@ -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. @@ -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)); + } } diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index a0915dd82..e553bea8d 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -278,6 +278,7 @@ pub enum StatusItemValue { GitBranch, LastToolElapsed, RateLimit, + Balance, } pub fn parse_mode(arg: Option<&str>) -> Result { @@ -996,6 +997,7 @@ impl From for StatusItemValue { StatusItem::GitBranch => Self::GitBranch, StatusItem::LastToolElapsed => Self::LastToolElapsed, StatusItem::RateLimit => Self::RateLimit, + StatusItem::Balance => Self::Balance, } } } @@ -1016,6 +1018,7 @@ impl From for StatusItem { StatusItemValue::GitBranch => Self::GitBranch, StatusItemValue::LastToolElapsed => Self::LastToolElapsed, StatusItemValue::RateLimit => Self::RateLimit, + StatusItemValue::Balance => Self::Balance, } } } diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index bcb7d881f..7301f5491 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -333,6 +333,7 @@ pub enum MessageId { FooterAgentsPlural, FooterPressCtrlCAgain, FooterWorking, + FooterBalancePrefix, HelpSectionActions, HelpSectionClipboard, HelpSectionEditing, @@ -569,6 +570,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::FooterAgentsPlural, MessageId::FooterPressCtrlCAgain, MessageId::FooterWorking, + MessageId::FooterBalancePrefix, MessageId::HelpSectionActions, MessageId::HelpSectionClipboard, MessageId::HelpSectionEditing, @@ -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", @@ -1230,6 +1233,7 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> { MessageId::TranslationInProgress => "正在翻譯助理輸出...", MessageId::TranslationComplete => "翻譯完成", MessageId::TranslationFailed => "翻譯失敗", + MessageId::FooterBalancePrefix => "餘額", other => chinese_simplified(other)?, }) } @@ -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 => "入力編集", @@ -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 => "输入编辑", @@ -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", @@ -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", diff --git a/crates/tui/src/pricing.rs b/crates/tui/src/pricing.rs index 750f9830b..5869a7cf1 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 { @@ -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); + } } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 093771b4b..68f14b6bd 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -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>>, /// Current runtime turn id (if known). pub runtime_turn_id: Option, /// Current runtime turn status (if known). @@ -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, diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 958e965db..93cfd02e4 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; @@ -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 @@ -398,6 +404,7 @@ pub(crate) fn render_footer_from( reasoning_replay, cache, cost, + balance, ); if !has(S::Mode) { props.mode_label = ""; @@ -487,6 +494,37 @@ pub(crate) fn footer_cost_spans(app: &App) -> Vec> { )] } +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 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 } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 13f60e164..f1bb39b4c 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -4,7 +4,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; @@ -779,6 +779,36 @@ 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 = &*BALANCE_CLIENT; + 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, @@ -1401,6 +1431,25 @@ 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 { + if let Some(info) = fetch_deepseek_balance(&api_key).await { + if let Ok(mut guard) = cell.lock() { + *guard = Some(info); + } + } + }); + } + } + if app.mode == AppMode::Plan && app.plan_tool_used_in_turn && !app.plan_prompt_pending @@ -4562,6 +4611,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 55c67b9e5..33b00962a 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::{ @@ -5059,13 +5059,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" @@ -5158,6 +5162,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 diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index 2cbf576e1..d47a5ffae 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, @@ -18,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}; @@ -35,18 +37,30 @@ 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 { #[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, selected, cursor: 0, original: active.to_vec(), + scroll_offset: Cell::new(0), + visible_rows: Cell::new(0), } } @@ -65,6 +79,7 @@ impl StatusPickerView { if self.cursor > 0 { self.cursor -= 1; } + self.scroll_to_cursor(); } fn move_down(&mut self) { @@ -72,6 +87,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 +186,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,16 +228,31 @@ 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); + // 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, + // 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("")); - for (idx, item) in self.rows.iter().enumerate() { - let checked = *self.selected.get(idx).unwrap_or(&false); - let is_cursor = idx == self.cursor; + 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 { "[ ]" }; let row_style = if is_cursor { @@ -246,14 +295,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 { @@ -268,7 +317,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, .. }) => { @@ -281,7 +330,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(); @@ -299,7 +348,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, .. }) => { @@ -319,7 +368,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); @@ -331,4 +380,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)); + } } diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index 01ac69f8e..f3a425aa4 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 @@ -246,6 +249,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 @@ -280,6 +284,7 @@ impl FooterProps { mcp, worked, cost, + balance, toast, working_strip_frame: None, retry: crate::retry_status::snapshot(), @@ -358,15 +363,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(); @@ -379,29 +381,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 { @@ -410,17 +444,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; @@ -429,13 +464,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(), @@ -453,21 +487,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( @@ -492,6 +522,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( @@ -691,6 +733,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 @@ -803,6 +846,7 @@ mod tests { Vec::>::new(), Vec::>::new(), Vec::>::new(), + Vec::>::new(), ); assert!(props.state_label.starts_with("thinking")); @@ -878,6 +922,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); @@ -1140,6 +1185,7 @@ mod tests { Vec::>::new(), Vec::>::new(), Vec::>::new(), + Vec::>::new(), ) } @@ -1236,6 +1282,7 @@ mod tests { Vec::>::new(), Vec::>::new(), vec![Span::styled(cost.to_string(), Style::default())], + Vec::>::new(), ) } @@ -1256,6 +1303,7 @@ mod tests { Vec::>::new(), long_cache, Vec::>::new(), + Vec::>::new(), ); let line = render_at_width(props, 40); @@ -1288,6 +1336,7 @@ mod tests { Vec::>::new(), cache, Vec::>::new(), + Vec::>::new(), ); let line = render_at_width(props, 80); @@ -1352,6 +1401,7 @@ mod tests { Vec::>::new(), Vec::>::new(), Vec::>::new(), + Vec::>::new(), ); let widget = FooterWidget::new(props);