Merge pull request #2257 from HUQIANTAO/feat/deepseek-balance-carry

feat: add account balance status bar item (carries #1970)
This commit is contained in:
Hunter Bown
2026-05-31 00:10:54 -07:00
committed by GitHub
10 changed files with 636 additions and 50 deletions
+109 -1
View File
@@ -503,6 +503,28 @@ pub struct RetryConfig {
pub exponential_base: Option<f64>,
}
/// 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<Option<Vec<StatusItem>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: Option<Vec<String>> = 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 {
@@ -517,6 +539,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<Vec<StatusItem>>,
/// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so
/// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,
@@ -829,6 +852,8 @@ pub enum StatusItem {
RateLimit,
/// Session token usage: input / cache-hit / output.
Tokens,
/// DeepSeek account balance, refreshed once per turn completion.
Balance,
}
impl StatusItem {
@@ -870,6 +895,32 @@ impl StatusItem {
StatusItem::LastToolElapsed => "last_tool_elapsed",
StatusItem::RateLimit => "rate_limit",
StatusItem::Tokens => "tokens",
StatusItem::Balance => "balance",
}
}
/// 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<Self> {
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,
}
}
@@ -891,6 +942,7 @@ impl StatusItem {
StatusItem::LastToolElapsed => "Last tool elapsed",
StatusItem::RateLimit => "Rate-limit remaining",
StatusItem::Tokens => "Session tokens",
StatusItem::Balance => "Account balance",
}
}
@@ -913,6 +965,7 @@ impl StatusItem {
StatusItem::LastToolElapsed => "ms of the most recent tool call (placeholder)",
StatusItem::RateLimit => "remaining requests in the budget (placeholder)",
StatusItem::Tokens => "input / cache-hit / output token totals",
StatusItem::Balance => "topped-up + granted balance from DeepSeek",
}
}
@@ -923,6 +976,7 @@ impl StatusItem {
StatusItem::Mode,
StatusItem::Model,
StatusItem::Cost,
StatusItem::Balance,
StatusItem::Status,
StatusItem::Coherence,
StatusItem::Agents,
@@ -942,9 +996,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.
@@ -7855,4 +7926,41 @@ 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));
}
#[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);
}
}
+3
View File
@@ -280,6 +280,7 @@ pub enum StatusItemValue {
LastToolElapsed,
RateLimit,
Tokens,
Balance,
}
pub fn parse_mode(arg: Option<&str>) -> Result<ConfigUiMode, String> {
@@ -1005,6 +1006,7 @@ impl From<StatusItem> for StatusItemValue {
StatusItem::LastToolElapsed => Self::LastToolElapsed,
StatusItem::RateLimit => Self::RateLimit,
StatusItem::Tokens => Self::Tokens,
StatusItem::Balance => Self::Balance,
}
}
}
@@ -1026,6 +1028,7 @@ impl From<StatusItemValue> for StatusItem {
StatusItemValue::LastToolElapsed => Self::LastToolElapsed,
StatusItemValue::RateLimit => Self::RateLimit,
StatusItemValue::Tokens => Self::Tokens,
StatusItemValue::Balance => Self::Balance,
}
}
}
+9
View File
@@ -342,6 +342,7 @@ pub enum MessageId {
FooterAgentsPlural,
FooterPressCtrlCAgain,
FooterWorking,
FooterBalancePrefix,
HelpSectionActions,
HelpSectionClipboard,
HelpSectionEditing,
@@ -609,6 +610,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::FooterAgentsPlural,
MessageId::FooterPressCtrlCAgain,
MessageId::FooterWorking,
MessageId::FooterBalancePrefix,
MessageId::HelpSectionActions,
MessageId::HelpSectionClipboard,
MessageId::HelpSectionEditing,
@@ -1119,6 +1121,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",
@@ -1541,6 +1544,7 @@ fn vietnamese(id: MessageId) -> Option<&'static str> {
MessageId::FooterAgentsPlural => "{count} tác nhân",
MessageId::FooterPressCtrlCAgain => "Nhấn Ctrl+C một lần nữa để thoát",
MessageId::FooterWorking => "đang xử lý",
MessageId::FooterBalancePrefix => "số dư",
MessageId::HelpSectionActions => "Hành động",
MessageId::HelpSectionClipboard => "Bộ nhớ tạm",
MessageId::HelpSectionEditing => "Chỉnh sửa đầu vào",
@@ -1762,6 +1766,7 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> {
MessageId::TranslationInProgress => "正在翻譯助理輸出...",
MessageId::TranslationComplete => "翻譯完成",
MessageId::TranslationFailed => "翻譯失敗",
MessageId::FooterBalancePrefix => "餘額",
other => chinese_simplified(other)?,
})
}
@@ -1958,6 +1963,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 => "入力編集",
@@ -2320,6 +2326,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 => "输入编辑",
@@ -2700,6 +2707,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",
@@ -3126,6 +3134,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",
+106
View File
@@ -55,6 +55,39 @@ impl CostEstimate {
}
}
// === DeepSeek Account Balance ===
/// Response from `GET https://api.deepseek.com/user/balance`.
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct BalanceResponse {
#[allow(dead_code)]
pub is_available: bool,
pub balance_infos: Vec<BalanceInfo>,
}
/// Per-currency balance entry from the balance API.
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct BalanceInfo {
pub currency: String,
#[serde(default)]
pub total_balance: String,
#[serde(default)]
#[allow(dead_code)]
pub topped_up_balance: String,
#[serde(default)]
#[allow(dead_code)]
pub granted_balance: String,
}
impl BalanceInfo {
/// Parse the `total_balance` field as an f64. Returns `None` on parse
/// failure or empty string.
#[must_use]
pub fn total_balance_f64(&self) -> Option<f64> {
self.total_balance.parse::<f64>().ok()
}
}
/// Per-million-token pricing for a model.
#[derive(Debug, Clone, Copy)]
struct CurrencyPricing {
@@ -357,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);
}
}
+10
View File
@@ -1433,6 +1433,13 @@ pub struct App {
/// Incremented on `TurnComplete` from the elapsed time of the
/// just-finished turn. Resets per launch.
pub cumulative_turn_duration: std::time::Duration,
/// DeepSeek account balance, refreshed once per turn completion.
/// Shared cell updated by background fetch tasks; read lock in the UI thread.
pub balance_cell: std::sync::Arc<std::sync::Mutex<Option<crate::pricing::BalanceInfo>>>,
/// 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<std::time::Instant>,
/// Current runtime turn id (if known).
pub runtime_turn_id: Option<String>,
/// Current runtime turn status (if known).
@@ -2029,6 +2036,9 @@ 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)),
balance_initiated: false,
last_balance_fetch: None,
runtime_turn_id: None,
runtime_turn_status: None,
dispatch_started_at: None,
+38
View File
@@ -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;
@@ -461,6 +462,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
@@ -475,6 +481,7 @@ pub(crate) fn render_footer_from(
reasoning_replay,
cache,
cost,
balance,
);
if !has(S::Mode) {
props.mode_label = "";
@@ -587,6 +594,37 @@ pub(crate) fn footer_cost_spans(app: &App) -> Vec<Span<'static>> {
spans
}
pub(crate) fn footer_balance_spans(app: &App) -> Vec<Span<'static>> {
let balance = match app.balance_cell.lock() {
Ok(guard) => guard,
Err(_) => return Vec::new(),
};
let info = match balance.as_ref() {
Some(info) => info,
None => return Vec::new(),
};
let total = match info.total_balance_f64() {
Some(total) if total > 0.0 => total,
_ => return Vec::new(),
};
let currency = match info.currency.as_str() {
"CNY" | "cny" => "¥",
_ => "$",
};
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
}
+119 -2
View File
@@ -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;
@@ -41,7 +41,7 @@ use crate::client::{DeepSeekClient, build_cache_warmup_request};
use crate::commands;
use crate::compaction::estimate_input_tokens_conservative;
use crate::config::{
ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL, ProviderConfig, ProvidersConfig,
ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL, ProviderConfig, ProvidersConfig, StatusItem,
save_provider_auth_mode_for,
};
use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEvent};
@@ -906,6 +906,55 @@ fn active_rlm_task_entries(app: &App) -> Vec<TaskPanelEntry> {
.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(|| {
::reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.unwrap_or_default()
});
/// 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,
base_url: &str,
) -> Option<crate::pricing::BalanceInfo> {
let url = format!("{}/user/balance", base_url.trim_end_matches('/'));
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()
}
fn should_fetch_deepseek_balance(app: &App) -> bool {
app.status_items.contains(&StatusItem::Balance)
&& matches!(
app.api_provider,
ApiProvider::Deepseek | ApiProvider::DeepseekCN
)
}
#[allow(clippy::too_many_lines)]
async fn run_event_loop(
terminal: &mut AppTerminal,
@@ -972,6 +1021,26 @@ 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 && should_fetch_deepseek_balance(app) {
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, &base_url).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.
@@ -1665,6 +1734,29 @@ 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.
let balance_cooldown_expired = app
.last_balance_fetch
.map_or(true, |t| t.elapsed() >= BALANCE_FETCH_COOLDOWN);
if balance_cooldown_expired && should_fetch_deepseek_balance(app) {
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, &base_url).await
&& let Ok(mut guard) = cell.lock()
{
*guard = Some(info);
}
});
}
}
if app.mode == AppMode::Plan
&& app.plan_tool_used_in_turn
&& !app.plan_prompt_pending
@@ -5015,6 +5107,30 @@ async fn apply_command_result(
}
AppAction::SwitchProvider { provider, model } => {
switch_provider(app, engine_handle, config, provider, model).await;
// Refresh balance after provider switch.
let balance_cooldown_expired = app
.last_balance_fetch
.map_or(true, |t| t.elapsed() >= BALANCE_FETCH_COOLDOWN);
if balance_cooldown_expired && should_fetch_deepseek_balance(app) {
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, &base_url).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;
@@ -5117,6 +5233,7 @@ async fn apply_command_result(
app.view_stack
.push(crate::tui::views::status_picker::StatusPickerView::new(
&app.status_items,
app.api_provider,
));
}
}
+131 -3
View File
@@ -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::{
@@ -6054,13 +6054,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"
@@ -6158,6 +6162,130 @@ 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());
}
#[test]
fn should_fetch_deepseek_balance_requires_balance_status_item() {
let mut app = create_test_app();
app.api_provider = ApiProvider::Deepseek;
app.status_items = crate::config::StatusItem::default_footer();
assert!(!should_fetch_deepseek_balance(&app));
app.status_items.push(crate::config::StatusItem::Balance);
assert!(should_fetch_deepseek_balance(&app));
}
#[test]
fn should_fetch_deepseek_balance_requires_deepseek_provider() {
let mut app = create_test_app();
app.status_items = vec![crate::config::StatusItem::Balance];
app.api_provider = ApiProvider::Openrouter;
assert!(!should_fetch_deepseek_balance(&app));
app.api_provider = ApiProvider::DeepseekCN;
assert!(should_fetch_deepseek_balance(&app));
}
#[test]
fn default_footer_renders_workspace_branch_when_available() {
let repo = init_git_repo();
+31 -14
View File
@@ -18,7 +18,7 @@ use ratatui::{
widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
};
use crate::config::StatusItem;
use crate::config::{ApiProvider, StatusItem};
use crate::localization::truncate_to_width;
use crate::palette;
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
@@ -43,8 +43,12 @@ pub struct StatusPickerView {
impl StatusPickerView {
#[must_use]
pub fn new(active: &[StatusItem]) -> Self {
let rows: Vec<StatusItem> = StatusItem::all().to_vec();
pub fn new(active: &[StatusItem], provider: ApiProvider) -> Self {
let rows: Vec<StatusItem> = StatusItem::all()
.iter()
.filter(|item| item.is_available_for(provider))
.copied()
.collect();
let selected: Vec<bool> = rows.iter().map(|item| active.contains(item)).collect();
Self {
rows,
@@ -164,8 +168,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,
@@ -203,16 +210,16 @@ impl ModalView for StatusPickerView {
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let mut lines: Vec<Line> = Vec::with_capacity(self.rows.len() + 2);
let visible_rows = inner.height.saturating_sub(2) as usize;
let row_start = visible_row_start(self.rows.len(), self.cursor, visible_rows);
let mut lines: Vec<Line> = Vec::with_capacity(visible_rows + 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(""));
let visible_rows = inner.height.saturating_sub(2) as usize;
let row_start = visible_row_start(self.rows.len(), self.cursor, visible_rows);
for (idx, item) in self
.rows
.iter()
@@ -294,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 {
@@ -316,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, .. }) => {
@@ -329,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();
@@ -347,7 +354,7 @@ mod tests {
#[test]
fn select_all_and_select_none_keys_work() {
let active: Vec<StatusItem> = 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, .. }) => {
@@ -367,7 +374,7 @@ mod tests {
#[test]
fn arrow_keys_wrap_cursor_at_edges() {
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::Up, KeyModifiers::NONE));
assert_eq!(view.cursor, StatusItem::all().len() - 1);
@@ -393,4 +400,14 @@ mod tests {
assert_eq!(text.width(), 40);
assert!(text.starts_with(" ▸ [ ] Last tool elapsed"));
}
#[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));
}
}
+80 -30
View File
@@ -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<Span<'static>>,
/// Account balance chip spans (empty when un fetched or zero). Rendered
/// in the left cluster right after cost.
pub balance: Vec<Span<'static>>,
/// Optional toast that, when present, replaces the left status line.
pub toast: Option<FooterToast>,
/// When `Some(frame_idx)`, the gap between the left status line and the
@@ -259,6 +262,7 @@ impl FooterProps {
reasoning_replay: Vec<Span<'static>>,
cache: Vec<Span<'static>>,
cost: Vec<Span<'static>>,
balance: Vec<Span<'static>>,
) -> 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 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: 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<Span<'static>> {
if max_width == 0 {
return Vec::new();
@@ -392,48 +394,81 @@ 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 · balance · cost · 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(balance_w)
+ balance_w
+ extra_sep(cost_w)
+ cost_w
+ extra_sep(status_w)
+ status_w;
if (show_balance || show_cost || show_status) && full_w <= max_width {
return self.build_status_line_spans(
mode_label,
model.to_string(),
show_balance.then(|| balance_text.clone()),
show_cost.then(|| cost_text.clone()),
show_status.then_some(status_label),
);
}
// Tier 2: mode · model · cost — drop status first.
if show_cost {
let with_cost_w = mode_w + sep_w + model_w + sep_w + cost_w;
if with_cost_w <= max_width {
// Tier 2: mode · model · balance · cost — drop status.
let with_cost_w = mode_w
+ sep_w
+ model_w
+ extra_sep(balance_w)
+ 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_balance.then(|| balance_text.clone()),
show_cost.then(|| cost_text.clone()),
None,
);
}
// 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 3: 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);
return self.build_status_line_spans(mode_label, model.to_string(), None, None, None);
}
// Tier 4: mode · <truncated model> — keep both labels visible by
// Tier 5: mode · <truncated model> — 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(),
@@ -465,22 +499,18 @@ impl FooterWidget {
&self,
mode_label: &'static str,
model_label: String,
balance: Option<String>,
cost: Option<String>,
status: Option<&str>,
) -> Vec<Span<'static>> {
let sep = " \u{00B7} ";
let mut spans: Vec<Span<'static>> = 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(
@@ -493,6 +523,18 @@ impl FooterWidget {
Style::default().fg(self.props.text_hint_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(cost_text) = cost {
if !spans.is_empty() {
spans.push(Span::styled(
@@ -717,6 +759,7 @@ mod tests {
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
);
// `from_app` reads the process-wide retry-status surface; pin
// `Idle` so footer tests don't pick up state set by retry-banner
@@ -829,6 +872,7 @@ mod tests {
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
);
assert!(props.state_label.starts_with("thinking"));
@@ -904,6 +948,7 @@ mod tests {
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
);
let widget = FooterWidget::new(props);
let area = ratatui::layout::Rect::new(0, 0, 60, 1);
@@ -1166,6 +1211,7 @@ mod tests {
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
)
}
@@ -1262,6 +1308,7 @@ mod tests {
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
vec![Span::styled(cost.to_string(), Style::default())],
Vec::<Span<'static>>::new(),
)
}
@@ -1282,6 +1329,7 @@ mod tests {
Vec::<Span<'static>>::new(),
long_cache,
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
);
let line = render_at_width(props, 40);
@@ -1314,6 +1362,7 @@ mod tests {
Vec::<Span<'static>>::new(),
cache,
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
);
let line = render_at_width(props, 80);
@@ -1378,6 +1427,7 @@ mod tests {
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
);
let widget = FooterWidget::new(props);