Merge remote-tracking branch 'origin/main' into codex/harvest-siliconflow

This commit is contained in:
Hunter B
2026-05-31 00:17:24 -07:00
20 changed files with 1020 additions and 81 deletions
+109 -1
View File
@@ -520,6 +520,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 {
@@ -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<Vec<StatusItem>>,
/// 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<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,
}
}
@@ -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);
}
}
+8 -1
View File
@@ -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<ConfigUiMode, String> {
@@ -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<StatusItem> for StatusItemValue {
StatusItem::LastToolElapsed => Self::LastToolElapsed,
StatusItem::RateLimit => Self::RateLimit,
StatusItem::Tokens => Self::Tokens,
StatusItem::Balance => Self::Balance,
}
}
}
@@ -1023,6 +1028,7 @@ impl From<StatusItemValue> 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"
])
);
}
+53
View File
@@ -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<String> {
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::<String>();
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<String> = if has_write_tools {
approval_intent_summary(&current_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
+4
View File
@@ -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<String>,
},
/// Request user input for a tool call
+9
View File
@@ -342,6 +342,7 @@ pub enum MessageId {
FooterAgentsPlural,
FooterPressCtrlCAgain,
FooterWorking,
FooterBalancePrefix,
HelpSectionActions,
HelpSectionClipboard,
HelpSectionEditing,
@@ -609,6 +610,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::FooterAgentsPlural,
MessageId::FooterPressCtrlCAgain,
MessageId::FooterWorking,
MessageId::FooterBalancePrefix,
MessageId::HelpSectionActions,
MessageId::HelpSectionClipboard,
MessageId::HelpSectionEditing,
@@ -1119,6 +1121,7 @@ fn english(id: MessageId) -> &'static str {
MessageId::FooterAgentsPlural => "{count} agents",
MessageId::FooterPressCtrlCAgain => "Press Ctrl+C again to quit",
MessageId::FooterWorking => "working",
MessageId::FooterBalancePrefix => "bal",
MessageId::HelpSectionActions => "Actions",
MessageId::HelpSectionClipboard => "Clipboard",
MessageId::HelpSectionEditing => "Input editing",
@@ -1541,6 +1544,7 @@ fn vietnamese(id: MessageId) -> Option<&'static str> {
MessageId::FooterAgentsPlural => "{count} tác nhân",
MessageId::FooterPressCtrlCAgain => "Nhấn Ctrl+C một lần nữa để thoát",
MessageId::FooterWorking => "đang xử lý",
MessageId::FooterBalancePrefix => "số dư",
MessageId::HelpSectionActions => "Hành động",
MessageId::HelpSectionClipboard => "Bộ nhớ tạm",
MessageId::HelpSectionEditing => "Chỉnh sửa đầu vào",
@@ -1762,6 +1766,7 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> {
MessageId::TranslationInProgress => "正在翻譯助理輸出...",
MessageId::TranslationComplete => "翻譯完成",
MessageId::TranslationFailed => "翻譯失敗",
MessageId::FooterBalancePrefix => "餘額",
other => chinese_simplified(other)?,
})
}
@@ -1958,6 +1963,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
MessageId::FooterAgentsPlural => "{count} エージェント",
MessageId::FooterPressCtrlCAgain => "もう一度 Ctrl+C で終了",
MessageId::FooterWorking => "処理中",
MessageId::FooterBalancePrefix => "残高",
MessageId::HelpSectionActions => "操作",
MessageId::HelpSectionClipboard => "クリップボード",
MessageId::HelpSectionEditing => "入力編集",
@@ -2320,6 +2326,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
MessageId::FooterAgentsPlural => "{count} 个子代理",
MessageId::FooterPressCtrlCAgain => "再次按 Ctrl+C 退出",
MessageId::FooterWorking => "工作中",
MessageId::FooterBalancePrefix => "余额",
MessageId::HelpSectionActions => "操作",
MessageId::HelpSectionClipboard => "剪贴板",
MessageId::HelpSectionEditing => "输入编辑",
@@ -2700,6 +2707,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
MessageId::FooterAgentsPlural => "{count} sub-agentes",
MessageId::FooterPressCtrlCAgain => "Pressione Ctrl+C novamente para sair",
MessageId::FooterWorking => "trabalhando",
MessageId::FooterBalancePrefix => "saldo",
MessageId::HelpSectionActions => "Ações",
MessageId::HelpSectionClipboard => "Área de transferência",
MessageId::HelpSectionEditing => "Edição de entrada",
@@ -3126,6 +3134,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> {
MessageId::FooterAgentsPlural => "{count} sub-agentes",
MessageId::FooterPressCtrlCAgain => "Presiona Ctrl+C de nuevo para salir",
MessageId::FooterWorking => "trabajando",
MessageId::FooterBalancePrefix => "saldo",
MessageId::HelpSectionActions => "Acciones",
MessageId::HelpSectionClipboard => "Portapapeles",
MessageId::HelpSectionEditing => "Edición de entrada",
+127 -1
View File
@@ -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 {
+106
View File
@@ -55,6 +55,39 @@ impl CostEstimate {
}
}
// === DeepSeek Account Balance ===
/// Response from `GET https://api.deepseek.com/user/balance`.
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct BalanceResponse {
#[allow(dead_code)]
pub is_available: bool,
pub balance_infos: Vec<BalanceInfo>,
}
/// Per-currency balance entry from the balance API.
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct BalanceInfo {
pub currency: String,
#[serde(default)]
pub total_balance: String,
#[serde(default)]
#[allow(dead_code)]
pub topped_up_balance: String,
#[serde(default)]
#[allow(dead_code)]
pub granted_balance: String,
}
impl BalanceInfo {
/// Parse the `total_balance` field as an f64. Returns `None` on parse
/// failure or empty string.
#[must_use]
pub fn total_balance_f64(&self) -> Option<f64> {
self.total_balance.parse::<f64>().ok()
}
}
/// Per-million-token pricing for a model.
#[derive(Debug, Clone, Copy)]
struct CurrencyPricing {
@@ -357,4 +390,77 @@ mod tests {
"¥0.1234"
);
}
// ── BalanceResponse / BalanceInfo ──────────────────────────────
#[test]
fn balance_response_deserializes_from_json() {
let json = r#"{
"is_available": true,
"balance_infos": [
{
"currency": "CNY",
"total_balance": "123.45",
"topped_up_balance": "100.00",
"granted_balance": "23.45"
}
]
}"#;
let resp: BalanceResponse = serde_json::from_str(json).expect("valid JSON");
assert!(resp.is_available);
assert_eq!(resp.balance_infos.len(), 1);
let info = &resp.balance_infos[0];
assert_eq!(info.currency, "CNY");
assert_eq!(info.total_balance, "123.45");
assert_eq!(info.topped_up_balance, "100.00");
assert_eq!(info.granted_balance, "23.45");
}
#[test]
fn balance_response_defaults_empty_balance_infos_when_unavailable() {
let json = r#"{"is_available": false, "balance_infos": []}"#;
let resp: BalanceResponse = serde_json::from_str(json).expect("valid JSON");
assert!(!resp.is_available);
assert!(resp.balance_infos.is_empty());
}
#[test]
fn balance_response_empty_list_is_valid() {
let json = r#"{"is_available": true, "balance_infos": []}"#;
let resp: BalanceResponse = serde_json::from_str(json).expect("valid JSON");
assert!(resp.is_available);
assert!(resp.balance_infos.is_empty());
}
// ── BalanceInfo::total_balance_f64 ─────────────────────────────
#[test]
fn total_balance_f64_parses_decimal() {
let info = BalanceInfo {
currency: "CNY".into(),
total_balance: "123.45".into(),
..Default::default()
};
assert_eq!(info.total_balance_f64(), Some(123.45));
}
#[test]
fn total_balance_f64_returns_none_on_empty() {
let info = BalanceInfo {
currency: "USD".into(),
total_balance: String::new(),
..Default::default()
};
assert_eq!(info.total_balance_f64(), None);
}
#[test]
fn total_balance_f64_returns_none_on_invalid() {
let info = BalanceInfo {
currency: "USD".into(),
total_balance: "not-a-number".into(),
..Default::default()
};
assert_eq!(info.total_balance_f64(), None);
}
}
+20
View File
@@ -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?;
+10
View File
@@ -1433,6 +1433,13 @@ pub struct App {
/// Incremented on `TurnComplete` from the elapsed time of the
/// just-finished turn. Resets per launch.
pub cumulative_turn_duration: std::time::Duration,
/// DeepSeek account balance, refreshed once per turn completion.
/// Shared cell updated by background fetch tasks; read lock in the UI thread.
pub balance_cell: std::sync::Arc<std::sync::Mutex<Option<crate::pricing::BalanceInfo>>>,
/// Tracks whether the initial balance fetch has been attempted for this session.
pub balance_initiated: bool,
/// Timestamp of the last balance fetch, used to debounce rapid requests.
pub last_balance_fetch: Option<std::time::Instant>,
/// Current runtime turn id (if known).
pub runtime_turn_id: Option<String>,
/// Current runtime turn status (if known).
@@ -2029,6 +2036,9 @@ impl App {
submit_pending_steers_after_interrupt: false,
turn_started_at: None,
cumulative_turn_duration: std::time::Duration::ZERO,
balance_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
balance_initiated: false,
last_balance_fetch: None,
runtime_turn_id: None,
runtime_turn_status: None,
dispatch_started_at: None,
+24
View File
@@ -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<String>,
}
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())
}
}),
}
}
+38
View File
@@ -4,6 +4,7 @@ use std::time::Instant;
use unicode_width::UnicodeWidthStr;
use crate::core::coherence::CoherenceState;
use crate::localization::MessageId;
use crate::palette;
use crate::tools::subagent::SubAgentStatus;
use crate::tui::app::App;
@@ -461,6 +462,11 @@ pub(crate) fn render_footer_from(
} else {
Vec::new()
};
let balance = if has(S::Balance) {
footer_balance_spans(app)
} else {
Vec::new()
};
// Build the props; `Mode` and `Model` toggles modulate downstream by
// blanking the rendered text rather than restructuring the widget — the
@@ -475,6 +481,7 @@ pub(crate) fn render_footer_from(
reasoning_replay,
cache,
cost,
balance,
);
if !has(S::Mode) {
props.mode_label = "";
@@ -587,6 +594,37 @@ pub(crate) fn footer_cost_spans(app: &App) -> Vec<Span<'static>> {
spans
}
pub(crate) fn footer_balance_spans(app: &App) -> Vec<Span<'static>> {
let balance = match app.balance_cell.lock() {
Ok(guard) => guard,
Err(_) => return Vec::new(),
};
let info = match balance.as_ref() {
Some(info) => info,
None => return Vec::new(),
};
let total = match info.total_balance_f64() {
Some(total) if total > 0.0 => total,
_ => return Vec::new(),
};
let currency = match info.currency.as_str() {
"CNY" | "cny" => "¥",
_ => "$",
};
let prefix = app.tr(MessageId::FooterBalancePrefix);
let label = if total >= 1000.0 {
format!("{prefix} {currency}{total:.0}")
} else if total >= 10.0 {
format!("{prefix} {currency}{total:.1}")
} else {
format!("{prefix} {currency}{total:.2}")
};
vec![Span::styled(
label,
Style::default().fg(palette::TEXT_MUTED),
)]
}
pub(crate) fn should_show_footer_cost(displayed_cost: f64) -> bool {
displayed_cost.is_finite() && displayed_cost > 0.0
}
+5 -16
View File
@@ -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]
+131 -3
View File
@@ -5,7 +5,7 @@ use std::io::{self, Stdout, Write};
use std::path::PathBuf;
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
use anyhow::Result;
@@ -41,7 +41,7 @@ use crate::client::{DeepSeekClient, build_cache_warmup_request};
use crate::commands;
use crate::compaction::estimate_input_tokens_conservative;
use crate::config::{
ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL, ProviderConfig, ProvidersConfig,
ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL, ProviderConfig, ProvidersConfig, StatusItem,
save_provider_auth_mode_for,
};
use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEvent};
@@ -906,6 +906,55 @@ fn active_rlm_task_entries(app: &App) -> Vec<TaskPanelEntry> {
.collect()
}
/// Minimum interval between balance API fetches to avoid flooding.
const BALANCE_FETCH_COOLDOWN: Duration = Duration::from_secs(60);
/// Shared `reqwest::Client` for balance fetches so connection pools are
/// reused across successive background polls.
static BALANCE_CLIENT: LazyLock<::reqwest::Client> = LazyLock::new(|| {
::reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.unwrap_or_default()
});
/// Fetch the DeepSeek account balance from the balance API.
///
/// Returns `None` on any error (network, auth, parse) — callers should treat
/// a `None` return as "balance unknown" and keep the previous value.
async fn fetch_deepseek_balance(
api_key: &str,
base_url: &str,
) -> Option<crate::pricing::BalanceInfo> {
let url = format!("{}/user/balance", base_url.trim_end_matches('/'));
let client = &*BALANCE_CLIENT;
let response = client
.get(url)
.header("Authorization", format!("Bearer {api_key}"))
.send()
.await
.ok()?;
if !response.status().is_success() {
tracing::debug!(
"balance API returned {}: {}",
response.status().as_u16(),
response.text().await.unwrap_or_default()
);
return None;
}
let body: crate::pricing::BalanceResponse = response.json().await.ok()?;
// Return the first balance entry (typically the user's primary currency).
body.balance_infos.into_iter().next()
}
fn should_fetch_deepseek_balance(app: &App) -> bool {
app.status_items.contains(&StatusItem::Balance)
&& matches!(
app.api_provider,
ApiProvider::Deepseek | ApiProvider::DeepseekCN
)
}
#[allow(clippy::too_many_lines)]
async fn run_event_loop(
terminal: &mut AppTerminal,
@@ -972,6 +1021,26 @@ async fn run_event_loop(
})
});
// Fire a one-shot initial balance fetch for DeepSeek providers
// so the footer chip shows balance on the first frame without
// waiting for a turn to complete.
if !app.balance_initiated && should_fetch_deepseek_balance(app) {
let cell = app.balance_cell.clone();
let api_key = config.deepseek_api_key().unwrap_or_default();
let base_url = config.deepseek_base_url();
if !api_key.is_empty() {
app.last_balance_fetch = Some(Instant::now());
tokio::spawn(async move {
if let Some(info) = fetch_deepseek_balance(&api_key, &base_url).await
&& let Ok(mut guard) = cell.lock()
{
*guard = Some(info);
}
});
}
app.balance_initiated = true;
}
loop {
// Drain the version-check handle once; re-assign None so we
// don't poll it again.
@@ -1665,6 +1734,29 @@ async fn run_event_loop(
}
persistence_actor::persist(PersistRequest::ClearCheckpoint);
// Refresh DeepSeek account balance after each completed
// turn so the footer balance chip stays current without
// adding latency to any request path.
let balance_cooldown_expired = app
.last_balance_fetch
.map_or(true, |t| t.elapsed() >= BALANCE_FETCH_COOLDOWN);
if balance_cooldown_expired && should_fetch_deepseek_balance(app) {
let cell = app.balance_cell.clone();
let api_key = config.deepseek_api_key().unwrap_or_default();
let base_url = config.deepseek_base_url();
if !api_key.is_empty() {
app.last_balance_fetch = Some(Instant::now());
tokio::spawn(async move {
if let Some(info) =
fetch_deepseek_balance(&api_key, &base_url).await
&& let Ok(mut guard) = cell.lock()
{
*guard = Some(info);
}
});
}
}
if app.mode == AppMode::Plan
&& app.plan_tool_used_in_turn
&& !app.plan_prompt_pending
@@ -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));
}
+132 -3
View File
@@ -9,8 +9,8 @@ use crate::tui::file_mention::{
try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,
};
use crate::tui::footer_ui::{
active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,
footer_state_label, footer_status_line_spans, format_context_budget,
active_tool_status_label, footer_auxiliary_spans, footer_balance_spans, footer_cache_spans,
footer_coherence_spans, footer_state_label, footer_status_line_spans, format_context_budget,
format_token_count_compact, friendly_subagent_progress, render_footer_from,
};
use crate::tui::history::{
@@ -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();
+31 -14
View File
@@ -18,7 +18,7 @@ use ratatui::{
widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
};
use crate::config::StatusItem;
use crate::config::{ApiProvider, StatusItem};
use crate::localization::truncate_to_width;
use crate::palette;
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
@@ -43,8 +43,12 @@ pub struct StatusPickerView {
impl StatusPickerView {
#[must_use]
pub fn new(active: &[StatusItem]) -> Self {
let rows: Vec<StatusItem> = StatusItem::all().to_vec();
pub fn new(active: &[StatusItem], provider: ApiProvider) -> Self {
let rows: Vec<StatusItem> = StatusItem::all()
.iter()
.filter(|item| item.is_available_for(provider))
.copied()
.collect();
let selected: Vec<bool> = rows.iter().map(|item| active.contains(item)).collect();
Self {
rows,
@@ -164,8 +168,11 @@ impl ModalView for StatusPickerView {
fn render(&self, area: Rect, buf: &mut Buffer) {
let popup_width = 64.min(area.width.saturating_sub(4)).max(40);
// Two header lines + one row per StatusItem + one footer hint line.
// When the full list is taller than the screen, cap the popup so it
// stays on-screen and let the scroll offset handle overflow.
let needed_height = (self.rows.len() as u16).saturating_add(4);
let popup_height = needed_height.min(area.height.saturating_sub(4)).max(8);
let max_fit = area.height.saturating_sub(4).max(8);
let popup_height = needed_height.min(max_fit);
let popup_area = Rect {
x: area.x + (area.width.saturating_sub(popup_width)) / 2,
@@ -203,16 +210,16 @@ impl ModalView for StatusPickerView {
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let mut lines: Vec<Line> = Vec::with_capacity(self.rows.len() + 2);
let visible_rows = inner.height.saturating_sub(2) as usize;
let row_start = visible_row_start(self.rows.len(), self.cursor, visible_rows);
let mut lines: Vec<Line> = Vec::with_capacity(visible_rows + 2);
lines.push(Line::from(Span::styled(
"Pick the chips you want in the footer:",
Style::default().fg(palette::TEXT_MUTED),
)));
lines.push(Line::from(""));
let visible_rows = inner.height.saturating_sub(2) as usize;
let row_start = visible_row_start(self.rows.len(), self.cursor, visible_rows);
for (idx, item) in self
.rows
.iter()
@@ -294,14 +301,14 @@ mod tests {
#[test]
fn opens_with_active_items_pre_selected() {
let active = StatusItem::default_footer();
let view = StatusPickerView::new(&active);
let view = StatusPickerView::new(&active, ApiProvider::Deepseek);
assert_eq!(view.current_selection(), active);
}
#[test]
fn space_toggles_current_row_and_emits_live_preview() {
let active = StatusItem::default_footer();
let mut view = StatusPickerView::new(&active);
let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek);
// Cursor starts at row 0 = StatusItem::Mode (currently checked).
let action = view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
match action {
@@ -316,7 +323,7 @@ mod tests {
#[test]
fn enter_emits_final_save() {
let active = StatusItem::default_footer();
let mut view = StatusPickerView::new(&active);
let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek);
let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match action {
ViewAction::EmitAndClose(ViewEvent::StatusItemsUpdated { final_save, .. }) => {
@@ -329,7 +336,7 @@ mod tests {
#[test]
fn esc_reverts_to_snapshot() {
let active = StatusItem::default_footer();
let mut view = StatusPickerView::new(&active);
let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek);
// Toggle a few items off so the working set diverges from snapshot.
view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
view.move_down();
@@ -347,7 +354,7 @@ mod tests {
#[test]
fn select_all_and_select_none_keys_work() {
let active: Vec<StatusItem> = Vec::new();
let mut view = StatusPickerView::new(&active);
let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek);
let action = view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
match action {
ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, .. }) => {
@@ -367,7 +374,7 @@ mod tests {
#[test]
fn arrow_keys_wrap_cursor_at_edges() {
let active = StatusItem::default_footer();
let mut view = StatusPickerView::new(&active);
let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek);
assert_eq!(view.cursor, 0);
view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(view.cursor, StatusItem::all().len() - 1);
@@ -393,4 +400,14 @@ mod tests {
assert_eq!(text.width(), 40);
assert!(text.starts_with(" ▸ [ ] Last tool elapsed"));
}
#[test]
fn balance_excluded_for_non_deepseek_provider() {
let active = StatusItem::default_footer();
let view = StatusPickerView::new(&active, ApiProvider::Openrouter);
// Balance should not appear as a row for non-DeepSeek providers.
assert!(!view.rows.contains(&StatusItem::Balance));
// Mode should still be present.
assert!(view.rows.contains(&StatusItem::Mode));
}
}
+80 -30
View File
@@ -73,6 +73,9 @@ pub struct FooterProps {
/// Rendered in the left cluster (after the model name) — cost is steady
/// info, not a transient signal, so it lives with mode and model.
pub cost: Vec<Span<'static>>,
/// Account balance chip spans (empty when un fetched or zero). Rendered
/// in the left cluster right after cost.
pub balance: Vec<Span<'static>>,
/// Optional toast that, when present, replaces the left status line.
pub toast: Option<FooterToast>,
/// When `Some(frame_idx)`, the gap between the left status line and the
@@ -259,6 +262,7 @@ impl FooterProps {
reasoning_replay: Vec<Span<'static>>,
cache: Vec<Span<'static>>,
cost: Vec<Span<'static>>,
balance: Vec<Span<'static>>,
) -> Self {
let (mode_label, mode_color) = mode_style(app);
// MCP chip (#502) — passive, derived from the user's existing
@@ -293,6 +297,7 @@ impl FooterProps {
mcp,
worked,
cost,
balance,
toast,
working_strip_frame: None,
retry: crate::retry_status::snapshot(),
@@ -371,15 +376,12 @@ impl FooterWidget {
///
/// Priority order (highest to lowest — last to drop):
/// 1. Mode label (always visible at any width; truncated only as a last resort)
/// 2. Model name (always visible; then truncated mid-word once status & cost are gone)
/// 3. Cost chip — drops second after status (steady-info still wants to be visible)
/// 4. Status label (e.g. "working", "draft") — drops first when space is tight
/// 2. Model name (always visible; then truncated mid-word once all hints are gone)
/// 3. Balance chip — drops third (account balance is more actionable than session cost)
/// 4. Cost chip — drops fourth
/// 5. Status label (e.g. "working", "draft") — drops first when space is tight
///
/// At every width ≥40 cols the line never wraps mid-hint: the widget
/// chooses one of (`mode · model · cost · status`, `mode · model · cost`,
/// `mode · model`, `mode`) and renders that single line within
/// `max_width`. Cost lives between model and status so the eye finds
/// "what's this run going to cost me" without scanning past the wave.
/// At every width ≥40 cols the line never wraps mid-hint.
fn status_line_spans(&self, max_width: usize) -> Vec<Span<'static>> {
if max_width == 0 {
return Vec::new();
@@ -392,48 +394,81 @@ impl FooterWidget {
let status_label = self.props.state_label.as_str();
let cost_text = spans_text(&self.props.cost);
let show_cost = !cost_text.is_empty();
let balance_text = spans_text(&self.props.balance);
let show_balance = !balance_text.is_empty();
let mode_w = mode_label.width();
let sep_w = sep.width();
let model_w = UnicodeWidthStr::width(model);
let status_w = status_label.width();
let cost_w = cost_text.width();
let status_w = if show_status { status_label.width() } else { 0 };
let cost_w = if show_cost { cost_text.width() } else { 0 };
let balance_w = if show_balance {
balance_text.width()
} else {
0
};
// Tier 1: mode · model · cost · status — everything fits.
let extra_sep = |w: usize| if w > 0 { sep_w } else { 0 };
// Tier 1: mode · model · balance · cost · status
let full_w = mode_w
+ sep_w
+ model_w
+ if show_cost { sep_w + cost_w } else { 0 }
+ if show_status { sep_w + status_w } else { 0 };
if (show_cost || show_status) && full_w <= max_width {
+ extra_sep(balance_w)
+ balance_w
+ extra_sep(cost_w)
+ cost_w
+ extra_sep(status_w)
+ status_w;
if (show_balance || show_cost || show_status) && full_w <= max_width {
return self.build_status_line_spans(
mode_label,
model.to_string(),
show_balance.then(|| balance_text.clone()),
show_cost.then(|| cost_text.clone()),
show_status.then_some(status_label),
);
}
// Tier 2: mode · model · cost — drop status first.
if show_cost {
let with_cost_w = mode_w + sep_w + model_w + sep_w + cost_w;
if with_cost_w <= max_width {
// Tier 2: mode · model · balance · cost — drop status.
let with_cost_w = mode_w
+ sep_w
+ model_w
+ extra_sep(balance_w)
+ balance_w
+ extra_sep(cost_w)
+ cost_w;
if (show_balance || show_cost) && with_cost_w <= max_width {
return self.build_status_line_spans(
mode_label,
model.to_string(),
show_balance.then(|| balance_text.clone()),
show_cost.then(|| cost_text.clone()),
None,
);
}
// Tier 3: mode · model · balance — drop cost.
if show_balance {
let with_balance_w = mode_w + sep_w + model_w + sep_w + balance_w;
if with_balance_w <= max_width {
return self.build_status_line_spans(
mode_label,
model.to_string(),
Some(cost_text.clone()),
Some(balance_text.clone()),
None,
None,
);
}
}
// Tier 3: mode · model — drop cost too.
// Tier 4: mode · model — drop balance too.
let mode_model_w = mode_w + sep_w + model_w;
if mode_model_w <= max_width {
return self.build_status_line_spans(mode_label, model.to_string(), None, None);
return self.build_status_line_spans(mode_label, model.to_string(), None, None, None);
}
// Tier 4: mode · <truncated model> — keep both labels visible by
// Tier 5: mode · <truncated model> — keep both labels visible by
// ellipsizing the model name. Only do this when there is enough room
// for at least the ellipsis ("..."). Below that we drop to mode-only.
let prefix_w = mode_w + sep_w;
@@ -442,13 +477,12 @@ impl FooterWidget {
if model_budget >= 4 {
let truncated = truncate_to_width(model, model_budget);
if !truncated.is_empty() {
return self.build_status_line_spans(mode_label, truncated, None, None);
return self.build_status_line_spans(mode_label, truncated, None, None, None);
}
}
}
// Tier 5: mode-only. If even the mode label cannot fit, truncate it
// so the footer never wraps to a second row.
// Tier 6: mode-only.
if mode_w <= max_width {
return vec![Span::styled(
mode_label.to_string(),
@@ -465,22 +499,18 @@ impl FooterWidget {
&self,
mode_label: &'static str,
model_label: String,
balance: Option<String>,
cost: Option<String>,
status: Option<&str>,
) -> Vec<Span<'static>> {
let sep = " \u{00B7} ";
let mut spans: Vec<Span<'static>> = Vec::new();
// Skip the mode chip when the user has toggled it off via
// `/statusline`. The widget no longer assumes mode is always
// present so an opt-out user doesn't see a stray separator.
if !mode_label.is_empty() {
spans.push(Span::styled(
mode_label.to_string(),
Style::default().fg(self.props.mode_color),
));
}
// Same treatment for the model label — gating both keeps the bar
// visually tidy when only auxiliary chips remain.
if !model_label.is_empty() {
if !spans.is_empty() {
spans.push(Span::styled(
@@ -493,6 +523,18 @@ impl FooterWidget {
Style::default().fg(self.props.text_hint_color),
));
}
if let Some(balance_text) = balance {
if !spans.is_empty() {
spans.push(Span::styled(
sep.to_string(),
Style::default().fg(self.props.text_dim_color),
));
}
spans.push(Span::styled(
balance_text,
Style::default().fg(self.props.text_muted_color),
));
}
if let Some(cost_text) = cost {
if !spans.is_empty() {
spans.push(Span::styled(
@@ -717,6 +759,7 @@ mod tests {
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
);
// `from_app` reads the process-wide retry-status surface; pin
// `Idle` so footer tests don't pick up state set by retry-banner
@@ -829,6 +872,7 @@ mod tests {
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
);
assert!(props.state_label.starts_with("thinking"));
@@ -904,6 +948,7 @@ mod tests {
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
);
let widget = FooterWidget::new(props);
let area = ratatui::layout::Rect::new(0, 0, 60, 1);
@@ -1166,6 +1211,7 @@ mod tests {
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
)
}
@@ -1262,6 +1308,7 @@ mod tests {
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
vec![Span::styled(cost.to_string(), Style::default())],
Vec::<Span<'static>>::new(),
)
}
@@ -1282,6 +1329,7 @@ mod tests {
Vec::<Span<'static>>::new(),
long_cache,
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
);
let line = render_at_width(props, 40);
@@ -1314,6 +1362,7 @@ mod tests {
Vec::<Span<'static>>::new(),
cache,
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
);
let line = render_at_width(props, 80);
@@ -1378,6 +1427,7 @@ mod tests {
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
);
let widget = FooterWidget::new(props);
+39
View File
@@ -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;
+51 -12
View File
@@ -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 <name> — 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 }
});
}
}
+17
View File
@@ -147,6 +147,11 @@ export function commandAction(command) {
return { kind: "interrupt" };
case "compact":
return { kind: "compact" };
case "model":
// /model <model_name> — 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 <thread_id> - bind this chat to an existing thread",
"/model <name|default> - set or reset this chat's model",
"/interrupt - interrupt the active turn",
"/compact - compact the current thread",
"/allow <approval_id> [remember] - approve a pending tool call",
@@ -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 <name\|default>/);
});
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",