Merge remote-tracking branch 'origin/main' into codex/harvest-siliconflow
This commit is contained in:
+109
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(¤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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -18,7 +18,7 @@ use ratatui::{
|
||||
widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
|
||||
};
|
||||
|
||||
use crate::config::StatusItem;
|
||||
use crate::config::{ApiProvider, StatusItem};
|
||||
use crate::localization::truncate_to_width;
|
||||
use crate::palette;
|
||||
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
|
||||
@@ -43,8 +43,12 @@ pub struct StatusPickerView {
|
||||
|
||||
impl StatusPickerView {
|
||||
#[must_use]
|
||||
pub fn new(active: &[StatusItem]) -> Self {
|
||||
let rows: Vec<StatusItem> = StatusItem::all().to_vec();
|
||||
pub fn new(active: &[StatusItem], provider: ApiProvider) -> Self {
|
||||
let rows: Vec<StatusItem> = StatusItem::all()
|
||||
.iter()
|
||||
.filter(|item| item.is_available_for(provider))
|
||||
.copied()
|
||||
.collect();
|
||||
let selected: Vec<bool> = rows.iter().map(|item| active.contains(item)).collect();
|
||||
Self {
|
||||
rows,
|
||||
@@ -164,8 +168,11 @@ impl ModalView for StatusPickerView {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let popup_width = 64.min(area.width.saturating_sub(4)).max(40);
|
||||
// Two header lines + one row per StatusItem + one footer hint line.
|
||||
// When the full list is taller than the screen, cap the popup so it
|
||||
// stays on-screen and let the scroll offset handle overflow.
|
||||
let needed_height = (self.rows.len() as u16).saturating_add(4);
|
||||
let popup_height = needed_height.min(area.height.saturating_sub(4)).max(8);
|
||||
let max_fit = area.height.saturating_sub(4).max(8);
|
||||
let popup_height = needed_height.min(max_fit);
|
||||
|
||||
let popup_area = Rect {
|
||||
x: area.x + (area.width.saturating_sub(popup_width)) / 2,
|
||||
@@ -203,16 +210,16 @@ impl ModalView for StatusPickerView {
|
||||
let inner = block.inner(popup_area);
|
||||
block.render(popup_area, buf);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(self.rows.len() + 2);
|
||||
let visible_rows = inner.height.saturating_sub(2) as usize;
|
||||
let row_start = visible_row_start(self.rows.len(), self.cursor, visible_rows);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(visible_rows + 2);
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Pick the chips you want in the footer:",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
let visible_rows = inner.height.saturating_sub(2) as usize;
|
||||
let row_start = visible_row_start(self.rows.len(), self.cursor, visible_rows);
|
||||
|
||||
for (idx, item) in self
|
||||
.rows
|
||||
.iter()
|
||||
@@ -294,14 +301,14 @@ mod tests {
|
||||
#[test]
|
||||
fn opens_with_active_items_pre_selected() {
|
||||
let active = StatusItem::default_footer();
|
||||
let view = StatusPickerView::new(&active);
|
||||
let view = StatusPickerView::new(&active, ApiProvider::Deepseek);
|
||||
assert_eq!(view.current_selection(), active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_toggles_current_row_and_emits_live_preview() {
|
||||
let active = StatusItem::default_footer();
|
||||
let mut view = StatusPickerView::new(&active);
|
||||
let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek);
|
||||
// Cursor starts at row 0 = StatusItem::Mode (currently checked).
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
match action {
|
||||
@@ -316,7 +323,7 @@ mod tests {
|
||||
#[test]
|
||||
fn enter_emits_final_save() {
|
||||
let active = StatusItem::default_footer();
|
||||
let mut view = StatusPickerView::new(&active);
|
||||
let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek);
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match action {
|
||||
ViewAction::EmitAndClose(ViewEvent::StatusItemsUpdated { final_save, .. }) => {
|
||||
@@ -329,7 +336,7 @@ mod tests {
|
||||
#[test]
|
||||
fn esc_reverts_to_snapshot() {
|
||||
let active = StatusItem::default_footer();
|
||||
let mut view = StatusPickerView::new(&active);
|
||||
let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek);
|
||||
// Toggle a few items off so the working set diverges from snapshot.
|
||||
view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
view.move_down();
|
||||
@@ -347,7 +354,7 @@ mod tests {
|
||||
#[test]
|
||||
fn select_all_and_select_none_keys_work() {
|
||||
let active: Vec<StatusItem> = Vec::new();
|
||||
let mut view = StatusPickerView::new(&active);
|
||||
let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek);
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
|
||||
match action {
|
||||
ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, .. }) => {
|
||||
@@ -367,7 +374,7 @@ mod tests {
|
||||
#[test]
|
||||
fn arrow_keys_wrap_cursor_at_edges() {
|
||||
let active = StatusItem::default_footer();
|
||||
let mut view = StatusPickerView::new(&active);
|
||||
let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek);
|
||||
assert_eq!(view.cursor, 0);
|
||||
view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert_eq!(view.cursor, StatusItem::all().len() - 1);
|
||||
@@ -393,4 +400,14 @@ mod tests {
|
||||
assert_eq!(text.width(), 40);
|
||||
assert!(text.starts_with(" ▸ [ ] Last tool elapsed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balance_excluded_for_non_deepseek_provider() {
|
||||
let active = StatusItem::default_footer();
|
||||
let view = StatusPickerView::new(&active, ApiProvider::Openrouter);
|
||||
// Balance should not appear as a row for non-DeepSeek providers.
|
||||
assert!(!view.rows.contains(&StatusItem::Balance));
|
||||
// Mode should still be present.
|
||||
assert!(view.rows.contains(&StatusItem::Mode));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,9 @@ pub struct FooterProps {
|
||||
/// Rendered in the left cluster (after the model name) — cost is steady
|
||||
/// info, not a transient signal, so it lives with mode and model.
|
||||
pub cost: Vec<Span<'static>>,
|
||||
/// Account balance chip spans (empty when un fetched or zero). Rendered
|
||||
/// in the left cluster right after cost.
|
||||
pub balance: Vec<Span<'static>>,
|
||||
/// Optional toast that, when present, replaces the left status line.
|
||||
pub toast: Option<FooterToast>,
|
||||
/// When `Some(frame_idx)`, the gap between the left status line and the
|
||||
@@ -259,6 +262,7 @@ impl FooterProps {
|
||||
reasoning_replay: Vec<Span<'static>>,
|
||||
cache: Vec<Span<'static>>,
|
||||
cost: Vec<Span<'static>>,
|
||||
balance: Vec<Span<'static>>,
|
||||
) -> Self {
|
||||
let (mode_label, mode_color) = mode_style(app);
|
||||
// MCP chip (#502) — passive, derived from the user's existing
|
||||
@@ -293,6 +297,7 @@ impl FooterProps {
|
||||
mcp,
|
||||
worked,
|
||||
cost,
|
||||
balance,
|
||||
toast,
|
||||
working_strip_frame: None,
|
||||
retry: crate::retry_status::snapshot(),
|
||||
@@ -371,15 +376,12 @@ impl FooterWidget {
|
||||
///
|
||||
/// Priority order (highest to lowest — last to drop):
|
||||
/// 1. Mode label (always visible at any width; truncated only as a last resort)
|
||||
/// 2. Model name (always visible; then truncated mid-word once status & cost are gone)
|
||||
/// 3. Cost chip — drops second after status (steady-info still wants to be visible)
|
||||
/// 4. Status label (e.g. "working", "draft") — drops first when space is tight
|
||||
/// 2. Model name (always visible; then truncated mid-word once all hints are gone)
|
||||
/// 3. Balance chip — drops third (account balance is more actionable than session cost)
|
||||
/// 4. Cost chip — drops fourth
|
||||
/// 5. Status label (e.g. "working", "draft") — drops first when space is tight
|
||||
///
|
||||
/// At every width ≥40 cols the line never wraps mid-hint: the widget
|
||||
/// chooses one of (`mode · model · cost · status`, `mode · model · cost`,
|
||||
/// `mode · model`, `mode`) and renders that single line within
|
||||
/// `max_width`. Cost lives between model and status so the eye finds
|
||||
/// "what's this run going to cost me" without scanning past the wave.
|
||||
/// At every width ≥40 cols the line never wraps mid-hint.
|
||||
fn status_line_spans(&self, max_width: usize) -> Vec<Span<'static>> {
|
||||
if max_width == 0 {
|
||||
return Vec::new();
|
||||
@@ -392,48 +394,81 @@ impl FooterWidget {
|
||||
let status_label = self.props.state_label.as_str();
|
||||
let cost_text = spans_text(&self.props.cost);
|
||||
let show_cost = !cost_text.is_empty();
|
||||
let balance_text = spans_text(&self.props.balance);
|
||||
let show_balance = !balance_text.is_empty();
|
||||
|
||||
let mode_w = mode_label.width();
|
||||
let sep_w = sep.width();
|
||||
let model_w = UnicodeWidthStr::width(model);
|
||||
let status_w = status_label.width();
|
||||
let cost_w = cost_text.width();
|
||||
let status_w = if show_status { status_label.width() } else { 0 };
|
||||
let cost_w = if show_cost { cost_text.width() } else { 0 };
|
||||
let balance_w = if show_balance {
|
||||
balance_text.width()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Tier 1: mode · model · cost · status — everything fits.
|
||||
let extra_sep = |w: usize| if w > 0 { sep_w } else { 0 };
|
||||
|
||||
// Tier 1: mode · model · balance · cost · status
|
||||
let full_w = mode_w
|
||||
+ sep_w
|
||||
+ model_w
|
||||
+ if show_cost { sep_w + cost_w } else { 0 }
|
||||
+ if show_status { sep_w + status_w } else { 0 };
|
||||
if (show_cost || show_status) && full_w <= max_width {
|
||||
+ extra_sep(balance_w)
|
||||
+ balance_w
|
||||
+ extra_sep(cost_w)
|
||||
+ cost_w
|
||||
+ extra_sep(status_w)
|
||||
+ status_w;
|
||||
if (show_balance || show_cost || show_status) && full_w <= max_width {
|
||||
return self.build_status_line_spans(
|
||||
mode_label,
|
||||
model.to_string(),
|
||||
show_balance.then(|| balance_text.clone()),
|
||||
show_cost.then(|| cost_text.clone()),
|
||||
show_status.then_some(status_label),
|
||||
);
|
||||
}
|
||||
|
||||
// Tier 2: mode · model · cost — drop status first.
|
||||
if show_cost {
|
||||
let with_cost_w = mode_w + sep_w + model_w + sep_w + cost_w;
|
||||
if with_cost_w <= max_width {
|
||||
// Tier 2: mode · model · balance · cost — drop status.
|
||||
let with_cost_w = mode_w
|
||||
+ sep_w
|
||||
+ model_w
|
||||
+ extra_sep(balance_w)
|
||||
+ balance_w
|
||||
+ extra_sep(cost_w)
|
||||
+ cost_w;
|
||||
if (show_balance || show_cost) && with_cost_w <= max_width {
|
||||
return self.build_status_line_spans(
|
||||
mode_label,
|
||||
model.to_string(),
|
||||
show_balance.then(|| balance_text.clone()),
|
||||
show_cost.then(|| cost_text.clone()),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
// Tier 3: mode · model · balance — drop cost.
|
||||
if show_balance {
|
||||
let with_balance_w = mode_w + sep_w + model_w + sep_w + balance_w;
|
||||
if with_balance_w <= max_width {
|
||||
return self.build_status_line_spans(
|
||||
mode_label,
|
||||
model.to_string(),
|
||||
Some(cost_text.clone()),
|
||||
Some(balance_text.clone()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: mode · model — drop cost too.
|
||||
// Tier 4: mode · model — drop balance too.
|
||||
let mode_model_w = mode_w + sep_w + model_w;
|
||||
if mode_model_w <= max_width {
|
||||
return self.build_status_line_spans(mode_label, model.to_string(), None, None);
|
||||
return self.build_status_line_spans(mode_label, model.to_string(), None, None, None);
|
||||
}
|
||||
|
||||
// Tier 4: mode · <truncated model> — keep both labels visible by
|
||||
// Tier 5: mode · <truncated model> — keep both labels visible by
|
||||
// ellipsizing the model name. Only do this when there is enough room
|
||||
// for at least the ellipsis ("..."). Below that we drop to mode-only.
|
||||
let prefix_w = mode_w + sep_w;
|
||||
@@ -442,13 +477,12 @@ impl FooterWidget {
|
||||
if model_budget >= 4 {
|
||||
let truncated = truncate_to_width(model, model_budget);
|
||||
if !truncated.is_empty() {
|
||||
return self.build_status_line_spans(mode_label, truncated, None, None);
|
||||
return self.build_status_line_spans(mode_label, truncated, None, None, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 5: mode-only. If even the mode label cannot fit, truncate it
|
||||
// so the footer never wraps to a second row.
|
||||
// Tier 6: mode-only.
|
||||
if mode_w <= max_width {
|
||||
return vec![Span::styled(
|
||||
mode_label.to_string(),
|
||||
@@ -465,22 +499,18 @@ impl FooterWidget {
|
||||
&self,
|
||||
mode_label: &'static str,
|
||||
model_label: String,
|
||||
balance: Option<String>,
|
||||
cost: Option<String>,
|
||||
status: Option<&str>,
|
||||
) -> Vec<Span<'static>> {
|
||||
let sep = " \u{00B7} ";
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
// Skip the mode chip when the user has toggled it off via
|
||||
// `/statusline`. The widget no longer assumes mode is always
|
||||
// present so an opt-out user doesn't see a stray separator.
|
||||
if !mode_label.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
mode_label.to_string(),
|
||||
Style::default().fg(self.props.mode_color),
|
||||
));
|
||||
}
|
||||
// Same treatment for the model label — gating both keeps the bar
|
||||
// visually tidy when only auxiliary chips remain.
|
||||
if !model_label.is_empty() {
|
||||
if !spans.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
@@ -493,6 +523,18 @@ impl FooterWidget {
|
||||
Style::default().fg(self.props.text_hint_color),
|
||||
));
|
||||
}
|
||||
if let Some(balance_text) = balance {
|
||||
if !spans.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
sep.to_string(),
|
||||
Style::default().fg(self.props.text_dim_color),
|
||||
));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
balance_text,
|
||||
Style::default().fg(self.props.text_muted_color),
|
||||
));
|
||||
}
|
||||
if let Some(cost_text) = cost {
|
||||
if !spans.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
@@ -717,6 +759,7 @@ mod tests {
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
);
|
||||
// `from_app` reads the process-wide retry-status surface; pin
|
||||
// `Idle` so footer tests don't pick up state set by retry-banner
|
||||
@@ -829,6 +872,7 @@ mod tests {
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
);
|
||||
|
||||
assert!(props.state_label.starts_with("thinking"));
|
||||
@@ -904,6 +948,7 @@ mod tests {
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
);
|
||||
let widget = FooterWidget::new(props);
|
||||
let area = ratatui::layout::Rect::new(0, 0, 60, 1);
|
||||
@@ -1166,6 +1211,7 @@ mod tests {
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1262,6 +1308,7 @@ mod tests {
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
vec![Span::styled(cost.to_string(), Style::default())],
|
||||
Vec::<Span<'static>>::new(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1282,6 +1329,7 @@ mod tests {
|
||||
Vec::<Span<'static>>::new(),
|
||||
long_cache,
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
);
|
||||
|
||||
let line = render_at_width(props, 40);
|
||||
@@ -1314,6 +1362,7 @@ mod tests {
|
||||
Vec::<Span<'static>>::new(),
|
||||
cache,
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
);
|
||||
|
||||
let line = render_at_width(props, 80);
|
||||
@@ -1378,6 +1427,7 @@ mod tests {
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
);
|
||||
let widget = FooterWidget::new(props);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user