diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 354464ed..c51c5649 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -520,6 +520,28 @@ pub struct RetryConfig { pub exponential_base: Option, } +/// Deserialize `status_items` tolerantly: skip keys unknown to this build +/// instead of erroring with "unknown variant". This lets a dev build write +/// `"balance"` (or any future item) while the stable build still parses the +/// config file successfully. +fn deser_status_items<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let raw: Option> = Option::deserialize(deserializer)?; + Ok(raw.map(|strings| { + strings + .into_iter() + .filter_map(|s| { + StatusItem::from_key(&s).or_else(|| { + tracing::warn!("ignoring unknown status item {s:?} in config"); + None + }) + }) + .collect() + })) +} + /// UI configuration loaded from config files. #[derive(Debug, Clone, Deserialize, Default)] pub struct TuiConfig { @@ -534,6 +556,7 @@ pub struct TuiConfig { /// /// Edited interactively via `/statusline`; persisted to `tui.status_items` /// in `~/.deepseek/config.toml`. + #[serde(deserialize_with = "deser_status_items")] pub status_items: Option>, /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, @@ -846,6 +869,8 @@ pub enum StatusItem { RateLimit, /// Session token usage: input / cache-hit / output. Tokens, + /// DeepSeek account balance, refreshed once per turn completion. + Balance, } impl StatusItem { @@ -887,6 +912,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 { + 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, } } @@ -908,6 +959,7 @@ impl StatusItem { StatusItem::LastToolElapsed => "Last tool elapsed", StatusItem::RateLimit => "Rate-limit remaining", StatusItem::Tokens => "Session tokens", + StatusItem::Balance => "Account balance", } } @@ -930,6 +982,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", } } @@ -940,6 +993,7 @@ impl StatusItem { StatusItem::Mode, StatusItem::Model, StatusItem::Cost, + StatusItem::Balance, StatusItem::Status, StatusItem::Coherence, StatusItem::Agents, @@ -959,9 +1013,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. @@ -8157,4 +8228,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); + } } diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index 9cf8ecd2..33cd9bb6 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -184,6 +184,7 @@ pub enum UiThemeValue { TokyoNight, Dracula, GruvboxDark, + Matrix, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -279,6 +280,7 @@ pub enum StatusItemValue { LastToolElapsed, RateLimit, Tokens, + Balance, } pub fn parse_mode(arg: Option<&str>) -> Result { @@ -748,6 +750,7 @@ impl UiThemeValue { Self::TokyoNight => "tokyo-night", Self::Dracula => "dracula", Self::GruvboxDark => "gruvbox-dark", + Self::Matrix => "matrix", } } @@ -761,6 +764,7 @@ impl UiThemeValue { Some("tokyo-night") => Ok(Self::TokyoNight), Some("dracula") => Ok(Self::Dracula), Some("gruvbox-dark") => Ok(Self::GruvboxDark), + Some("matrix") => Ok(Self::Matrix), Some(other) => bail!("unsupported theme '{other}'"), None => bail!("invalid theme '{value}'"), } @@ -1002,6 +1006,7 @@ impl From for StatusItemValue { StatusItem::LastToolElapsed => Self::LastToolElapsed, StatusItem::RateLimit => Self::RateLimit, StatusItem::Tokens => Self::Tokens, + StatusItem::Balance => Self::Balance, } } } @@ -1023,6 +1028,7 @@ impl From for StatusItem { StatusItemValue::LastToolElapsed => Self::LastToolElapsed, StatusItemValue::RateLimit => Self::RateLimit, StatusItemValue::Tokens => Self::Tokens, + StatusItemValue::Balance => Self::Balance, } } } @@ -1191,7 +1197,8 @@ background_color = "#1A1B26" "catppuccin-mocha", "tokyo-night", "dracula", - "gruvbox-dark" + "gruvbox-dark", + "matrix" ]) ); } diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 04c5171b..9a3245e1 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -11,6 +11,25 @@ fn loop_guard_block_tool_result(message: String) -> ToolResult { ToolResult::error(message).with_metadata(json!({"loop_guard": "identical_tool_call"})) } +const MAX_APPROVAL_INTENT_SUMMARY_CHARS: usize = 2_000; + +fn approval_intent_summary(text: &str) -> Option { + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + + let mut chars = trimmed.chars(); + let mut summary = chars + .by_ref() + .take(MAX_APPROVAL_INTENT_SUMMARY_CHARS) + .collect::(); + if chars.next().is_some() { + summary.push_str("..."); + } + Some(summary) +} + impl Engine { pub(super) async fn handle_deepseek_turn( &mut self, @@ -1344,6 +1363,22 @@ impl Engine { } active_tool_names.extend(deferred_tools_hydrated_this_batch); + // --- Intent summary for write tools (#2381) --- + // When the model invokes write tools, extract its preceding text + // as an "intent summary" so the approval view can show *why* the + // change is being made, not just *what* will change. + let has_write_tools = plans.iter().any(|p| { + !p.read_only + && p.approval_required + && p.blocked_error.is_none() + && p.guard_result.is_none() + }); + let intent_summary: Option = if has_write_tools { + approval_intent_summary(¤t_text_visible) + } else { + None + }; + let plan_count = plans.len(); let batches = plan_tool_execution_batches(plans); let parallel_chunks = batches @@ -1702,6 +1737,11 @@ impl Engine { description: plan.approval_description.clone(), approval_key, approval_grouping_key, + intent_summary: if plan.read_only { + None + } else { + intent_summary.clone() + }, }) .await; @@ -2256,6 +2296,19 @@ mod tests { assert!(!should_hold_turn_for_subagents(0, 0)); } + #[test] + fn approval_intent_summary_trims_and_bounds_text() { + assert_eq!(approval_intent_summary(" "), None); + + let long_text = format!(" {} ", "x".repeat(MAX_APPROVAL_INTENT_SUMMARY_CHARS + 10)); + let summary = approval_intent_summary(&long_text).expect("summary"); + assert!(summary.ends_with("...")); + assert_eq!( + summary.chars().count(), + MAX_APPROVAL_INTENT_SUMMARY_CHARS + 3 + ); + } + /// Regression test for issue #1727 (P0, release-blocking). /// /// When a model (e.g. gpt-oss via ollama's harmony→OpenAI shim) returns diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index 0373dc04..11fbce02 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -257,6 +257,10 @@ pub enum Event { /// Lossy / arity-aware fingerprint, used to scope *approvals* so an /// "approve for session" covers later flag variants (v0.8.37). approval_grouping_key: String, + /// The model's explanation of intent before invoking write tools (#2381). + /// Displayed in the approval view so users understand *why* the change + /// is being made before reviewing *what* will change. + intent_summary: Option, }, /// Request user input for a tool call diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 645c758c..7e4cf941 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -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", diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index fb1c66e8..af05a494 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -81,6 +81,16 @@ pub const GRAYSCALE_TEXT_SOFT_RGB: (u8, u8, u8) = (220, 220, 220); // #DCDCDC pub const GRAYSCALE_BORDER_RGB: (u8, u8, u8) = (96, 96, 96); // #606060 pub const GRAYSCALE_SELECTION_RGB: (u8, u8, u8) = (62, 62, 62); // #3E3E3E +pub const MATRIX_SURFACE_RGB: (u8, u8, u8) = (0, 10, 0); // #000A00 +pub const MATRIX_ELEVATED_RGB: (u8, u8, u8) = (0, 51, 0); // #003300 +pub const MATRIX_SELECTION_RGB: (u8, u8, u8) = (0, 51, 0); // #003300 +pub const MATRIX_TEXT_BODY_RGB: (u8, u8, u8) = (136, 255, 136); // #88FF88 +pub const MATRIX_TEXT_MUTED_RGB: (u8, u8, u8) = (0, 85, 0); // #005500 +pub const MATRIX_TEXT_HINT_RGB: (u8, u8, u8) = (0, 102, 0); // #006600 +pub const MATRIX_TEXT_SOFT_RGB: (u8, u8, u8) = (221, 255, 221); // #DDFFDD +pub const MATRIX_TEXT_DIM_RGB: (u8, u8, u8) = (0, 68, 0); // #004400 +pub const MATRIX_BORDER_RGB: (u8, u8, u8) = (0, 204, 0); // #00CC00 + // New semantic colors pub const BORDER_COLOR_RGB: (u8, u8, u8) = WHALE_BORDER_RGB; // #2A4A7F @@ -925,6 +935,109 @@ pub const GRUVBOX_DARK_UI_THEME: UiTheme = UiTheme { tool_failed: Color::Rgb(0xfb, 0x49, 0x34), // red }; +pub const MATRIX_UI_THEME: UiTheme = UiTheme { + name: "matrix", + mode: PaletteMode::Dark, + surface_bg: Color::Rgb( + MATRIX_SURFACE_RGB.0, + MATRIX_SURFACE_RGB.1, + MATRIX_SURFACE_RGB.2, + ), + panel_bg: Color::Rgb( + MATRIX_SURFACE_RGB.0, + MATRIX_SURFACE_RGB.1, + MATRIX_SURFACE_RGB.2, + ), + elevated_bg: Color::Rgb( + MATRIX_ELEVATED_RGB.0, + MATRIX_ELEVATED_RGB.1, + MATRIX_ELEVATED_RGB.2, + ), + composer_bg: Color::Rgb( + MATRIX_SURFACE_RGB.0, + MATRIX_SURFACE_RGB.1, + MATRIX_SURFACE_RGB.2, + ), + selection_bg: Color::Rgb( + MATRIX_SELECTION_RGB.0, + MATRIX_SELECTION_RGB.1, + MATRIX_SELECTION_RGB.2, + ), + header_bg: Color::Rgb( + MATRIX_SURFACE_RGB.0, + MATRIX_SURFACE_RGB.1, + MATRIX_SURFACE_RGB.2, + ), + footer_bg: Color::Rgb( + MATRIX_SURFACE_RGB.0, + MATRIX_SURFACE_RGB.1, + MATRIX_SURFACE_RGB.2, + ), + text_dim: Color::Rgb( + MATRIX_TEXT_DIM_RGB.0, + MATRIX_TEXT_DIM_RGB.1, + MATRIX_TEXT_DIM_RGB.2, + ), + text_hint: Color::Rgb( + MATRIX_TEXT_HINT_RGB.0, + MATRIX_TEXT_HINT_RGB.1, + MATRIX_TEXT_HINT_RGB.2, + ), + text_muted: Color::Rgb( + MATRIX_TEXT_MUTED_RGB.0, + MATRIX_TEXT_MUTED_RGB.1, + MATRIX_TEXT_MUTED_RGB.2, + ), + text_body: Color::Rgb( + MATRIX_TEXT_BODY_RGB.0, + MATRIX_TEXT_BODY_RGB.1, + MATRIX_TEXT_BODY_RGB.2, + ), + text_soft: Color::Rgb( + MATRIX_TEXT_SOFT_RGB.0, + MATRIX_TEXT_SOFT_RGB.1, + MATRIX_TEXT_SOFT_RGB.2, + ), + border: Color::Rgb( + MATRIX_BORDER_RGB.0, + MATRIX_BORDER_RGB.1, + MATRIX_BORDER_RGB.2, + ), + accent_primary: Color::Rgb( + MATRIX_BORDER_RGB.0, + MATRIX_BORDER_RGB.1, + MATRIX_BORDER_RGB.2, + ), + accent_secondary: Color::Rgb(0, 153, 0), + accent_action: Color::Rgb(0x88, 0xff, 0x88), + error_fg: Color::Rgb(0xb4, 0, 0), + error_hover: Color::Rgb(0xe0, 0, 0), + error_surface: Color::Rgb(0x1a, 0x0d, 0x0d), + error_border: Color::Rgb(0xb4, 0, 0), + error_text: Color::Rgb(0xff, 0x44, 0x44), + warning: Color::Rgb(204, 204, 0), + success: Color::Rgb(0x88, 0xff, 0x88), + info: Color::Rgb(0, 204, 0), + mode_agent: Color::Rgb(0, 153, 0), + mode_yolo: Color::Rgb(255, 100, 100), + mode_plan: Color::Rgb(255, 170, 60), + mode_goal: Color::Rgb(170, 255, 170), + status_ready: Color::Rgb(0, 85, 0), + status_working: Color::Rgb( + MATRIX_TEXT_BODY_RGB.0, + MATRIX_TEXT_BODY_RGB.1, + MATRIX_TEXT_BODY_RGB.2, + ), + status_warning: Color::Rgb(204, 204, 0), + diff_added_fg: Color::Rgb(0x88, 0xff, 0x88), + diff_deleted_fg: Color::Rgb(0xb4, 0, 0), + diff_added_bg: Color::Rgb(0x0d, 0x1a, 0x0d), + diff_deleted_bg: Color::Rgb(0x1a, 0x0d, 0x0d), + tool_running: Color::Rgb(0x88, 0xff, 0x88), + tool_success: Color::Rgb(0, 102, 0), + tool_failed: Color::Rgb(0xb4, 0, 0), +}; + /// Stable identifiers for the named themes the user can select. `System` /// defers to `PaletteMode::detect()` (terminal-driven dark/light). Each /// dark/light id resolves to a single fixed `UiTheme`. @@ -939,6 +1052,7 @@ pub enum ThemeId { TokyoNight, Dracula, GruvboxDark, + Matrix, } impl ThemeId { @@ -957,6 +1071,7 @@ impl ThemeId { "tokyo-night" => Some(Self::TokyoNight), "dracula" => Some(Self::Dracula), "gruvbox-dark" => Some(Self::GruvboxDark), + "matrix" => Some(Self::Matrix), _ => None, } } @@ -975,6 +1090,7 @@ impl ThemeId { Self::TokyoNight => "tokyo-night", Self::Dracula => "dracula", Self::GruvboxDark => "gruvbox-dark", + Self::Matrix => "matrix", } } @@ -991,6 +1107,7 @@ impl ThemeId { Self::TokyoNight => "Tokyo Night", Self::Dracula => "Dracula", Self::GruvboxDark => "Gruvbox Dark", + Self::Matrix => "Matrix", } } @@ -1007,6 +1124,7 @@ impl ThemeId { Self::TokyoNight => "Deep blue/violet night palette", Self::Dracula => "Classic high-contrast purple", Self::GruvboxDark => "Vintage warm earth tones", + Self::Matrix => "The Matrix films inspired theme", } } @@ -1026,6 +1144,7 @@ impl ThemeId { Self::TokyoNight => TOKYO_NIGHT_UI_THEME, Self::Dracula => DRACULA_UI_THEME, Self::GruvboxDark => GRUVBOX_DARK_UI_THEME, + Self::Matrix => MATRIX_UI_THEME, } } } @@ -1041,6 +1160,7 @@ pub const SELECTABLE_THEMES: &[ThemeId] = &[ ThemeId::TokyoNight, ThemeId::Dracula, ThemeId::GruvboxDark, + ThemeId::Matrix, ]; impl UiTheme { @@ -1085,6 +1205,7 @@ pub fn normalize_theme_name(value: &str) -> Option<&'static str> { "tokyo-night" | "tokyonight" | "tokyo" => Some("tokyo-night"), "dracula" => Some("dracula"), "gruvbox-dark" | "gruvbox" => Some("gruvbox-dark"), + "matrix" | "hacker" => Some("matrix"), _ => None, } } @@ -1259,6 +1380,7 @@ pub const fn theme_remap_active(theme: ThemeId) -> bool { | ThemeId::TokyoNight | ThemeId::Dracula | ThemeId::GruvboxDark + | ThemeId::Matrix ) } @@ -1292,7 +1414,11 @@ pub fn adapt_fg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color { } else if color == TEXT_ACCENT || color == DEEPSEEK_SKY || color == ACCENT_TOOL_LIVE { ui.status_working } else if color == TEXT_REASONING || color == ACCENT_REASONING_LIVE { - ui.mode_plan + if theme == ThemeId::Matrix { + Color::Rgb(0x00, 0x55, 0x00) // #005500 + } else { + ui.mode_plan + } } else if color == ACCENT_TOOL_ISSUE { ui.mode_yolo } else if color == STATUS_WARNING { diff --git a/crates/tui/src/pricing.rs b/crates/tui/src/pricing.rs index eb78ed8b..2cb77451 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 { @@ -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); + } } diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index d86b147a..22ae0218 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -2683,6 +2683,7 @@ impl RuntimeThreadManager { id, tool_name, description, + intent_summary, .. } => { self.emit_event( @@ -2695,6 +2696,7 @@ impl RuntimeThreadManager { "approval_id": id, "tool_name": tool_name, "description": description, + "intent_summary": intent_summary, }), ) .await?; @@ -4217,6 +4219,7 @@ mod tests { tool_name: "exec_command".to_string(), description: "stale approval".to_string(), input: serde_json::json!({}), + intent_summary: None, }) .await?; @@ -4291,6 +4294,7 @@ mod tests { tool_name: "exec_command".to_string(), description: "external allow".to_string(), input: serde_json::json!({}), + intent_summary: Some("I will update the config file.".to_string()), }) .await?; @@ -4300,6 +4304,20 @@ mod tests { } assert_eq!(manager.pending_approvals_count(), 1); + let events = manager.events_since(&thread.id, None)?; + let approval_event = events + .iter() + .rev() + .find(|event| event.event == "approval.required") + .context("missing approval.required event")?; + assert_eq!( + approval_event + .payload + .get("intent_summary") + .and_then(Value::as_str), + Some("I will update the config file.") + ); + assert!(manager.deliver_external_approval( "tool_external_allow", ExternalApprovalDecision::Allow { remember: false }, @@ -4369,6 +4387,7 @@ mod tests { tool_name: "exec_command".to_string(), description: "external deny".to_string(), input: serde_json::json!({}), + intent_summary: None, }) .await?; @@ -4556,6 +4575,7 @@ mod tests { tool_name: "exec_command".to_string(), description: "remember=true".to_string(), input: serde_json::json!({}), + intent_summary: None, }) .await?; diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index f25c1ea3..8804ca1f 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -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>>, + /// Tracks whether the initial balance fetch has been attempted for this session. + pub balance_initiated: bool, + /// Timestamp of the last balance fetch, used to debounce rapid requests. + pub last_balance_fetch: Option, /// Current runtime turn id (if known). pub runtime_turn_id: Option, /// Current runtime turn status (if known). @@ -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, diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index 92e3208e..a8abe839 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -134,15 +134,31 @@ pub struct ApprovalRequest { /// Lossy / arity-aware fingerprint, used to scope *approvals* so an /// "approve for session" covers later flag variants (v0.8.37). pub approval_grouping_key: String, + /// The model's explanation of intent before invoking write tools (#2381). + /// Displayed in the approval view so users understand *why* the change + /// is being made before reviewing *what* will change. + pub intent_summary: Option, } impl ApprovalRequest { + #[cfg(test)] pub fn new( id: &str, tool_name: &str, description: &str, params: &Value, approval_key: &str, + ) -> Self { + Self::new_with_intent(id, tool_name, description, params, approval_key, None) + } + + pub fn new_with_intent( + id: &str, + tool_name: &str, + description: &str, + params: &Value, + approval_key: &str, + intent_summary: Option<&str>, ) -> Self { let category = get_tool_category(tool_name); let risk = classify_risk(tool_name, category, params); @@ -159,6 +175,14 @@ impl ApprovalRequest { params: params.clone(), approval_key: approval_key.to_string(), approval_grouping_key, + intent_summary: intent_summary.and_then(|summary| { + let summary = summary.trim(); + if summary.is_empty() { + None + } else { + Some(summary.to_string()) + } + }), } } diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index a3af6647..70b84e15 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; @@ -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> { spans } +pub(crate) fn footer_balance_spans(app: &App) -> Vec> { + let balance = match app.balance_cell.lock() { + Ok(guard) => guard, + Err(_) => return Vec::new(), + }; + let info = match balance.as_ref() { + Some(info) => info, + None => return Vec::new(), + }; + let total = match info.total_balance_f64() { + Some(total) if total > 0.0 => total, + _ => return Vec::new(), + }; + let currency = match info.currency.as_str() { + "CNY" | "cny" => "¥", + _ => "$", + }; + let 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/theme_picker.rs b/crates/tui/src/tui/theme_picker.rs index fca7254a..92b45769 100644 --- a/crates/tui/src/tui/theme_picker.rs +++ b/crates/tui/src/tui/theme_picker.rs @@ -90,23 +90,11 @@ impl ThemePickerView { } fn move_up(&mut self) { - let len = SELECTABLE_THEMES.len(); - if len == 0 { - self.selected = 0; - } else if self.selected == 0 { - self.selected = len - 1; - } else { - self.selected -= 1; - } + self.selected = (self.selected + SELECTABLE_THEMES.len() - 1) % SELECTABLE_THEMES.len(); } fn move_down(&mut self) { - let len = SELECTABLE_THEMES.len(); - if len == 0 { - self.selected = 0; - } else { - self.selected = (self.selected + 1) % len; - } + self.selected = (self.selected + 1) % SELECTABLE_THEMES.len(); } } @@ -323,12 +311,13 @@ mod tests { #[test] fn arrow_navigation_wraps_at_picker_edges() { let mut v = ThemePickerView::new("system".to_string()); + let last = SELECTABLE_THEMES.last().unwrap(); let action = v.handle_key(key(KeyCode::Up)); - assert_eq!(selected_name(&action), Some(ThemeId::GruvboxDark.name())); + assert_eq!(selected_name(&action), Some(last.name())); let action = v.handle_key(key(KeyCode::Down)); - assert_eq!(selected_name(&action), Some(ThemeId::System.name())); + assert_eq!(selected_name(&action), Some(SELECTABLE_THEMES[0].name())); } #[test] diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 8122bb00..f9df58d6 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -5,7 +5,7 @@ use std::io::{self, Stdout, Write}; use std::path::PathBuf; #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] use std::process::{Command, Stdio}; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; use anyhow::Result; @@ -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 { .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 { + 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 @@ -1966,6 +2058,7 @@ async fn run_event_loop( input, approval_key, approval_grouping_key, + intent_summary, } => { let session_approved = is_session_approved_for_tool(app, &tool_name, &approval_grouping_key); @@ -2017,6 +2110,7 @@ async fn run_event_loop( &description, &tool_input, &approval_key, + intent_summary.as_deref(), ); log_sensitive_event( "tool.approval.prompted", @@ -5015,6 +5109,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 +5235,7 @@ async fn apply_command_result( app.view_stack .push(crate::tui::views::status_picker::StatusPickerView::new( &app.status_items, + app.api_provider, )); } } @@ -6339,6 +6458,7 @@ fn toggle_live_transcript_overlay(app: &mut App) { app.needs_redraw = true; } +#[allow(clippy::too_many_arguments)] async fn handle_view_events( terminal: &mut AppTerminal, app: &mut App, @@ -6689,12 +6809,20 @@ fn push_approval_request_view( description: &str, tool_input: &serde_json::Value, approval_key: &str, + intent_summary: Option<&str>, ) { if tool_name == "apply_patch" { maybe_add_patch_preview(app, tool_input); } - let request = ApprovalRequest::new(id, tool_name, description, tool_input, approval_key); + let request = ApprovalRequest::new_with_intent( + id, + tool_name, + description, + tool_input, + approval_key, + intent_summary, + ); app.view_stack .push(ApprovalView::new_for_locale(request, app.ui_locale)); } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 55258e74..9a7d6781 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::{ @@ -5555,6 +5555,7 @@ fn approval_prompt_uses_event_input_after_message_complete_drain() { "Run cargo tests", &event_input, "approval-key", + None, ); let mut view = app.view_stack.pop().expect("approval view"); @@ -6054,13 +6055,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 +6163,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(); diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index 2d1a4362..6aa8a93e 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -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::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, @@ -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 = 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 = 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 = 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)); + } } diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index d049c0c8..91c16d95 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -73,6 +73,9 @@ pub struct FooterProps { /// Rendered in the left cluster (after the model name) — cost is steady /// info, not a transient signal, so it lives with mode and model. pub cost: Vec>, + /// Account balance chip spans (empty when un fetched or zero). Rendered + /// in the left cluster right after cost. + pub balance: Vec>, /// Optional toast that, when present, replaces the left status line. pub toast: Option, /// When `Some(frame_idx)`, the gap between the left status line and the @@ -259,6 +262,7 @@ impl FooterProps { reasoning_replay: Vec>, cache: Vec>, cost: Vec>, + balance: Vec>, ) -> Self { let (mode_label, mode_color) = mode_style(app); // MCP chip (#502) — passive, derived from the user's existing @@ -293,6 +297,7 @@ impl FooterProps { mcp, worked, cost, + balance, toast, working_strip_frame: None, retry: crate::retry_status::snapshot(), @@ -371,15 +376,12 @@ impl FooterWidget { /// /// Priority order (highest to lowest — last to drop): /// 1. Mode label (always visible at any width; truncated only as a last resort) - /// 2. Model name (always visible; then truncated mid-word once status & cost are gone) - /// 3. Cost chip — drops second after status (steady-info still wants to be visible) - /// 4. Status label (e.g. "working", "draft") — drops first when space is tight + /// 2. Model name (always visible; then truncated mid-word once 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> { 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 · — keep both labels visible by + // Tier 5: mode · — keep both labels visible by // ellipsizing the model name. Only do this when there is enough room // for at least the ellipsis ("..."). Below that we drop to mode-only. let prefix_w = mode_w + sep_w; @@ -442,13 +477,12 @@ impl FooterWidget { if model_budget >= 4 { let truncated = truncate_to_width(model, model_budget); if !truncated.is_empty() { - return self.build_status_line_spans(mode_label, truncated, None, None); + return self.build_status_line_spans(mode_label, truncated, None, None, None); } } } - // Tier 5: mode-only. If even the mode label cannot fit, truncate it - // so the footer never wraps to a second row. + // Tier 6: mode-only. if mode_w <= max_width { return vec![Span::styled( mode_label.to_string(), @@ -465,22 +499,18 @@ impl FooterWidget { &self, mode_label: &'static str, model_label: String, + balance: Option, cost: 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( @@ -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::>::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 @@ -829,6 +872,7 @@ mod tests { Vec::>::new(), Vec::>::new(), Vec::>::new(), + Vec::>::new(), ); assert!(props.state_label.starts_with("thinking")); @@ -904,6 +948,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); @@ -1166,6 +1211,7 @@ mod tests { Vec::>::new(), Vec::>::new(), Vec::>::new(), + Vec::>::new(), ) } @@ -1262,6 +1308,7 @@ mod tests { Vec::>::new(), Vec::>::new(), vec![Span::styled(cost.to_string(), Style::default())], + Vec::>::new(), ) } @@ -1282,6 +1329,7 @@ mod tests { Vec::>::new(), long_cache, Vec::>::new(), + Vec::>::new(), ); let line = render_at_width(props, 40); @@ -1314,6 +1362,7 @@ mod tests { Vec::>::new(), cache, Vec::>::new(), + Vec::>::new(), ); let line = render_at_width(props, 80); @@ -1378,6 +1427,7 @@ mod tests { Vec::>::new(), Vec::>::new(), Vec::>::new(), + Vec::>::new(), ); let widget = FooterWidget::new(props); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 7425a516..7011dad8 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1166,6 +1166,45 @@ impl Renderable for ApprovalWidget<'_> { ])); } + // Intent summary — the model's explanation of why this change is needed (#2381). + if let Some(ref summary) = self.request.intent_summary { + let max_width = card_area.width.saturating_sub(14) as usize; + if max_width > 0 { + lines.push(Line::from("")); + let intent_label = match locale { + Locale::ZhHans => "意图:", + _ => "Intent: ", + }; + let summary_lines: Vec<&str> = summary.lines().collect(); + for (i, sline) in summary_lines.iter().take(3).enumerate() { + let prefix = if i == 0 { intent_label } else { " " }; + let truncated = crate::utils::truncate_with_ellipsis(sline, max_width, "..."); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + prefix, + if i == 0 { + Style::default().fg(palette::TEXT_HINT) + } else { + Style::default() + }, + ), + Span::styled(truncated, Style::default().fg(palette::TEXT_SECONDARY)), + ])); + } + if summary_lines.len() > 3 { + let more = match locale { + Locale::ZhHans => format!(" … (还有 {} 行)", summary_lines.len() - 3), + _ => format!(" … (+{} lines)", summary_lines.len() - 3), + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(more, Style::default().fg(palette::TEXT_HINT)), + ])); + } + } + } + lines.push(Line::from("")); let params_str = self.request.params_display(); let params_width = card_area.width.saturating_sub(14) as usize; diff --git a/integrations/feishu-bridge/src/index.mjs b/integrations/feishu-bridge/src/index.mjs index 669da19c..69b1b968 100644 --- a/integrations/feishu-bridge/src/index.mjs +++ b/integrations/feishu-bridge/src/index.mjs @@ -16,6 +16,7 @@ import { parseList, parseApprovalDecisionArgs, parseTextContent, + preservedChatStateFields, splitMessage, stripGroupPrefix } from "./lib.mjs"; @@ -231,6 +232,9 @@ async function handleCommand(chatId, command) { case "approval": await decideApproval(chatId, action); return; + case "set_model": + await setChatModel(chatId, action.modelName); + return; case "prompt": await runPrompt(chatId, action.prompt); return; @@ -243,10 +247,14 @@ async function ensureThread(chatId, { forceNew = false } = {}) { const existing = await threadStore.getChat(chatId); if (existing?.threadId && !forceNew) return existing; + // Use per-chat model if set, fall back to bridge-level default. + // / 优先使用 per-chat 模型(/model 命令设置),否则用桥接级别的默认模型。 + const effectiveModel = existing?.model || config.model; + const thread = await runtimeJson("/v1/threads", { method: "POST", body: { - model: config.model, + model: effectiveModel, workspace: config.workspace, mode: config.mode, allow_shell: config.allowShell, @@ -259,6 +267,7 @@ async function ensureThread(chatId, { forceNew = false } = {}) { }); const state = { + ...preservedChatStateFields(existing), threadId: thread.id, lastSeq: 0, activeTurnId: null, @@ -274,6 +283,10 @@ async function runPrompt(chatId, prompt) { return; } const state = await ensureThread(chatId); + // Use per-chat model for this turn (may differ from the thread's + // creation model if the user ran /model after the thread was created). + // / 使用 per-chat 模型执行本轮对话(如果用户在创建线程后切换过模型)。 + const effectiveModel = state?.model || config.model; const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`); const activeBlock = activeTurnBlock(detail, state); if (activeBlock) { @@ -296,7 +309,7 @@ async function runPrompt(chatId, prompt) { body: { prompt, input_summary: prompt.slice(0, 200), - model: config.model, + model: effectiveModel, mode: config.mode, allow_shell: config.allowShell, trust_mode: config.trustMode, @@ -494,7 +507,9 @@ async function resumeThread(chatId, args) { return; } const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(threadId)}`); + const existing = await threadStore.getChat(chatId); await threadStore.setChat(chatId, { + ...preservedChatStateFields(existing), threadId, lastSeq: Number(detail.latest_seq || 0), activeTurnId: null, @@ -553,6 +568,24 @@ async function decideApproval(chatId, action) { await sendText(chatId, `Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`); } +async function setChatModel(chatId, modelName) { + // /model — set per-chat model; "default" or empty resets to bridge default. + // / /model "default" 或空参数 — 恢复桥接级别的默认模型。 + if (!modelName || modelName === "default") { + await threadStore.patchChat(chatId, { + model: null, + updatedAt: new Date().toISOString() + }); + await sendText(chatId, `Reset per-chat model. Using bridge default: ${config.model}`); + return; + } + await threadStore.patchChat(chatId, { + model: modelName, + updatedAt: new Date().toISOString() + }); + await sendText(chatId, `Per-chat model set to: ${modelName}`); +} + async function sendText(chatId, text) { // Try reply API first — keeps bot responses inside the same Feishu // thread/topic instead of spawning new standalone topics. @@ -572,22 +605,28 @@ async function sendText(chatId, text) { throw new Error("Lark SDK client does not expose im message create API"); } + let canReply = Boolean(replyMessage); for (const chunk of splitMessage(text, config.maxReplyChars)) { const body = { msg_type: "text", content: JSON.stringify({ text: chunk }) }; - if (replyMessage) { - await replyMessage({ - path: { message_id: replyToMessageId }, - data: body - }); - } else { - await createMessage({ - params: { receive_id_type: "chat_id" }, - data: { ...body, receive_id: chatId } - }); + if (canReply) { + try { + await replyMessage({ + path: { message_id: replyToMessageId }, + data: body + }); + continue; + } catch (error) { + canReply = false; + console.warn("Feishu reply API failed; falling back to message create", error); + } } + await createMessage({ + params: { receive_id_type: "chat_id" }, + data: { ...body, receive_id: chatId } + }); } } diff --git a/integrations/feishu-bridge/src/lib.mjs b/integrations/feishu-bridge/src/lib.mjs index b6dae5f2..217b6beb 100644 --- a/integrations/feishu-bridge/src/lib.mjs +++ b/integrations/feishu-bridge/src/lib.mjs @@ -147,6 +147,11 @@ export function commandAction(command) { return { kind: "interrupt" }; case "compact": return { kind: "compact" }; + case "model": + // /model — switch per-chat default model. + // Stored in thread store and used for future threads/turns. + // Pass "default" to reset to the bridge-level default. + return { kind: "set_model", modelName: command.args }; case "allow": return { kind: "approval", decision: "allow", ...parseApprovalDecisionArgs(command.args) }; case "deny": @@ -161,6 +166,17 @@ export function commandAction(command) { } } +export function preservedChatStateFields(state = {}) { + const preserved = {}; + if (Object.prototype.hasOwnProperty.call(state || {}, "model")) { + preserved.model = state.model || null; + } + if (state?.replyToMessageId) { + preserved.replyToMessageId = state.replyToMessageId; + } + return preserved; +} + export function splitMessage(text, maxChars = 3500) { const value = String(text || ""); const chars = Array.from(value); @@ -341,6 +357,7 @@ export function helpText() { "/threads - recent runtime threads", "/new - create a new thread for this chat", "/resume - bind this chat to an existing thread", + "/model - set or reset this chat's model", "/interrupt - interrupt the active turn", "/compact - compact the current thread", "/allow [remember] - approve a pending tool call", diff --git a/integrations/feishu-bridge/test/lib.test.mjs b/integrations/feishu-bridge/test/lib.test.mjs index ca242035..40e264d2 100644 --- a/integrations/feishu-bridge/test/lib.test.mjs +++ b/integrations/feishu-bridge/test/lib.test.mjs @@ -12,8 +12,10 @@ import { parseCommand, parseList, parseTextContent, + preservedChatStateFields, splitMessage, stripGroupPrefix, + helpText, validateBridgeConfig } from "../src/lib.mjs"; @@ -89,12 +91,36 @@ test("commandAction maps bridge commands and falls back to prompts", () => { kind: "resume", threadId: "thread-1" }); + assert.deepEqual(commandAction(parseCommand("/model deepseek-v4-pro")), { + kind: "set_model", + modelName: "deepseek-v4-pro" + }); assert.deepEqual(commandAction(parseCommand("/unknown value")), { kind: "prompt", prompt: "/unknown value" }); }); +test("helpText documents per-chat model switching", () => { + assert.match(helpText(), /\/model /); +}); + +test("preservedChatStateFields carries model across state replacement", () => { + assert.deepEqual( + preservedChatStateFields({ + threadId: "old-thread", + model: "deepseek-v4-flash", + replyToMessageId: "om_123", + activeTurnId: "turn-1" + }), + { + model: "deepseek-v4-flash", + replyToMessageId: "om_123" + } + ); + assert.deepEqual(preservedChatStateFields({ model: null }), { model: null }); +}); + test("parseApprovalDecisionArgs extracts remember flag", () => { assert.deepEqual(parseApprovalDecisionArgs("ap_123 remember"), { approvalId: "ap_123",