From c0b82b6ec0253d847ba02a5a529b13ba40666d5d Mon Sep 17 00:00:00 2001 From: yuanchenglu Date: Tue, 26 May 2026 09:45:46 +0800 Subject: [PATCH 01/22] fix(feishu): reply inside thread/topic instead of creating standalone topics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When running in a Feishu thread-enabled group (话题群), every bot response — status messages, approval prompts, streaming progress, turn results — was sent via the Lark SDK's `create` API which spawns a new standalone topic. The user sees a cluttered group with orphan topics for each intermediate bot message. Root cause: `sendText()` only called `client.im.message.create()` with a bare `chat_id`, never passing any reply context. The Feishu `reply` API was completely unused. Fix (two changes, one site each): 1. **lib.mjs — incomingIdentity()**: expose `parentId`, `rootId`, `threadId` from the raw Feishu message event so callers can determine thread context. (Not consumed directly yet, but available for future use.) 2. **index.mjs**: - `handleIncomingMessage()`: store the latest incoming `messageId` as `replyToMessageId` in the per-chat thread store. - `sendText()`: look up `replyToMessageId` from the thread store; when present, call `client.im.message.reply()` instead of `create()`. This keeps ALL bot responses nested under the original user message inside the same topic. No config changes needed. New chats automatically start using the reply path; existing chats without a `replyToMessageId` in the store fall back to the old `create` behaviour. / 修复飞书话题群中 bot 消息新建独立话题的问题。所有回复改为使用 reply API / 在原话题内嵌套回复,而非通过 create API 创建新话题。 --- integrations/feishu-bridge/src/index.mjs | 58 ++++++++++++++++++++---- integrations/feishu-bridge/src/lib.mjs | 8 +++- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/integrations/feishu-bridge/src/index.mjs b/integrations/feishu-bridge/src/index.mjs index 535ebd06..669da19c 100644 --- a/integrations/feishu-bridge/src/index.mjs +++ b/integrations/feishu-bridge/src/index.mjs @@ -145,6 +145,29 @@ async function handleIncomingMessage(event) { const identity = incomingIdentity(event); if (!identity.chatId) return; + // Store the incoming message ID so sendText() can reply inside the same + // Feishu thread/topic — without this, every bot message creates a new + // standalone topic in thread-enabled groups. + // / 缓存入站消息 ID,让 sendText 能通过 reply API 在同一话题内回复。 + // / 否则每条 bot 消息都会在话题群中创建独立的新话题(见 #1710)。 + if (identity.messageId) { + const existing = await threadStore.getChat(identity.chatId); + if (existing) { + await threadStore.patchChat(identity.chatId, { + replyToMessageId: identity.messageId, + updatedAt: new Date().toISOString() + }); + } else { + await threadStore.setChat(identity.chatId, { + replyToMessageId: identity.messageId, + threadId: null, + lastSeq: 0, + activeTurnId: null, + updatedAt: new Date().toISOString() + }); + } + } + if (identity.messageType && identity.messageType !== "text") { await sendText(identity.chatId, "Only text messages are supported in this first bridge."); return; @@ -531,21 +554,40 @@ async function decideApproval(chatId, action) { } async function sendText(chatId, text) { + // Try reply API first — keeps bot responses inside the same Feishu + // thread/topic instead of spawning new standalone topics. + // / 优先使用 reply API,确保 bot 回复留在话题群的同一条话题内。 + const state = await threadStore.getChat(chatId); + const replyToMessageId = state?.replyToMessageId || null; + + const replyMessage = + replyToMessageId + ? client.im?.v1?.message?.reply?.bind(client.im.v1.message) || + client.im?.message?.reply?.bind(client.im.message) + : null; const createMessage = client.im?.v1?.message?.create?.bind(client.im.v1.message) || client.im?.message?.create?.bind(client.im.message); if (!createMessage) { throw new Error("Lark SDK client does not expose im message create API"); } + for (const chunk of splitMessage(text, config.maxReplyChars)) { - await createMessage({ - params: { receive_id_type: "chat_id" }, - data: { - receive_id: chatId, - msg_type: "text", - content: JSON.stringify({ text: chunk }) - } - }); + 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 } + }); + } } } diff --git a/integrations/feishu-bridge/src/lib.mjs b/integrations/feishu-bridge/src/lib.mjs index a16daf93..b6dae5f2 100644 --- a/integrations/feishu-bridge/src/lib.mjs +++ b/integrations/feishu-bridge/src/lib.mjs @@ -67,7 +67,13 @@ export function incomingIdentity(event) { messageType: message.message_type || "", openId: sender.open_id || "", unionId: sender.union_id || "", - userId: sender.user_id || "" + userId: sender.user_id || "", + // Thread/topic group context: these fields let the bridge reply + // inside the same topic instead of spawning a new standalone topic. + // / 话题群上下文:用于在同一话题内回复,而非新建独立话题。 + parentId: message.parent_id || "", + rootId: message.root_id || "", + threadId: message.thread_id || "" }; } From 82499e1c20895eee6cb1f9527c9f3112ecafbf1d Mon Sep 17 00:00:00 2001 From: yuanchenglu Date: Tue, 26 May 2026 09:52:53 +0800 Subject: [PATCH 02/22] feat(feishu): add /model command for per-chat model switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge only supported a single global model (DEEPSEEK_MODEL env / default_text_model in config.toml). Users who wanted a different model for a particular Feishu group had to restart the bridge with a different env var — impractical and disruptive. This commit adds per-chat model switching so users in a group chat can run "/model " to switch the model for all future threads and turns in that chat, without affecting other chats. Changes: - **lib.mjs — commandAction()**: handle "model" command → { kind: "set_model", modelName }. - **index.mjs**: - setChatModel(chatId, modelName): store/clear per-chat model in the thread store. "/model default" resets to the bridge-level default. - ensureThread(): read per-chat model from store when creating a new runtime thread; fall back to config.model. - runPrompt(): read per-chat model for each turn submission (independent of the thread-creation model), so a /model change takes effect on the very next message. Usage: /model deepseek-v4-flash — switch this chat to Flash /model deepseek-v4-pro — switch this chat to Pro /model default — reset to bridge default Resolution priority (per-chat): global default < per-chat /model. / 新增 /model 命令,支持按飞书群设置独立模型。 / 群内输入 /model 切换,/model default 恢复全局默认。 --- integrations/feishu-bridge/src/index.mjs | 33 ++++++++++++++++++++++-- integrations/feishu-bridge/src/lib.mjs | 5 ++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/integrations/feishu-bridge/src/index.mjs b/integrations/feishu-bridge/src/index.mjs index 669da19c..0a485b5c 100644 --- a/integrations/feishu-bridge/src/index.mjs +++ b/integrations/feishu-bridge/src/index.mjs @@ -231,6 +231,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 +246,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, @@ -274,6 +281,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 +307,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, @@ -553,6 +564,24 @@ async function decideApproval(chatId, action) { await sendText(chatId, `Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`); } +async function setChatModel(chatId, modelName) { + // /model — set per-chat model; "default" or empty resets to bridge default. + // / /model "default" 或空参数 — 恢复桥接级别的默认模型。 + if (!modelName || modelName === "default") { + await threadStore.patchChat(chatId, { + model: null, + updatedAt: new Date().toISOString() + }); + await sendText(chatId, `Reset per-chat model. Using bridge default: ${config.model}`); + return; + } + await threadStore.patchChat(chatId, { + model: modelName, + updatedAt: new Date().toISOString() + }); + await sendText(chatId, `Per-chat model set to: ${modelName}`); +} + async function sendText(chatId, text) { // Try reply API first — keeps bot responses inside the same Feishu // thread/topic instead of spawning new standalone topics. diff --git a/integrations/feishu-bridge/src/lib.mjs b/integrations/feishu-bridge/src/lib.mjs index b6dae5f2..2408fe81 100644 --- a/integrations/feishu-bridge/src/lib.mjs +++ b/integrations/feishu-bridge/src/lib.mjs @@ -147,6 +147,11 @@ export function commandAction(command) { return { kind: "interrupt" }; case "compact": return { kind: "compact" }; + case "model": + // /model — switch per-chat default model. + // Stored in thread store and used for future threads/turns. + // Pass "default" to reset to the bridge-level default. + return { kind: "set_model", modelName: command.args }; case "allow": return { kind: "approval", decision: "allow", ...parseApprovalDecisionArgs(command.args) }; case "deny": From a0836f0a9f434e752fe6599780cb3de46fb64b61 Mon Sep 17 00:00:00 2001 From: ts25504 Date: Sat, 16 May 2026 10:38:07 +0800 Subject: [PATCH 03/22] feat: add account balance status bar item --- crates/tui/src/config.rs | 22 ++++- crates/tui/src/config_ui.rs | 12 +++ crates/tui/src/pricing.rs | 33 +++++++ crates/tui/src/tui/app.rs | 4 + crates/tui/src/tui/footer_ui.rs | 36 ++++++++ crates/tui/src/tui/ui.rs | 44 ++++++++++ crates/tui/src/tui/views/status_picker.rs | 55 +++++++++++- crates/tui/src/tui/widgets/footer.rs | 102 ++++++++++++++++------ 8 files changed, 279 insertions(+), 29 deletions(-) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 282d2023..41adef59 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -779,6 +779,8 @@ pub enum StatusItem { RateLimit, /// Session token usage: input / cache-hit / output. Tokens, + /// DeepSeek account balance, refreshed once per turn completion. + Balance, } impl StatusItem { @@ -798,6 +800,7 @@ impl StatusItem { StatusItem::ReasoningReplay, StatusItem::Cache, StatusItem::Tokens, + StatusItem::Balance, ] } @@ -818,7 +821,11 @@ impl StatusItem { StatusItem::GitBranch => "git_branch", StatusItem::LastToolElapsed => "last_tool_elapsed", StatusItem::RateLimit => "rate_limit", +<<<<<<< HEAD StatusItem::Tokens => "tokens", +======= + StatusItem::Balance => "balance", +>>>>>>> 4bc823e6 (feat: add account balance status bar item) } } @@ -839,7 +846,11 @@ impl StatusItem { StatusItem::GitBranch => "Git branch", StatusItem::LastToolElapsed => "Last tool elapsed", StatusItem::RateLimit => "Rate-limit remaining", +<<<<<<< HEAD StatusItem::Tokens => "Session tokens", +======= + StatusItem::Balance => "Account balance", +>>>>>>> 4bc823e6 (feat: add account balance status bar item) } } @@ -861,7 +872,11 @@ impl StatusItem { StatusItem::GitBranch => "current workspace branch", StatusItem::LastToolElapsed => "ms of the most recent tool call (placeholder)", StatusItem::RateLimit => "remaining requests in the budget (placeholder)", +<<<<<<< HEAD StatusItem::Tokens => "input / cache-hit / output token totals", +======= + StatusItem::Balance => "topped-up + granted balance from DeepSeek", +>>>>>>> 4bc823e6 (feat: add account balance status bar item) } } @@ -883,6 +898,7 @@ impl StatusItem { StatusItem::LastToolElapsed, StatusItem::RateLimit, StatusItem::Tokens, + StatusItem::Balance, ] } @@ -891,7 +907,11 @@ 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 ) } } diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index 9cf8ecd2..b3da0253 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -278,7 +278,11 @@ pub enum StatusItemValue { GitBranch, LastToolElapsed, RateLimit, +<<<<<<< HEAD Tokens, +======= + Balance, +>>>>>>> 4bc823e6 (feat: add account balance status bar item) } pub fn parse_mode(arg: Option<&str>) -> Result { @@ -1001,7 +1005,11 @@ impl From for StatusItemValue { StatusItem::GitBranch => Self::GitBranch, StatusItem::LastToolElapsed => Self::LastToolElapsed, StatusItem::RateLimit => Self::RateLimit, +<<<<<<< HEAD StatusItem::Tokens => Self::Tokens, +======= + StatusItem::Balance => Self::Balance, +>>>>>>> 4bc823e6 (feat: add account balance status bar item) } } } @@ -1022,7 +1030,11 @@ impl From for StatusItem { StatusItemValue::GitBranch => Self::GitBranch, StatusItemValue::LastToolElapsed => Self::LastToolElapsed, StatusItemValue::RateLimit => Self::RateLimit, +<<<<<<< HEAD StatusItemValue::Tokens => Self::Tokens, +======= + StatusItemValue::Balance => Self::Balance, +>>>>>>> 4bc823e6 (feat: add account balance status bar item) } } } diff --git a/crates/tui/src/pricing.rs b/crates/tui/src/pricing.rs index eb78ed8b..b04d320a 100644 --- a/crates/tui/src/pricing.rs +++ b/crates/tui/src/pricing.rs @@ -55,6 +55,39 @@ impl CostEstimate { } } +// === DeepSeek Account Balance === + +/// Response from `GET https://api.deepseek.com/user/balance`. +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub struct BalanceResponse { + #[allow(dead_code)] + pub is_available: bool, + pub balance_infos: Vec, +} + +/// Per-currency balance entry from the balance API. +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub struct BalanceInfo { + pub currency: String, + #[serde(default)] + pub total_balance: String, + #[serde(default)] + #[allow(dead_code)] + pub topped_up_balance: String, + #[serde(default)] + #[allow(dead_code)] + pub granted_balance: String, +} + +impl BalanceInfo { + /// Parse the `total_balance` field as an f64. Returns `None` on parse + /// failure or empty string. + #[must_use] + pub fn total_balance_f64(&self) -> Option { + self.total_balance.parse::().ok() + } +} + /// Per-million-token pricing for a model. #[derive(Debug, Clone, Copy)] struct CurrencyPricing { diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 3c49df0e..bfe5ec58 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1402,6 +1402,9 @@ 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>>, /// Current runtime turn id (if known). pub runtime_turn_id: Option, /// Current runtime turn status (if known). @@ -1980,6 +1983,7 @@ 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)), runtime_turn_id: None, runtime_turn_status: None, dispatch_started_at: None, diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 1fc58d91..49c3a33d 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -473,6 +473,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 @@ -487,6 +492,7 @@ pub(crate) fn render_footer_from( reasoning_replay, cache, cost, + balance, ); if !has(S::Mode) { props.mode_label = ""; @@ -595,6 +601,36 @@ pub(crate) fn footer_cost_spans(app: &App) -> Vec> { spans } +pub(crate) fn footer_balance_spans(app: &App) -> Vec> { + let balance = match app.balance_cell.lock() { + Ok(guard) => guard, + Err(_) => return Vec::new(), + }; + let info = match balance.as_ref() { + Some(info) => info, + None => return Vec::new(), + }; + let total = match info.total_balance_f64() { + Some(total) if total > 0.0 => total, + _ => return Vec::new(), + }; + let currency = match info.currency.as_str() { + "CNY" | "cny" => "¥", + _ => "$", + }; + let label = if total >= 1000.0 { + format!("bal {currency}{total:.0}") + } else if total >= 10.0 { + format!("bal {currency}{total:.1}") + } else { + format!("bal {currency}{total:.2}") + }; + vec![Span::styled( + label, + Style::default().fg(palette::TEXT_MUTED), + )] +} + pub(crate) fn should_show_footer_cost(displayed_cost: f64) -> bool { displayed_cost.is_finite() && displayed_cost > 0.0 } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index fb89de61..b8451e93 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -858,6 +858,32 @@ fn active_rlm_task_entries(app: &App) -> Vec { .collect() } +/// 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) -> Option { + let url = "https://api.deepseek.com/user/balance"; + let client = ::reqwest::Client::new(); + 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() +} + #[allow(clippy::too_many_lines)] async fn run_event_loop( terminal: &mut AppTerminal, @@ -1610,6 +1636,24 @@ 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. + if app.api_provider == ApiProvider::Deepseek + || app.api_provider == ApiProvider::DeepseekCN + { + let cell = app.balance_cell.clone(); + let api_key = config.deepseek_api_key().unwrap_or_default(); + if !api_key.is_empty() { + tokio::spawn(async move { + let info = fetch_deepseek_balance(&api_key).await; + if let Ok(mut guard) = cell.lock() { + *guard = info; + } + }); + } + } + if app.mode == AppMode::Plan && app.plan_tool_used_in_turn && !app.plan_prompt_pending diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index 17b6173a..68280fda 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -9,6 +9,8 @@ //! The picker enumerates [`StatusItem::all`] so adding a new variant in //! `crates/tui/src/config.rs` automatically surfaces a new row here. +use std::cell::Cell; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ buffer::Buffer, @@ -35,6 +37,12 @@ pub struct StatusPickerView { cursor: usize, /// Snapshot of `app.status_items` at open time so Esc reverts cleanly. original: Vec, + /// First visible row index. Auto-adjusted by `render` and `move_*` so + /// every item stays reachable regardless of popup height. Uses `Cell` + /// because `render` takes `&self`. + scroll_offset: Cell, + /// Number of item rows that fit in the popup, updated on each `render`. + visible_rows: Cell, } impl StatusPickerView { @@ -47,6 +55,8 @@ impl StatusPickerView { selected, cursor: 0, original: active.to_vec(), + scroll_offset: Cell::new(0), + visible_rows: Cell::new(0), } } @@ -65,6 +75,7 @@ impl StatusPickerView { if self.cursor > 0 { self.cursor -= 1; } + self.scroll_to_cursor(); } fn move_down(&mut self) { @@ -72,6 +83,22 @@ impl StatusPickerView { if self.cursor < max { self.cursor += 1; } + self.scroll_to_cursor(); + } + + /// Keep the cursor row inside the visible window. + fn scroll_to_cursor(&self) { + let visible = self.visible_rows.get(); + if visible == 0 { + return; + } + let offset = self.scroll_offset.get(); + if self.cursor < offset { + self.scroll_offset.set(self.cursor); + } else if self.cursor >= offset + visible { + self.scroll_offset + .set(self.cursor.saturating_sub(visible.saturating_sub(1))); + } } fn toggle_current(&mut self) { @@ -155,8 +182,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, @@ -194,17 +224,38 @@ impl ModalView for StatusPickerView { let inner = block.inner(popup_area); block.render(popup_area, buf); - let mut lines: Vec = Vec::with_capacity(self.rows.len() + 2); + // Two header lines ("Pick the chips…" + blank), rest is item rows. + let visible = (inner.height as usize).saturating_sub(2).max(1); + self.visible_rows.set(visible); + + // Auto-scroll so the cursor stays inside the visible window, + // then clamp to a valid range. + self.scroll_to_cursor(); + let offset = self + .scroll_offset + .get() + .min(self.rows.len().saturating_sub(visible)); + + let mut lines: Vec = Vec::with_capacity(visible + 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("")); +<<<<<<< HEAD for (idx, item) in self.rows.iter().enumerate() { let checked = *self.selected.get(idx).unwrap_or(&false); let is_cursor = idx == self.cursor; let mark = if checked { "[✓]" } else { "[ ]" }; +======= + let end = (offset + visible).min(self.rows.len()); + for (idx, item) in self.rows[offset..end].iter().enumerate() { + let real_idx = offset + idx; + let checked = *self.selected.get(real_idx).unwrap_or(&false); + let is_cursor = real_idx == self.cursor; + let mark = if checked { "[x]" } else { "[ ]" }; +>>>>>>> 4bc823e6 (feat: add account balance status bar item) let row_style = if is_cursor { Style::default() diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index 27f13aa7..d402e1df 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -73,6 +73,9 @@ pub struct FooterProps { /// Rendered in the left cluster (after the model name) — cost is steady /// info, not a transient signal, so it lives with mode and model. pub cost: Vec>, + /// Account balance chip spans (empty when un fetched or zero). Rendered + /// in the left cluster right after cost. + pub balance: Vec>, /// Optional toast that, when present, replaces the left status line. pub toast: Option, /// When `Some(frame_idx)`, the gap between the left status line and the @@ -259,6 +262,7 @@ impl FooterProps { reasoning_replay: Vec>, cache: Vec>, cost: Vec>, + balance: Vec>, ) -> Self { let (mode_label, mode_color) = mode_style(app); // MCP chip (#502) — passive, derived from the user's existing @@ -293,6 +297,7 @@ impl FooterProps { mcp, worked, cost, + balance, toast, working_strip_frame: None, retry: crate::retry_status::snapshot(), @@ -371,15 +376,12 @@ impl FooterWidget { /// /// Priority order (highest to lowest — last to drop): /// 1. Mode label (always visible at any width; truncated only as a last resort) - /// 2. Model name (always visible; then truncated mid-word once status & cost are gone) - /// 3. Cost chip — drops second after status (steady-info still wants to be visible) - /// 4. Status label (e.g. "working", "draft") — drops first when space is tight + /// 2. Model name (always visible; then truncated mid-word once status, balance, & cost are gone) + /// 3. Cost chip — drops third (steady cost is more important than balance) + /// 4. Balance chip — drops second (after status, before cost) + /// 5. Status label (e.g. "working", "draft") — drops first when space is tight /// - /// At every width ≥40 cols the line never wraps mid-hint: the widget - /// chooses one of (`mode · model · cost · status`, `mode · model · cost`, - /// `mode · model`, `mode`) and renders that single line within - /// `max_width`. Cost lives between model and status so the eye finds - /// "what's this run going to cost me" without scanning past the wave. + /// At every width ≥40 cols the line never wraps mid-hint. fn status_line_spans(&self, max_width: usize) -> Vec> { if max_width == 0 { return Vec::new(); @@ -392,29 +394,61 @@ 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 · cost · balance · 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(cost_w) + + cost_w + + extra_sep(balance_w) + + balance_w + + extra_sep(status_w) + + status_w; + if (show_cost || show_balance || show_status) && full_w <= max_width { return self.build_status_line_spans( mode_label, model.to_string(), show_cost.then(|| cost_text.clone()), + show_balance.then(|| balance_text.clone()), show_status.then_some(status_label), ); } - // Tier 2: mode · model · cost — drop status first. + // Tier 2: mode · model · cost · balance — drop status. + let with_balance_w = mode_w + + sep_w + + model_w + + extra_sep(cost_w) + + cost_w + + extra_sep(balance_w) + + balance_w; + if (show_cost || show_balance) && with_balance_w <= max_width { + return self.build_status_line_spans( + mode_label, + model.to_string(), + show_cost.then(|| cost_text.clone()), + show_balance.then(|| balance_text.clone()), + None, + ); + } + + // Tier 3: mode · model · cost — drop balance. if show_cost { let with_cost_w = mode_w + sep_w + model_w + sep_w + cost_w; if with_cost_w <= max_width { @@ -423,17 +457,18 @@ impl FooterWidget { model.to_string(), Some(cost_text.clone()), None, + None, ); } } - // Tier 3: mode · model — drop cost too. + // Tier 4: mode · model — drop cost too. let mode_model_w = mode_w + sep_w + model_w; if mode_model_w <= max_width { - return self.build_status_line_spans(mode_label, model.to_string(), None, None); + return self.build_status_line_spans(mode_label, model.to_string(), None, None, None); } - // Tier 4: mode · — keep both labels visible by + // Tier 5: mode · — keep both labels visible by // ellipsizing the model name. Only do this when there is enough room // for at least the ellipsis ("..."). Below that we drop to mode-only. let prefix_w = mode_w + sep_w; @@ -442,13 +477,12 @@ impl FooterWidget { if model_budget >= 4 { let truncated = truncate_to_width(model, model_budget); if !truncated.is_empty() { - return self.build_status_line_spans(mode_label, truncated, None, None); + return self.build_status_line_spans(mode_label, truncated, None, None, None); } } } - // Tier 5: mode-only. If even the mode label cannot fit, truncate it - // so the footer never wraps to a second row. + // Tier 6: mode-only. if mode_w <= max_width { return vec![Span::styled( mode_label.to_string(), @@ -466,21 +500,17 @@ impl FooterWidget { mode_label: &'static str, model_label: String, cost: Option, + balance: Option, status: Option<&str>, ) -> Vec> { let sep = " \u{00B7} "; let mut spans: Vec> = Vec::new(); - // Skip the mode chip when the user has toggled it off via - // `/statusline`. The widget no longer assumes mode is always - // present so an opt-out user doesn't see a stray separator. if !mode_label.is_empty() { spans.push(Span::styled( mode_label.to_string(), Style::default().fg(self.props.mode_color), )); } - // Same treatment for the model label — gating both keeps the bar - // visually tidy when only auxiliary chips remain. if !model_label.is_empty() { if !spans.is_empty() { spans.push(Span::styled( @@ -505,6 +535,18 @@ impl FooterWidget { Style::default().fg(self.props.text_muted_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(status_label) = status { if !spans.is_empty() { spans.push(Span::styled( @@ -704,6 +746,7 @@ mod tests { Vec::>::new(), Vec::>::new(), Vec::>::new(), + Vec::>::new(), ); // `from_app` reads the process-wide retry-status surface; pin // `Idle` so footer tests don't pick up state set by retry-banner @@ -816,6 +859,7 @@ mod tests { Vec::>::new(), Vec::>::new(), Vec::>::new(), + Vec::>::new(), ); assert!(props.state_label.starts_with("thinking")); @@ -891,6 +935,7 @@ mod tests { Vec::>::new(), Vec::>::new(), Vec::>::new(), + Vec::>::new(), ); let widget = FooterWidget::new(props); let area = ratatui::layout::Rect::new(0, 0, 60, 1); @@ -1153,6 +1198,7 @@ mod tests { Vec::>::new(), Vec::>::new(), Vec::>::new(), + Vec::>::new(), ) } @@ -1249,6 +1295,7 @@ mod tests { Vec::>::new(), Vec::>::new(), vec![Span::styled(cost.to_string(), Style::default())], + Vec::>::new(), ) } @@ -1269,6 +1316,7 @@ mod tests { Vec::>::new(), long_cache, Vec::>::new(), + Vec::>::new(), ); let line = render_at_width(props, 40); @@ -1301,6 +1349,7 @@ mod tests { Vec::>::new(), cache, Vec::>::new(), + Vec::>::new(), ); let line = render_at_width(props, 80); @@ -1365,6 +1414,7 @@ mod tests { Vec::>::new(), Vec::>::new(), Vec::>::new(), + Vec::>::new(), ); let widget = FooterWidget::new(props); From e774c940aa76ecf7d6ad1cb758d5e0289f97b921 Mon Sep 17 00:00:00 2001 From: ts25504 Date: Sat, 16 May 2026 10:58:56 +0800 Subject: [PATCH 04/22] test: add balance status item unit and integration tests --- crates/tui/src/pricing.rs | 73 +++++++++++++++++++++++ crates/tui/src/tui/ui/tests.rs | 104 ++++++++++++++++++++++++++++++++- 2 files changed, 175 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/pricing.rs b/crates/tui/src/pricing.rs index b04d320a..2cb77451 100644 --- a/crates/tui/src/pricing.rs +++ b/crates/tui/src/pricing.rs @@ -390,4 +390,77 @@ mod tests { "¥0.1234" ); } + + // ── BalanceResponse / BalanceInfo ────────────────────────────── + + #[test] + fn balance_response_deserializes_from_json() { + let json = r#"{ + "is_available": true, + "balance_infos": [ + { + "currency": "CNY", + "total_balance": "123.45", + "topped_up_balance": "100.00", + "granted_balance": "23.45" + } + ] + }"#; + let resp: BalanceResponse = serde_json::from_str(json).expect("valid JSON"); + assert!(resp.is_available); + assert_eq!(resp.balance_infos.len(), 1); + let info = &resp.balance_infos[0]; + assert_eq!(info.currency, "CNY"); + assert_eq!(info.total_balance, "123.45"); + assert_eq!(info.topped_up_balance, "100.00"); + assert_eq!(info.granted_balance, "23.45"); + } + + #[test] + fn balance_response_defaults_empty_balance_infos_when_unavailable() { + let json = r#"{"is_available": false, "balance_infos": []}"#; + let resp: BalanceResponse = serde_json::from_str(json).expect("valid JSON"); + assert!(!resp.is_available); + assert!(resp.balance_infos.is_empty()); + } + + #[test] + fn balance_response_empty_list_is_valid() { + let json = r#"{"is_available": true, "balance_infos": []}"#; + let resp: BalanceResponse = serde_json::from_str(json).expect("valid JSON"); + assert!(resp.is_available); + assert!(resp.balance_infos.is_empty()); + } + + // ── BalanceInfo::total_balance_f64 ───────────────────────────── + + #[test] + fn total_balance_f64_parses_decimal() { + let info = BalanceInfo { + currency: "CNY".into(), + total_balance: "123.45".into(), + ..Default::default() + }; + assert_eq!(info.total_balance_f64(), Some(123.45)); + } + + #[test] + fn total_balance_f64_returns_none_on_empty() { + let info = BalanceInfo { + currency: "USD".into(), + total_balance: String::new(), + ..Default::default() + }; + assert_eq!(info.total_balance_f64(), None); + } + + #[test] + fn total_balance_f64_returns_none_on_invalid() { + let info = BalanceInfo { + currency: "USD".into(), + total_balance: "not-a-number".into(), + ..Default::default() + }; + assert_eq!(info.total_balance_f64(), None); + } } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 4f0baa5b..f55ca152 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -9,8 +9,8 @@ use crate::tui::file_mention::{ try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries, }; use crate::tui::footer_ui::{ - active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans, - footer_state_label, footer_status_line_spans, format_context_budget, + active_tool_status_label, footer_auxiliary_spans, footer_balance_spans, footer_cache_spans, + footer_coherence_spans, footer_state_label, footer_status_line_spans, format_context_budget, format_token_count_compact, friendly_subagent_progress, render_footer_from, }; use crate::tui::history::{ @@ -5782,6 +5782,106 @@ 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()); +} + /// Regression for issue #244: visible session spend must not decrease. /// Sub-agent token usage events arrive out of order and may be reconciled /// later (cache adjustments, provisional → final swap). The displayed total From 6b70d27cc892d57da41e290ff02ae440621a13e7 Mon Sep 17 00:00:00 2001 From: ts25504 Date: Sat, 16 May 2026 11:12:03 +0800 Subject: [PATCH 05/22] refactor: gate balance status item behind DeepSeek provider --- crates/tui/src/config.rs | 42 +++++++++++++++++------ crates/tui/src/tui/ui.rs | 1 + crates/tui/src/tui/ui/tests.rs | 6 +++- crates/tui/src/tui/views/status_picker.rs | 32 ++++++++++++----- 4 files changed, 61 insertions(+), 20 deletions(-) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 41adef59..1da34b8a 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -800,7 +800,6 @@ impl StatusItem { StatusItem::ReasoningReplay, StatusItem::Cache, StatusItem::Tokens, - StatusItem::Balance, ] } @@ -821,11 +820,8 @@ impl StatusItem { StatusItem::GitBranch => "git_branch", StatusItem::LastToolElapsed => "last_tool_elapsed", StatusItem::RateLimit => "rate_limit", -<<<<<<< HEAD StatusItem::Tokens => "tokens", -======= StatusItem::Balance => "balance", ->>>>>>> 4bc823e6 (feat: add account balance status bar item) } } @@ -846,11 +842,8 @@ impl StatusItem { StatusItem::GitBranch => "Git branch", StatusItem::LastToolElapsed => "Last tool elapsed", StatusItem::RateLimit => "Rate-limit remaining", -<<<<<<< HEAD StatusItem::Tokens => "Session tokens", -======= StatusItem::Balance => "Account balance", ->>>>>>> 4bc823e6 (feat: add account balance status bar item) } } @@ -872,11 +865,8 @@ impl StatusItem { StatusItem::GitBranch => "current workspace branch", StatusItem::LastToolElapsed => "ms of the most recent tool call (placeholder)", StatusItem::RateLimit => "remaining requests in the budget (placeholder)", -<<<<<<< HEAD StatusItem::Tokens => "input / cache-hit / output token totals", -======= StatusItem::Balance => "topped-up + granted balance from DeepSeek", ->>>>>>> 4bc823e6 (feat: add account balance status bar item) } } @@ -914,6 +904,19 @@ impl StatusItem { | 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. @@ -7555,4 +7558,23 @@ 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)); + } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b8451e93..1954494b 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -4908,6 +4908,7 @@ async fn apply_command_result( app.view_stack .push(crate::tui::views::status_picker::StatusPickerView::new( &app.status_items, + app.api_provider, )); } } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index f55ca152..6ab8ae5d 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5683,13 +5683,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" diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index 68280fda..adfbae58 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -20,7 +20,7 @@ use ratatui::{ widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, }; -use crate::config::StatusItem; +use crate::config::{ApiProvider, StatusItem}; use crate::palette; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; @@ -47,8 +47,12 @@ pub struct StatusPickerView { impl StatusPickerView { #[must_use] - pub fn new(active: &[StatusItem]) -> Self { - let rows: Vec = StatusItem::all().to_vec(); + pub fn new(active: &[StatusItem], provider: ApiProvider) -> Self { + let rows: Vec = StatusItem::all() + .iter() + .filter(|item| item.is_available_for(provider)) + .copied() + .collect(); let selected: Vec = rows.iter().map(|item| active.contains(item)).collect(); Self { rows, @@ -297,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 { @@ -319,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, .. }) => { @@ -332,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(); @@ -350,7 +354,7 @@ mod tests { #[test] fn select_all_and_select_none_keys_work() { let active: Vec = Vec::new(); - let mut view = StatusPickerView::new(&active); + let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek); let action = view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); match action { ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, .. }) => { @@ -370,7 +374,7 @@ mod tests { #[test] fn arrow_keys_move_cursor_within_bounds() { 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::Down, KeyModifiers::NONE)); assert_eq!(view.cursor, 1); @@ -382,4 +386,14 @@ mod tests { } assert_eq!(view.cursor, StatusItem::all().len() - 1); } + + #[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)); + } } From 6241d52e79d0bea92eb9b8ba30573c870b50d06e Mon Sep 17 00:00:00 2001 From: ts25504 Date: Sat, 16 May 2026 11:15:46 +0800 Subject: [PATCH 06/22] refactor: place Balance after Cost in status item ordering --- crates/tui/src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 1da34b8a..ffea91cd 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -877,6 +877,7 @@ impl StatusItem { StatusItem::Mode, StatusItem::Model, StatusItem::Cost, + StatusItem::Balance, StatusItem::Status, StatusItem::Coherence, StatusItem::Agents, @@ -888,7 +889,6 @@ impl StatusItem { StatusItem::LastToolElapsed, StatusItem::RateLimit, StatusItem::Tokens, - StatusItem::Balance, ] } From 51cbcda5ffdd22a2dcd941e8ec08e71521304808 Mon Sep 17 00:00:00 2001 From: ts25504 Date: Sat, 16 May 2026 12:49:01 +0800 Subject: [PATCH 07/22] fix(footer): keep stale balance on fetch failure; localize "bal" prefix --- crates/tui/src/localization.rs | 8 ++++++++ crates/tui/src/tui/footer_ui.rs | 8 +++++--- crates/tui/src/tui/ui.rs | 7 ++++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 21809260..618b9d02 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -336,6 +336,7 @@ pub enum MessageId { FooterAgentsPlural, FooterPressCtrlCAgain, FooterWorking, + FooterBalancePrefix, HelpSectionActions, HelpSectionClipboard, HelpSectionEditing, @@ -575,6 +576,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::FooterAgentsPlural, MessageId::FooterPressCtrlCAgain, MessageId::FooterWorking, + MessageId::FooterBalancePrefix, MessageId::HelpSectionActions, MessageId::HelpSectionClipboard, MessageId::HelpSectionEditing, @@ -1047,6 +1049,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", @@ -1241,6 +1244,7 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> { MessageId::TranslationInProgress => "正在翻譯助理輸出...", MessageId::TranslationComplete => "翻譯完成", MessageId::TranslationFailed => "翻譯失敗", + MessageId::FooterBalancePrefix => "餘額", other => chinese_simplified(other)?, }) } @@ -1433,6 +1437,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 => "入力編集", @@ -1767,6 +1772,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 => "输入编辑", @@ -2117,6 +2123,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", @@ -2513,6 +2520,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::FooterAgentsPlural => "{count} sub-agentes", MessageId::FooterPressCtrlCAgain => "Presiona Ctrl+C de nuevo para salir", MessageId::FooterWorking => "trabajando", + MessageId::FooterBalancePrefix => "saldo", MessageId::HelpSectionActions => "Acciones", MessageId::HelpSectionClipboard => "Portapapeles", MessageId::HelpSectionEditing => "Edición de entrada", diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 49c3a33d..1162ef41 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -4,6 +4,7 @@ use std::time::Instant; use unicode_width::UnicodeWidthStr; use crate::core::coherence::CoherenceState; +use crate::localization::MessageId; use crate::palette; use crate::tools::subagent::SubAgentStatus; use crate::tui::app::App; @@ -618,12 +619,13 @@ pub(crate) fn footer_balance_spans(app: &App) -> Vec> { "CNY" | "cny" => "¥", _ => "$", }; + let prefix = app.tr(MessageId::FooterBalancePrefix); let label = if total >= 1000.0 { - format!("bal {currency}{total:.0}") + format!("{prefix} {currency}{total:.0}") } else if total >= 10.0 { - format!("bal {currency}{total:.1}") + format!("{prefix} {currency}{total:.1}") } else { - format!("bal {currency}{total:.2}") + format!("{prefix} {currency}{total:.2}") }; vec![Span::styled( label, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 1954494b..d76028c9 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1646,9 +1646,10 @@ async fn run_event_loop( let api_key = config.deepseek_api_key().unwrap_or_default(); if !api_key.is_empty() { tokio::spawn(async move { - let info = fetch_deepseek_balance(&api_key).await; - if let Ok(mut guard) = cell.lock() { - *guard = info; + if let Some(info) = fetch_deepseek_balance(&api_key).await { + if let Ok(mut guard) = cell.lock() { + *guard = Some(info); + } } }); } From 75f7ea6dcdf4fbbfb8ba0513dea239a0fa48d1a0 Mon Sep 17 00:00:00 2001 From: ts25504 Date: Sat, 16 May 2026 13:29:15 +0800 Subject: [PATCH 08/22] fix(ui): reuse reqwest client for balance fetches; fix status picker visible rows --- crates/tui/src/tui/ui.rs | 8 ++++++-- crates/tui/src/tui/views/status_picker.rs | 5 +++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index d76028c9..4c475d18 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -5,7 +5,7 @@ use std::io::{self, Stdout, Write}; use std::path::PathBuf; #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] use std::process::{Command, Stdio}; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; use anyhow::Result; @@ -858,13 +858,17 @@ fn active_rlm_task_entries(app: &App) -> Vec { .collect() } +/// 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::new()); + /// 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) -> Option { let url = "https://api.deepseek.com/user/balance"; - let client = ::reqwest::Client::new(); + let client = &*BALANCE_CLIENT; let response = client .get(url) .header("Authorization", format!("Bearer {api_key}")) diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index adfbae58..ef16c1d9 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -228,8 +228,9 @@ impl ModalView for StatusPickerView { let inner = block.inner(popup_area); block.render(popup_area, buf); - // Two header lines ("Pick the chips…" + blank), rest is item rows. - let visible = (inner.height as usize).saturating_sub(2).max(1); + // Four non-item lines (header, blank, footer hint, and one for + // the bottom border decoration), rest is item rows. + let visible = (inner.height as usize).saturating_sub(4).max(1); self.visible_rows.set(visible); // Auto-scroll so the cursor stays inside the visible window, From c2590d16bacd35f803eed6dff4efa87a5bd75c38 Mon Sep 17 00:00:00 2001 From: ts25504 Date: Sun, 24 May 2026 13:01:12 +0800 Subject: [PATCH 09/22] fix(balance): add 10s timeout to BALANCE_CLIENT; show chip at zero balance --- crates/tui/src/tui/footer_ui.rs | 2 +- crates/tui/src/tui/ui.rs | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 1162ef41..bc535f54 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -612,7 +612,7 @@ pub(crate) fn footer_balance_spans(app: &App) -> Vec> { None => return Vec::new(), }; let total = match info.total_balance_f64() { - Some(total) if total > 0.0 => total, + Some(total) if total >= 0.0 => total, _ => return Vec::new(), }; let currency = match info.currency.as_str() { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 4c475d18..8a824add 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -860,7 +860,12 @@ fn active_rlm_task_entries(app: &App) -> Vec { /// 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::new()); +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. /// From 8d092588abe2188b1c20e5c8c28a83dddeeadfd2 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Wed, 27 May 2026 12:59:30 +0800 Subject: [PATCH 10/22] fix: resolve rebase conflicts and clippy warnings - Merge upstream Tokens status item with Balance from PR #1970 - Keep Balance opt-in (not in default_footer) - Fix clippy: collapsible if, useless format!, redundant closure - Show balance only when total > 0 Co-Authored-By: MoriTang Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/commands/config.rs | 6 +++--- crates/tui/src/config_ui.rs | 9 --------- crates/tui/src/runtime_log.rs | 2 +- crates/tui/src/tui/footer_ui.rs | 2 +- crates/tui/src/tui/ui.rs | 8 ++++---- crates/tui/src/tui/views/status_picker.rs | 7 ------- 6 files changed, 9 insertions(+), 25 deletions(-) diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 651b4d5d..192f1697 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -473,9 +473,9 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> Err(err) => return CommandResult::error(format!("Failed to save: {err}")), } } - return CommandResult::error(format!( - "base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving." - )); + return CommandResult::error( + "base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.".to_string(), + ); } _ => {} } diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index b3da0253..4724715f 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -278,11 +278,8 @@ pub enum StatusItemValue { GitBranch, LastToolElapsed, RateLimit, -<<<<<<< HEAD Tokens, -======= Balance, ->>>>>>> 4bc823e6 (feat: add account balance status bar item) } pub fn parse_mode(arg: Option<&str>) -> Result { @@ -1005,11 +1002,8 @@ impl From for StatusItemValue { StatusItem::GitBranch => Self::GitBranch, StatusItem::LastToolElapsed => Self::LastToolElapsed, StatusItem::RateLimit => Self::RateLimit, -<<<<<<< HEAD StatusItem::Tokens => Self::Tokens, -======= StatusItem::Balance => Self::Balance, ->>>>>>> 4bc823e6 (feat: add account balance status bar item) } } } @@ -1030,11 +1024,8 @@ impl From for StatusItem { StatusItemValue::GitBranch => Self::GitBranch, StatusItemValue::LastToolElapsed => Self::LastToolElapsed, StatusItemValue::RateLimit => Self::RateLimit, -<<<<<<< HEAD StatusItemValue::Tokens => Self::Tokens, -======= StatusItemValue::Balance => Self::Balance, ->>>>>>> 4bc823e6 (feat: add account balance status bar item) } } } diff --git a/crates/tui/src/runtime_log.rs b/crates/tui/src/runtime_log.rs index 48373d4b..2edd53ad 100644 --- a/crates/tui/src/runtime_log.rs +++ b/crates/tui/src/runtime_log.rs @@ -174,7 +174,7 @@ fn log_directory() -> Option { { return resolve(userprofile); } - dirs::home_dir().and_then(|h| resolve(h)) + dirs::home_dir().and_then(resolve) } fn log_file_name(date: &str, pid: u32) -> String { diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index bc535f54..1162ef41 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -612,7 +612,7 @@ pub(crate) fn footer_balance_spans(app: &App) -> Vec> { None => return Vec::new(), }; let total = match info.total_balance_f64() { - Some(total) if total >= 0.0 => total, + Some(total) if total > 0.0 => total, _ => return Vec::new(), }; let currency = match info.currency.as_str() { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 8a824add..6b01958f 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1655,10 +1655,10 @@ async fn run_event_loop( let api_key = config.deepseek_api_key().unwrap_or_default(); if !api_key.is_empty() { tokio::spawn(async move { - if let Some(info) = fetch_deepseek_balance(&api_key).await { - if let Ok(mut guard) = cell.lock() { - *guard = Some(info); - } + if let Some(info) = fetch_deepseek_balance(&api_key).await + && let Ok(mut guard) = cell.lock() + { + *guard = Some(info); } }); } diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index ef16c1d9..d47a5ffa 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -248,19 +248,12 @@ impl ModalView for StatusPickerView { ))); lines.push(Line::from("")); -<<<<<<< HEAD - for (idx, item) in self.rows.iter().enumerate() { - let checked = *self.selected.get(idx).unwrap_or(&false); - let is_cursor = idx == self.cursor; - let mark = if checked { "[✓]" } else { "[ ]" }; -======= let end = (offset + visible).min(self.rows.len()); for (idx, item) in self.rows[offset..end].iter().enumerate() { let real_idx = offset + idx; let checked = *self.selected.get(real_idx).unwrap_or(&false); let is_cursor = real_idx == self.cursor; let mark = if checked { "[x]" } else { "[ ]" }; ->>>>>>> 4bc823e6 (feat: add account balance status bar item) let row_style = if is_cursor { Style::default() From f9516921b1ec89a1f79889a0757baf8f70a59d66 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Wed, 27 May 2026 13:18:54 +0800 Subject: [PATCH 11/22] fix(balance): fetch balance on startup and provider switch Previously the balance chip only appeared after a completed turn. Now it also fetches: - On first frame (startup) for DeepSeek/DeepSeekCN providers - After switching to DeepSeek, and clears when switching away Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/tui/app.rs | 3 +++ crates/tui/src/tui/ui.rs | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index bfe5ec58..ca3f48f4 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1405,6 +1405,8 @@ pub struct App { /// DeepSeek account balance, refreshed once per turn completion. /// Shared cell updated by background fetch tasks; read lock in the UI thread. pub balance_cell: std::sync::Arc>>, + /// Tracks whether the initial balance fetch has been attempted for this session. + pub balance_initiated: bool, /// Current runtime turn id (if known). pub runtime_turn_id: Option, /// Current runtime turn status (if known). @@ -1984,6 +1986,7 @@ impl App { 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, runtime_turn_id: None, runtime_turn_status: None, dispatch_started_at: None, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 6b01958f..d826c282 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -974,6 +974,27 @@ 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 + && (app.api_provider == ApiProvider::Deepseek + || app.api_provider == ApiProvider::DeepseekCN) + { + let cell = app.balance_cell.clone(); + let api_key = config.deepseek_api_key().unwrap_or_default(); + if !api_key.is_empty() { + tokio::spawn(async move { + if let Some(info) = fetch_deepseek_balance(&api_key).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. @@ -4816,6 +4837,27 @@ async fn apply_command_result( } AppAction::SwitchProvider { provider, model } => { switch_provider(app, engine_handle, config, provider, model).await; + // Refresh balance after provider switch. + if app.api_provider == ApiProvider::Deepseek + || app.api_provider == ApiProvider::DeepseekCN + { + let cell = app.balance_cell.clone(); + let api_key = config.deepseek_api_key().unwrap_or_default(); + if !api_key.is_empty() { + tokio::spawn(async move { + if let Some(info) = fetch_deepseek_balance(&api_key).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; From c57b83024920c2e0d5ecb28c0dc0624ec5b3baa1 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Wed, 27 May 2026 13:47:33 +0800 Subject: [PATCH 12/22] fix(balance): render balance before cost in footer priority order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Balance (account remaining) is more actionable than session cost, so it should drop later when the footer is width-constrained. Tier order: status → cost → balance → model (was: status → balance → cost → model) Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/tui/widgets/footer.rs | 42 ++++++++++++++-------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index d402e1df..6f847f6d 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -376,9 +376,9 @@ 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, balance, & cost are gone) - /// 3. Cost chip — drops third (steady cost is more important than balance) - /// 4. Balance chip — drops second (after status, before cost) + /// 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. @@ -410,59 +410,59 @@ impl FooterWidget { let extra_sep = |w: usize| if w > 0 { sep_w } else { 0 }; - // Tier 1: mode · model · cost · balance · status + // Tier 1: mode · model · balance · cost · status let full_w = mode_w + sep_w + model_w - + extra_sep(cost_w) - + cost_w + extra_sep(balance_w) + balance_w + + extra_sep(cost_w) + + cost_w + extra_sep(status_w) + status_w; - if (show_cost || show_balance || show_status) && full_w <= max_width { + if (show_balance || show_cost || show_status) && full_w <= max_width { return self.build_status_line_spans( mode_label, model.to_string(), - show_cost.then(|| cost_text.clone()), show_balance.then(|| balance_text.clone()), + show_cost.then(|| cost_text.clone()), show_status.then_some(status_label), ); } - // Tier 2: mode · model · cost · balance — drop status. - let with_balance_w = mode_w + // Tier 2: mode · model · balance · cost — drop status. + let with_cost_w = mode_w + sep_w + model_w - + extra_sep(cost_w) - + cost_w + extra_sep(balance_w) - + balance_w; - if (show_cost || show_balance) && with_balance_w <= max_width { + + 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_cost.then(|| cost_text.clone()), show_balance.then(|| balance_text.clone()), + show_cost.then(|| cost_text.clone()), None, ); } - // Tier 3: mode · model · cost — drop balance. - if show_cost { - let with_cost_w = mode_w + sep_w + model_w + sep_w + cost_w; - if with_cost_w <= max_width { + // 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 4: 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, None); From 1ad0a17ab6a8560ba9a8b37ebc016d534888b283 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Wed, 27 May 2026 14:08:50 +0800 Subject: [PATCH 13/22] fix(config): silently skip unknown status_items instead of crashing When a dev build writes new status item variants (e.g. "balance") to config.toml, the stable build must not crash with "unknown variant". Add a tolerant deserializer that filters unrecognized keys via StatusItem::from_key() and logs a warning. Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/config.rs | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index ffea91cd..dde0168e 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -467,6 +467,28 @@ pub struct RetryConfig { pub exponential_base: Option, } +/// Deserialize `status_items` tolerantly: skip keys unknown to this build +/// instead of erroring with "unknown variant". This lets a dev build write +/// `"balance"` (or any future item) while the stable build still parses the +/// config file successfully. +fn deser_status_items<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let raw: Option> = Option::deserialize(deserializer)?; + Ok(raw.map(|strings| { + strings + .into_iter() + .filter_map(|s| { + StatusItem::from_key(&s).or_else(|| { + tracing::warn!("ignoring unknown status item {s:?} in config"); + None + }) + }) + .collect() + })) +} + /// UI configuration loaded from config files. #[derive(Debug, Clone, Deserialize, Default)] pub struct TuiConfig { @@ -481,6 +503,7 @@ pub struct TuiConfig { /// /// Edited interactively via `/statusline`; persisted to `tui.status_items` /// in `~/.deepseek/config.toml`. + #[serde(deserialize_with = "deser_status_items")] pub status_items: Option>, /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, @@ -825,6 +848,31 @@ impl StatusItem { } } + /// Reverse of [`key`](Self::key): parse a config string back to a variant. + /// Returns `None` for unknown keys so the config parser can silently skip + /// items added by newer versions rather than crashing with "unknown variant". + #[must_use] + pub fn from_key(key: &str) -> Option { + match key { + "mode" => Some(Self::Mode), + "model" => Some(Self::Model), + "cost" => Some(Self::Cost), + "status" => Some(Self::Status), + "coherence" => Some(Self::Coherence), + "agents" => Some(Self::Agents), + "reasoning_replay" => Some(Self::ReasoningReplay), + "prefix_stability" => Some(Self::PrefixStability), + "cache" => Some(Self::Cache), + "context_percent" => Some(Self::ContextPercent), + "git_branch" => Some(Self::GitBranch), + "last_tool_elapsed" => Some(Self::LastToolElapsed), + "rate_limit" => Some(Self::RateLimit), + "tokens" => Some(Self::Tokens), + "balance" => Some(Self::Balance), + _ => None, + } + } + /// Human-readable label for the picker. #[must_use] pub fn label(self) -> &'static str { @@ -7577,4 +7625,22 @@ model = "deepseek-ai/deepseek-v4-pro" // 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); + } } From 13b182bf4779439111ea5db78588f3ead5ba080a Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Wed, 27 May 2026 14:15:20 +0800 Subject: [PATCH 14/22] chore: bump version to 0.8.47 (dev) Co-Authored-By: Claude Opus 4.7 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index dae89151..c82fe624 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.8.46" +version = "0.8.47" edition = "2024" # Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the # codebase relies on extensively. Cargo enforces this so users on older From a95d65861e955561ae6af34e80191f9129a7bc52 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Wed, 27 May 2026 22:49:21 +0800 Subject: [PATCH 15/22] fix(balance): address review feedback on balance status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use config.deepseek_base_url() instead of hardcoded api.deepseek.com - Add 60-second debounce (BALANCE_FETCH_COOLDOWN) to prevent rapid consecutive balance API calls during provider switches - Fix [x] → [✓] regression in status_picker Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/tui/app.rs | 3 ++ crates/tui/src/tui/ui.rs | 35 +++++++++++++++++------ crates/tui/src/tui/views/status_picker.rs | 2 +- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index ca3f48f4..5059a2fb 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1407,6 +1407,8 @@ pub struct App { pub balance_cell: std::sync::Arc>>, /// Tracks whether the initial balance fetch has been attempted for this session. pub balance_initiated: bool, + /// Timestamp of the last balance fetch, used to debounce rapid requests. + pub last_balance_fetch: Option, /// Current runtime turn id (if known). pub runtime_turn_id: Option, /// Current runtime turn status (if known). @@ -1987,6 +1989,7 @@ impl App { cumulative_turn_duration: std::time::Duration::ZERO, balance_cell: std::sync::Arc::new(std::sync::Mutex::new(None)), balance_initiated: false, + last_balance_fetch: None, runtime_turn_id: None, runtime_turn_status: None, dispatch_started_at: None, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index d826c282..06f0cddc 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -858,6 +858,9 @@ fn active_rlm_task_entries(app: &App) -> Vec { .collect() } +/// Minimum interval between balance API fetches to avoid flooding. +const BALANCE_FETCH_COOLDOWN: Duration = Duration::from_secs(60); + /// Shared `reqwest::Client` for balance fetches so connection pools are /// reused across successive background polls. static BALANCE_CLIENT: LazyLock<::reqwest::Client> = LazyLock::new(|| { @@ -871,8 +874,8 @@ static BALANCE_CLIENT: LazyLock<::reqwest::Client> = LazyLock::new(|| { /// /// 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) -> Option { - let url = "https://api.deepseek.com/user/balance"; +async fn fetch_deepseek_balance(api_key: &str, base_url: &str) -> Option { + let url = format!("{}/user/balance", base_url.trim_end_matches('/')); let client = &*BALANCE_CLIENT; let response = client .get(url) @@ -983,9 +986,11 @@ async fn run_event_loop( { 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).await + if let Some(info) = fetch_deepseek_balance(&api_key, &base_url).await && let Ok(mut guard) = cell.lock() { *guard = Some(info); @@ -1669,14 +1674,20 @@ async fn run_event_loop( // Refresh DeepSeek account balance after each completed // turn so the footer balance chip stays current without // adding latency to any request path. - if app.api_provider == ApiProvider::Deepseek - || app.api_provider == ApiProvider::DeepseekCN + let balance_cooldown_expired = app + .last_balance_fetch + .map_or(true, |t| t.elapsed() >= BALANCE_FETCH_COOLDOWN); + if balance_cooldown_expired + && (app.api_provider == ApiProvider::Deepseek + || app.api_provider == ApiProvider::DeepseekCN) { 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).await + if let Some(info) = fetch_deepseek_balance(&api_key, &base_url).await && let Ok(mut guard) = cell.lock() { *guard = Some(info); @@ -4838,14 +4849,20 @@ async fn apply_command_result( AppAction::SwitchProvider { provider, model } => { switch_provider(app, engine_handle, config, provider, model).await; // Refresh balance after provider switch. - if app.api_provider == ApiProvider::Deepseek - || app.api_provider == ApiProvider::DeepseekCN + let balance_cooldown_expired = app + .last_balance_fetch + .map_or(true, |t| t.elapsed() >= BALANCE_FETCH_COOLDOWN); + if balance_cooldown_expired + && (app.api_provider == ApiProvider::Deepseek + || app.api_provider == ApiProvider::DeepseekCN) { 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).await + if let Some(info) = fetch_deepseek_balance(&api_key, &base_url).await && let Ok(mut guard) = cell.lock() { *guard = Some(info); diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index d47a5ffa..e00502be 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -253,7 +253,7 @@ impl ModalView for StatusPickerView { let real_idx = offset + idx; let checked = *self.selected.get(real_idx).unwrap_or(&false); let is_cursor = real_idx == self.cursor; - let mark = if checked { "[x]" } else { "[ ]" }; + let mark = if checked { "[✓]" } else { "[ ]" }; let row_style = if is_cursor { Style::default() From 8cff9ada12712010c7b71ea58ebe54b1e846cd5f Mon Sep 17 00:00:00 2001 From: malsony Date: Mon, 25 May 2026 18:19:56 +0800 Subject: [PATCH 16/22] feat(theme): add Matrix films inspired theme and improve theme_picker logic - Add Matrix films inspired color scheme - Refactor theme_picker to use SELECTABLE_THEMES for last-theme lookup instead of hard-coding --- crates/tui/src/config_ui.rs | 8 +++- crates/tui/src/palette.rs | 70 +++++++++++++++++++++++++++++- crates/tui/src/tui/theme_picker.rs | 21 +++------ 3 files changed, 79 insertions(+), 20 deletions(-) diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index 9cf8ecd2..9537536b 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -184,6 +184,7 @@ pub enum UiThemeValue { TokyoNight, Dracula, GruvboxDark, + Matrix, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -748,6 +749,7 @@ impl UiThemeValue { Self::TokyoNight => "tokyo-night", Self::Dracula => "dracula", Self::GruvboxDark => "gruvbox-dark", + Self::Matrix => "matrix", } } @@ -761,6 +763,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}'"), } @@ -1191,7 +1194,8 @@ background_color = "#1A1B26" "catppuccin-mocha", "tokyo-night", "dracula", - "gruvbox-dark" + "gruvbox-dark", + "matrix" ]) ); } @@ -1276,4 +1280,4 @@ mcp_config_path = "disk-mcp.json" assert!(outcome.changed); assert!(!outcome.requires_engine_sync); } -} +} \ No newline at end of file diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index fb1c66e8..401a4f70 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -81,6 +81,16 @@ pub const GRAYSCALE_TEXT_SOFT_RGB: (u8, u8, u8) = (220, 220, 220); // #DCDCDC pub const GRAYSCALE_BORDER_RGB: (u8, u8, u8) = (96, 96, 96); // #606060 pub const GRAYSCALE_SELECTION_RGB: (u8, u8, u8) = (62, 62, 62); // #3E3E3E +pub const MATRIX_SURFACE_RGB: (u8, u8, u8) = (0, 10, 0); // #000A00 +pub const MATRIX_ELEVATED_RGB: (u8, u8, u8) = (0, 51, 0); // #003300 +pub const MATRIX_SELECTION_RGB: (u8, u8, u8) = (0, 51, 0); // #003300 +pub const MATRIX_TEXT_BODY_RGB: (u8, u8, u8) = (136, 255, 136); // #88FF88 +pub const MATRIX_TEXT_MUTED_RGB: (u8, u8, u8) = (0, 68, 0); // #004400 +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, 102, 0); // #006600 +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,49 @@ 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(0, 204, 0), + 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 +992,7 @@ pub enum ThemeId { TokyoNight, Dracula, GruvboxDark, + Matrix, } impl ThemeId { @@ -957,6 +1011,7 @@ impl ThemeId { "tokyo-night" => Some(Self::TokyoNight), "dracula" => Some(Self::Dracula), "gruvbox-dark" => Some(Self::GruvboxDark), + "matrix" => Some(Self::Matrix), _ => None, } } @@ -975,6 +1030,7 @@ impl ThemeId { Self::TokyoNight => "tokyo-night", Self::Dracula => "dracula", Self::GruvboxDark => "gruvbox-dark", + Self::Matrix => "matrix", } } @@ -991,6 +1047,7 @@ impl ThemeId { Self::TokyoNight => "Tokyo Night", Self::Dracula => "Dracula", Self::GruvboxDark => "Gruvbox Dark", + Self::Matrix => "Matrix", } } @@ -1007,6 +1064,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 +1084,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 +1100,7 @@ pub const SELECTABLE_THEMES: &[ThemeId] = &[ ThemeId::TokyoNight, ThemeId::Dracula, ThemeId::GruvboxDark, + ThemeId::Matrix, ]; impl UiTheme { @@ -1085,6 +1145,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 +1320,7 @@ pub const fn theme_remap_active(theme: ThemeId) -> bool { | ThemeId::TokyoNight | ThemeId::Dracula | ThemeId::GruvboxDark + | ThemeId::Matrix ) } @@ -1292,7 +1354,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 { @@ -2161,4 +2227,4 @@ mod tests { let _ = ColorDepth::detect(); let _ = adapt_color(DEEPSEEK_INK, ColorDepth::detect()); } -} +} \ No newline at end of file diff --git a/crates/tui/src/tui/theme_picker.rs b/crates/tui/src/tui/theme_picker.rs index fca7254a..92b45769 100644 --- a/crates/tui/src/tui/theme_picker.rs +++ b/crates/tui/src/tui/theme_picker.rs @@ -90,23 +90,11 @@ impl ThemePickerView { } fn move_up(&mut self) { - let len = SELECTABLE_THEMES.len(); - if len == 0 { - self.selected = 0; - } else if self.selected == 0 { - self.selected = len - 1; - } else { - self.selected -= 1; - } + self.selected = (self.selected + SELECTABLE_THEMES.len() - 1) % SELECTABLE_THEMES.len(); } fn move_down(&mut self) { - let len = SELECTABLE_THEMES.len(); - if len == 0 { - self.selected = 0; - } else { - self.selected = (self.selected + 1) % len; - } + self.selected = (self.selected + 1) % SELECTABLE_THEMES.len(); } } @@ -323,12 +311,13 @@ mod tests { #[test] fn arrow_navigation_wraps_at_picker_edges() { let mut v = ThemePickerView::new("system".to_string()); + let last = SELECTABLE_THEMES.last().unwrap(); let action = v.handle_key(key(KeyCode::Up)); - assert_eq!(selected_name(&action), Some(ThemeId::GruvboxDark.name())); + assert_eq!(selected_name(&action), Some(last.name())); let action = v.handle_key(key(KeyCode::Down)); - assert_eq!(selected_name(&action), Some(ThemeId::System.name())); + assert_eq!(selected_name(&action), Some(SELECTABLE_THEMES[0].name())); } #[test] From 8454030e54997b0d32f2b17ac01b979f2acb3f94 Mon Sep 17 00:00:00 2001 From: malsony Date: Mon, 25 May 2026 18:54:00 +0800 Subject: [PATCH 17/22] fix(clippy): collapse nested if and suppress too_many_arguments warning --- crates/tui/src/tui/ui.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index c5d03744..b8c7263b 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -6254,6 +6254,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, From 1f7edfe85a106692996b9525cd8b2a7a9693af71 Mon Sep 17 00:00:00 2001 From: malsony Date: Sun, 31 May 2026 13:16:56 +0800 Subject: [PATCH 18/22] fix(theme): change Matrix text_muted from #004400 to #005500 for better contrast --- crates/tui/src/palette.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index 401a4f70..fbfe96fa 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -85,7 +85,7 @@ 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, 68, 0); // #004400 +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, 102, 0); // #006600 From feefae16c6fb0d2c7caaf3194f5683f05f3db1ec Mon Sep 17 00:00:00 2001 From: Hunter B Date: Sat, 30 May 2026 23:55:00 -0700 Subject: [PATCH 19/22] fix(feishu): preserve per-chat model state --- integrations/feishu-bridge/src/index.mjs | 30 +++++++++++++------- integrations/feishu-bridge/src/lib.mjs | 12 ++++++++ integrations/feishu-bridge/test/lib.test.mjs | 26 +++++++++++++++++ 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/integrations/feishu-bridge/src/index.mjs b/integrations/feishu-bridge/src/index.mjs index 0a485b5c..69b1b968 100644 --- a/integrations/feishu-bridge/src/index.mjs +++ b/integrations/feishu-bridge/src/index.mjs @@ -16,6 +16,7 @@ import { parseList, parseApprovalDecisionArgs, parseTextContent, + preservedChatStateFields, splitMessage, stripGroupPrefix } from "./lib.mjs"; @@ -266,6 +267,7 @@ async function ensureThread(chatId, { forceNew = false } = {}) { }); const state = { + ...preservedChatStateFields(existing), threadId: thread.id, lastSeq: 0, activeTurnId: null, @@ -505,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, @@ -601,22 +605,28 @@ async function sendText(chatId, text) { throw new Error("Lark SDK client does not expose im message create API"); } + let canReply = Boolean(replyMessage); for (const chunk of splitMessage(text, config.maxReplyChars)) { const body = { msg_type: "text", content: JSON.stringify({ text: chunk }) }; - if (replyMessage) { - await replyMessage({ - path: { message_id: replyToMessageId }, - data: body - }); - } else { - await createMessage({ - params: { receive_id_type: "chat_id" }, - data: { ...body, receive_id: chatId } - }); + if (canReply) { + try { + await replyMessage({ + path: { message_id: replyToMessageId }, + data: body + }); + continue; + } catch (error) { + canReply = false; + console.warn("Feishu reply API failed; falling back to message create", error); + } } + await createMessage({ + params: { receive_id_type: "chat_id" }, + data: { ...body, receive_id: chatId } + }); } } diff --git a/integrations/feishu-bridge/src/lib.mjs b/integrations/feishu-bridge/src/lib.mjs index 2408fe81..217b6beb 100644 --- a/integrations/feishu-bridge/src/lib.mjs +++ b/integrations/feishu-bridge/src/lib.mjs @@ -166,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); @@ -346,6 +357,7 @@ export function helpText() { "/threads - recent runtime threads", "/new - create a new thread for this chat", "/resume - bind this chat to an existing thread", + "/model - set or reset this chat's model", "/interrupt - interrupt the active turn", "/compact - compact the current thread", "/allow [remember] - approve a pending tool call", diff --git a/integrations/feishu-bridge/test/lib.test.mjs b/integrations/feishu-bridge/test/lib.test.mjs index ca242035..40e264d2 100644 --- a/integrations/feishu-bridge/test/lib.test.mjs +++ b/integrations/feishu-bridge/test/lib.test.mjs @@ -12,8 +12,10 @@ import { parseCommand, parseList, parseTextContent, + preservedChatStateFields, splitMessage, stripGroupPrefix, + helpText, validateBridgeConfig } from "../src/lib.mjs"; @@ -89,12 +91,36 @@ test("commandAction maps bridge commands and falls back to prompts", () => { kind: "resume", threadId: "thread-1" }); + assert.deepEqual(commandAction(parseCommand("/model deepseek-v4-pro")), { + kind: "set_model", + modelName: "deepseek-v4-pro" + }); assert.deepEqual(commandAction(parseCommand("/unknown value")), { kind: "prompt", prompt: "/unknown value" }); }); +test("helpText documents per-chat model switching", () => { + assert.match(helpText(), /\/model /); +}); + +test("preservedChatStateFields carries model across state replacement", () => { + assert.deepEqual( + preservedChatStateFields({ + threadId: "old-thread", + model: "deepseek-v4-flash", + replyToMessageId: "om_123", + activeTurnId: "turn-1" + }), + { + model: "deepseek-v4-flash", + replyToMessageId: "om_123" + } + ); + assert.deepEqual(preservedChatStateFields({ model: null }), { model: null }); +}); + test("parseApprovalDecisionArgs extracts remember flag", () => { assert.deepEqual(parseApprovalDecisionArgs("ap_123 remember"), { approvalId: "ap_123", From ea7dffa59d244867e43d710563e0ab72384e2c07 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Sun, 31 May 2026 14:41:35 +0800 Subject: [PATCH 20/22] feat: show intent summary before file approval prompt (#2381) When the model invokes write/modify/delete tools, extract its preceding text content as an 'intent summary' and pass it to the approval view. This gives users context about why a change is being made before they review what will change. Changes: - Add intent_summary field to ApprovalRequired event (events.rs) - Extract model text from current_text_visible when write tools are detected in the turn loop (turn_loop.rs) - Add ApprovalRequest::new_with_intent constructor with intent_summary parameter (approval.rs) - Pass intent_summary through TUI event handler to approval view (ui.rs) - Render intent summary in approval widget: up to 3 lines of the model explanation, truncated to available card width, with i18n labels for zh-Hans locale (widgets/mod.rs) - Adapt existing tests to new event field (runtime_threads.rs, ui/tests.rs) Design decisions: - Non-blocking: if the model provides no explanation, the approval still proceeds normally (no extra round-trip or token cost) - Backward compatible: YOLO mode and approval cache unaffected - The new() constructor is gated behind #[cfg(test)] since production code now uses new_with_intent() --- crates/tui/src/core/engine/turn_loop.rs | 22 +++++++++++++ crates/tui/src/core/events.rs | 4 +++ crates/tui/src/runtime_threads.rs | 4 +++ crates/tui/src/tui/approval.rs | 17 ++++++++++ crates/tui/src/tui/ui.rs | 12 ++++++- crates/tui/src/tui/ui/tests.rs | 1 + crates/tui/src/tui/widgets/mod.rs | 44 +++++++++++++++++++++++++ 7 files changed, 103 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 04c5171b..6b1b2c4d 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1344,6 +1344,27 @@ impl Engine { } active_tool_names.extend(deferred_tools_hydrated_this_batch); + // --- Intent summary for write tools (#2381) --- + // When the model invokes write tools, extract its preceding text + // as an "intent summary" so the approval view can show *why* the + // change is being made, not just *what* will change. + let has_write_tools = plans.iter().any(|p| { + !p.read_only + && p.approval_required + && p.blocked_error.is_none() + && p.guard_result.is_none() + }); + let intent_summary: Option = if has_write_tools { + let text = current_text_visible.trim(); + if text.is_empty() { + None + } else { + Some(text.to_string()) + } + } else { + None + }; + let plan_count = plans.len(); let batches = plan_tool_execution_batches(plans); let parallel_chunks = batches @@ -1702,6 +1723,7 @@ impl Engine { description: plan.approval_description.clone(), approval_key, approval_grouping_key, + intent_summary: intent_summary.clone(), }) .await; diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index 65e551ce..d5ddda43 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -234,6 +234,10 @@ pub enum Event { /// Lossy / arity-aware fingerprint, used to scope *approvals* so an /// "approve for session" covers later flag variants (v0.8.37). approval_grouping_key: String, + /// The model's explanation of intent before invoking write tools (#2381). + /// Displayed in the approval view so users understand *why* the change + /// is being made before reviewing *what* will change. + intent_summary: Option, }, /// Request user input for a tool call diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index d86b147a..f1a4ade2 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -4217,6 +4217,7 @@ mod tests { tool_name: "exec_command".to_string(), description: "stale approval".to_string(), input: serde_json::json!({}), + intent_summary: None, }) .await?; @@ -4291,6 +4292,7 @@ mod tests { tool_name: "exec_command".to_string(), description: "external allow".to_string(), input: serde_json::json!({}), + intent_summary: None, }) .await?; @@ -4369,6 +4371,7 @@ mod tests { tool_name: "exec_command".to_string(), description: "external deny".to_string(), input: serde_json::json!({}), + intent_summary: None, }) .await?; @@ -4556,6 +4559,7 @@ mod tests { tool_name: "exec_command".to_string(), description: "remember=true".to_string(), input: serde_json::json!({}), + intent_summary: None, }) .await?; diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index 92e3208e..f237aba8 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -134,15 +134,31 @@ pub struct ApprovalRequest { /// Lossy / arity-aware fingerprint, used to scope *approvals* so an /// "approve for session" covers later flag variants (v0.8.37). pub approval_grouping_key: String, + /// The model's explanation of intent before invoking write tools (#2381). + /// Displayed in the approval view so users understand *why* the change + /// is being made before reviewing *what* will change. + pub intent_summary: Option, } impl ApprovalRequest { + #[cfg(test)] pub fn new( id: &str, tool_name: &str, description: &str, params: &Value, approval_key: &str, + ) -> Self { + Self::new_with_intent(id, tool_name, description, params, approval_key, None) + } + + pub fn new_with_intent( + id: &str, + tool_name: &str, + description: &str, + params: &Value, + approval_key: &str, + intent_summary: Option<&str>, ) -> Self { let category = get_tool_category(tool_name); let risk = classify_risk(tool_name, category, params); @@ -159,6 +175,7 @@ impl ApprovalRequest { params: params.clone(), approval_key: approval_key.to_string(), approval_grouping_key, + intent_summary: intent_summary.map(std::string::ToString::to_string), } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 4e8c2463..d3ecf18f 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1929,6 +1929,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); @@ -1980,6 +1981,7 @@ async fn run_event_loop( &description, &tool_input, &approval_key, + intent_summary.as_deref(), ); log_sensitive_event( "tool.approval.prompted", @@ -6609,12 +6611,20 @@ fn push_approval_request_view( description: &str, tool_input: &serde_json::Value, approval_key: &str, + intent_summary: Option<&str>, ) { if tool_name == "apply_patch" { maybe_add_patch_preview(app, tool_input); } - let request = ApprovalRequest::new(id, tool_name, description, tool_input, approval_key); + let request = ApprovalRequest::new_with_intent( + id, + tool_name, + description, + tool_input, + approval_key, + intent_summary, + ); app.view_stack .push(ApprovalView::new_for_locale(request, app.ui_locale)); } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 2d7fac45..d4e37a61 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5529,6 +5529,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"); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 4ab2cc84..2082a25c 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1166,6 +1166,50 @@ 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 { + if !summary.is_empty() { + 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; From b9df3a843b826594eda88931042a076a8b5bc296 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Sun, 31 May 2026 00:02:21 -0700 Subject: [PATCH 21/22] test(tui): include Volcengine in provider picker expectation --- crates/tui/src/tui/provider_picker.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 8fb3ef1c..046a4892 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -474,6 +474,7 @@ mod tests { "OpenAI-compatible", "AtlasCloud", "Wanjie Ark", + "Volcengine Ark", "OpenRouter", "Xiaomi MiMo", "Novita AI", From 4d772bb9389f077ee3ee9096e97e7688c61f1ff8 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Sun, 31 May 2026 00:03:11 -0700 Subject: [PATCH 22/22] fix(tui): harden approval intent summaries --- crates/tui/src/core/engine/turn_loop.rs | 45 +++++++++++++--- crates/tui/src/runtime_threads.rs | 18 ++++++- crates/tui/src/tui/approval.rs | 9 +++- crates/tui/src/tui/widgets/mod.rs | 69 ++++++++++++------------- 4 files changed, 95 insertions(+), 46 deletions(-) diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 6b1b2c4d..9a3245e1 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -11,6 +11,25 @@ fn loop_guard_block_tool_result(message: String) -> ToolResult { ToolResult::error(message).with_metadata(json!({"loop_guard": "identical_tool_call"})) } +const MAX_APPROVAL_INTENT_SUMMARY_CHARS: usize = 2_000; + +fn approval_intent_summary(text: &str) -> Option { + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + + let mut chars = trimmed.chars(); + let mut summary = chars + .by_ref() + .take(MAX_APPROVAL_INTENT_SUMMARY_CHARS) + .collect::(); + if chars.next().is_some() { + summary.push_str("..."); + } + Some(summary) +} + impl Engine { pub(super) async fn handle_deepseek_turn( &mut self, @@ -1355,12 +1374,7 @@ impl Engine { && p.guard_result.is_none() }); let intent_summary: Option = if has_write_tools { - let text = current_text_visible.trim(); - if text.is_empty() { - None - } else { - Some(text.to_string()) - } + approval_intent_summary(¤t_text_visible) } else { None }; @@ -1723,7 +1737,11 @@ impl Engine { description: plan.approval_description.clone(), approval_key, approval_grouping_key, - intent_summary: intent_summary.clone(), + intent_summary: if plan.read_only { + None + } else { + intent_summary.clone() + }, }) .await; @@ -2278,6 +2296,19 @@ mod tests { assert!(!should_hold_turn_for_subagents(0, 0)); } + #[test] + fn approval_intent_summary_trims_and_bounds_text() { + assert_eq!(approval_intent_summary(" "), None); + + let long_text = format!(" {} ", "x".repeat(MAX_APPROVAL_INTENT_SUMMARY_CHARS + 10)); + let summary = approval_intent_summary(&long_text).expect("summary"); + assert!(summary.ends_with("...")); + assert_eq!( + summary.chars().count(), + MAX_APPROVAL_INTENT_SUMMARY_CHARS + 3 + ); + } + /// Regression test for issue #1727 (P0, release-blocking). /// /// When a model (e.g. gpt-oss via ollama's harmony→OpenAI shim) returns diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index f1a4ade2..22ae0218 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -2683,6 +2683,7 @@ impl RuntimeThreadManager { id, tool_name, description, + intent_summary, .. } => { self.emit_event( @@ -2695,6 +2696,7 @@ impl RuntimeThreadManager { "approval_id": id, "tool_name": tool_name, "description": description, + "intent_summary": intent_summary, }), ) .await?; @@ -4292,7 +4294,7 @@ mod tests { tool_name: "exec_command".to_string(), description: "external allow".to_string(), input: serde_json::json!({}), - intent_summary: None, + intent_summary: Some("I will update the config file.".to_string()), }) .await?; @@ -4302,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 }, diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index f237aba8..a8abe839 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -175,7 +175,14 @@ impl ApprovalRequest { params: params.clone(), approval_key: approval_key.to_string(), approval_grouping_key, - intent_summary: intent_summary.map(std::string::ToString::to_string), + intent_summary: intent_summary.and_then(|summary| { + let summary = summary.trim(); + if summary.is_empty() { + None + } else { + Some(summary.to_string()) + } + }), } } diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index c7c11615..7011dad8 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1168,44 +1168,39 @@ 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 { - if !summary.is_empty() { - 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 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), }; - 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(vec![ + Span::raw(" "), + Span::styled(more, Style::default().fg(palette::TEXT_HINT)), + ])); } } }