Merge pull request #2257 from HUQIANTAO/feat/deepseek-balance-carry
feat: add account balance status bar item (carries #1970)
This commit is contained in:
+109
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user