From 81bc2da069a3ffe67cb255e5bb3967424cd9975d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 04:03:13 +0000 Subject: [PATCH 1/2] fix(tui): revert v0.8.38 /model picker rework and restore approval grouping The v0.8.38 upgrade dramatically changed two user-visible behaviors that were not intended as regressions: - The /model picker was reworked (#1201/#1632) to make a blocking network fetch on open and replace the curated tier list with the raw provider catalog. Revert model_picker.rs and the OpenModelPicker handler to the v0.8.37 instant curated picker. The /models command still lists the live catalog. - #1617 rekeyed the approval cache to an exact full-argument fingerprint, which also dropped the v0.8.37 arity-aware command-family grouping for "approve for session". Reintroduce build_approval_grouping_key (the lossy v0.8.37 logic) for approvals while keeping the exact key for denials, so denying one call no longer over-blocks later differing calls. https://claude.ai/code/session_01NDuRxM56o17SE7SDLcTFYT --- CHANGELOG.md | 10 ++ crates/tui/CHANGELOG.md | 10 ++ crates/tui/src/core/engine/turn_loop.rs | 7 ++ crates/tui/src/core/events.rs | 5 +- crates/tui/src/runtime_threads.rs | 4 + crates/tui/src/tools/approval_cache.rs | 159 ++++++++++++++++++++++-- crates/tui/src/tui/approval.rs | 9 +- crates/tui/src/tui/model_picker.rs | 111 +++++++---------- crates/tui/src/tui/ui.rs | 45 ++----- crates/tui/src/tui/views/mod.rs | 4 +- 10 files changed, 252 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80600674..aff05feb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Feishu/Lark bridge startup order is guarded.** The bridge now keeps `ThreadStore` initialized before startup opens persisted thread state, with a regression test to prevent moving it below its first use. +- **`/model` picker opens instantly with the curated list again.** Reverted + the v0.8.38 live-catalog rework: the picker no longer makes a blocking + network call on open and once again shows the curated `auto` / + `deepseek-v4-pro` / `deepseek-v4-flash` rows. The `/models` command still + lists the live provider catalog. +- **"Approve for session" groups by command family again.** Session approvals + are keyed by a lossy, arity-aware fingerprint once more, so approving + `cargo build` also covers `cargo build --release`. Denials keep the exact + per-call fingerprint from #1617, so denying one call no longer over-blocks + later, different calls to the same tool. ## [0.8.38] - 2026-05-15 diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 80600674..aff05feb 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -12,6 +12,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Feishu/Lark bridge startup order is guarded.** The bridge now keeps `ThreadStore` initialized before startup opens persisted thread state, with a regression test to prevent moving it below its first use. +- **`/model` picker opens instantly with the curated list again.** Reverted + the v0.8.38 live-catalog rework: the picker no longer makes a blocking + network call on open and once again shows the curated `auto` / + `deepseek-v4-pro` / `deepseek-v4-flash` rows. The `/models` command still + lists the live provider catalog. +- **"Approve for session" groups by command family again.** Session approvals + are keyed by a lossy, arity-aware fingerprint once more, so approving + `cargo build` also covers `cargo build --release`. Denials keep the exact + per-call fingerprint from #1617, so denying one call no longer over-blocks + later, different calls to the same tool. ## [0.8.38] - 2026-05-15 diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 53622461..4f59474b 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1597,6 +1597,12 @@ impl Engine { &tool_input, ) .0; + let approval_grouping_key = + crate::tools::approval_cache::build_approval_grouping_key( + &tool_name, + &tool_input, + ) + .0; let _ = self .tx_event .send(Event::ApprovalRequired { @@ -1604,6 +1610,7 @@ impl Engine { tool_name: tool_name.clone(), description: plan.approval_description.clone(), approval_key, + approval_grouping_key, }) .await; diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index ea338409..b02ba2f9 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -226,8 +226,11 @@ pub enum Event { id: String, tool_name: String, description: String, - /// Fingerprint key for per‑call approval caching (§5.A). + /// Exact-argument fingerprint, used to scope *denials* (#1617). approval_key: String, + /// Lossy / arity-aware fingerprint, used to scope *approvals* so an + /// "approve for session" covers later flag variants (v0.8.37). + approval_grouping_key: String, }, /// Request user input for a tool call diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index b556e030..07ad8d6f 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -4154,6 +4154,7 @@ mod tests { .tx_event .send(EngineEvent::ApprovalRequired { approval_key: "test_key".to_string(), + approval_grouping_key: "test_key".to_string(), id: "tool_stale".to_string(), tool_name: "exec_command".to_string(), description: "stale approval".to_string(), @@ -4226,6 +4227,7 @@ mod tests { .tx_event .send(EngineEvent::ApprovalRequired { approval_key: "key1".to_string(), + approval_grouping_key: "key1".to_string(), id: "tool_external_allow".to_string(), tool_name: "exec_command".to_string(), description: "external allow".to_string(), @@ -4302,6 +4304,7 @@ mod tests { .tx_event .send(EngineEvent::ApprovalRequired { approval_key: "key2".to_string(), + approval_grouping_key: "key2".to_string(), id: "tool_external_deny".to_string(), tool_name: "exec_command".to_string(), description: "external deny".to_string(), @@ -4487,6 +4490,7 @@ mod tests { .tx_event .send(EngineEvent::ApprovalRequired { approval_key: "key3".to_string(), + approval_grouping_key: "key3".to_string(), id: "tool_remember".to_string(), tool_name: "exec_command".to_string(), description: "remember=true".to_string(), diff --git a/crates/tui/src/tools/approval_cache.rs b/crates/tui/src/tools/approval_cache.rs index db9a201a..fd5852b3 100644 --- a/crates/tui/src/tools/approval_cache.rs +++ b/crates/tui/src/tools/approval_cache.rs @@ -6,14 +6,31 @@ //! cache keys off a **call fingerprint** — a digest of the tool name and //! the semantically‑relevant portion of its arguments. //! -//! ## Fingerprint shape +//! ## Two fingerprint shapes //! -//! | Tool | Key | -//! |---------------|------------------------------------------| -//! | file writes | `file::` | -//! | shell tools | `shell::` | -//! | `fetch_url` | `net:` | -//! | everything else| `tool::` | +//! There are two key flavours, used for opposite sides of the decision: +//! +//! * [`build_approval_key`] — an **exact** digest of the full arguments. +//! Used to scope *denials* so that denying one call (e.g. `rm -rf /tmp/x`) +//! does not also suppress a later, different call to the same tool (#1617). +//! +//! | Tool | Exact key | +//! |---------------|------------------------------------------| +//! | file writes | `file::` | +//! | shell tools | `shell::` | +//! | `fetch_url` | `net:` | +//! | everything else| `tool::` | +//! +//! * [`build_approval_grouping_key`] — a **lossy / arity-aware** digest. +//! Used to scope *approvals* so that approving `cargo build` for the +//! session also covers `cargo build --release` (the v0.8.37 behaviour). +//! +//! | Tool | Grouping key | +//! |---------------|------------------------------------------| +//! | `apply_patch` | `patch:` | +//! | shell tools | `shell:` | +//! | `fetch_url` | `net:` | +//! | everything else| `tool::` | //! //! The cache is **session‑keyed**: entries carry an //! `ApprovedForSession` flag. When true, the approval is reused for the @@ -27,6 +44,8 @@ use std::time::Instant; use serde_json::Value; use sha2::{Digest, Sha256}; +use crate::command_safety::classify_command; + /// The fingerprint of a tool call — stable enough to match repeated /// calls but specific enough to avoid privilege confusion. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -141,6 +160,86 @@ pub fn build_approval_key(tool_name: &str, input: &serde_json::Value) -> Approva ApprovalKey(fingerprint) } +/// Build the **grouping** approval key for a tool call. +/// +/// Unlike [`build_approval_key`], this collapses argument variants of the +/// same command family onto one key (the v0.8.37 behaviour) so that an +/// "approve for session" decision covers later invocations that differ only +/// by flags. Denials must keep using the exact [`build_approval_key`]. +#[must_use] +pub fn build_approval_grouping_key(tool_name: &str, input: &serde_json::Value) -> ApprovalKey { + let fingerprint = match tool_name { + "apply_patch" => { + let paths_hash = hash_patch_paths(input); + format!("patch:{paths_hash}") + } + "exec_shell" + | "task_shell_start" + | "exec_shell_wait" + | "exec_shell_interact" + | "exec_wait" + | "exec_interact" => { + let prefix = command_prefix(input); + format!("shell:{prefix}") + } + "fetch_url" | "web.fetch" | "web_fetch" => { + let host = parse_host(input); + format!("net:{host}") + } + _ => format!("tool:{tool_name}:{}", hash_json_value(input)), + }; + ApprovalKey(fingerprint) +} + +/// Return the canonical command prefix for the shell command in `input`. +/// +/// Uses [`classify_command`] from the arity dictionary so that approving +/// `git status` also covers `git status -s` / `git status --porcelain` +/// without also covering `git push`. +fn command_prefix(input: &serde_json::Value) -> String { + let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or(""); + let tokens: Vec<&str> = cmd.split_whitespace().collect(); + if tokens.is_empty() { + return "".to_string(); + } + classify_command(&tokens) +} + +/// Hash the sorted set of file paths referenced by a patch input. +fn hash_patch_paths(input: &serde_json::Value) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut paths: Vec<&str> = Vec::new(); + + if let Some(changes) = input.get("changes").and_then(|v| v.as_array()) { + for change in changes { + if let Some(path) = change.get("path").and_then(|v| v.as_str()) { + paths.push(path); + } + } + } else if let Some(patch_text) = input.get("patch").and_then(|v| v.as_str()) { + for line in patch_text.lines() { + if let Some(rest) = line.strip_prefix("+++ b/") { + paths.push(rest.trim()); + } + } + } + + paths.sort(); + paths.dedup(); + + if paths.is_empty() { + return "no_files".to_string(); + } + + let mut hasher = DefaultHasher::new(); + for path in &paths { + path.hash(&mut hasher); + } + format!("{:x}", hasher.finish()) +} + /// Parse the host portion from a URL input. fn parse_host(input: &serde_json::Value) -> String { let url = input.get("url").and_then(|v| v.as_str()).unwrap_or(""); @@ -259,6 +358,52 @@ mod tests { assert_ne!(key_a, key_b); } + #[test] + fn grouping_key_collapses_shell_flag_variants() { + let key_a = build_approval_grouping_key("exec_shell", &json!({"command": "cargo build"})); + let key_b = + build_approval_grouping_key("exec_shell", &json!({"command": "cargo build --release"})); + assert_eq!( + key_a, key_b, + "approving a command family must cover later flag variants" + ); + } + + #[test] + fn grouping_key_still_separates_distinct_commands() { + let key_a = build_approval_grouping_key("exec_shell", &json!({"command": "git status"})); + let key_b = build_approval_grouping_key("exec_shell", &json!({"command": "git push"})); + assert_ne!(key_a, key_b); + } + + #[test] + fn grouping_key_collapses_patch_body_for_same_path() { + let key_a = build_approval_grouping_key( + "apply_patch", + &json!({"changes": [{"path": "a.rs", "content": "x"}]}), + ); + let key_b = build_approval_grouping_key( + "apply_patch", + &json!({"changes": [{"path": "a.rs", "content": "y"}]}), + ); + assert_eq!( + key_a, key_b, + "approving a patch family must cover later edits to the same path" + ); + } + + #[test] + fn denial_key_stays_exact_while_grouping_key_collapses() { + let exact_a = build_approval_key("exec_shell", &json!({"command": "cargo build"})); + let exact_b = build_approval_key("exec_shell", &json!({"command": "cargo build --release"})); + assert_ne!(exact_a, exact_b, "denials must remain exact-call scoped"); + + let group_a = build_approval_grouping_key("exec_shell", &json!({"command": "cargo build"})); + let group_b = + build_approval_grouping_key("exec_shell", &json!({"command": "cargo build --release"})); + assert_eq!(group_a, group_b, "approvals must group by command family"); + } + #[test] fn patch_keys_differ_by_path() { let key_a = build_approval_key( diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index 32f90919..dc7e9cdc 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -130,8 +130,11 @@ pub struct ApprovalRequest { pub impacts: Vec, /// Tool parameters (for display) pub params: Value, - /// Fingerprint key for per‑call approval caching (§5.A). + /// Exact-argument fingerprint, used to scope *denials* (#1617). pub approval_key: String, + /// 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, } impl ApprovalRequest { @@ -144,6 +147,8 @@ impl ApprovalRequest { ) -> Self { let category = get_tool_category(tool_name); let risk = classify_risk(tool_name, category, params); + let approval_grouping_key = + crate::tools::approval_cache::build_approval_grouping_key(tool_name, params).0; Self { id: id.to_string(), @@ -154,6 +159,7 @@ impl ApprovalRequest { impacts: build_impact_summary(tool_name, category, params), params: params.clone(), approval_key: approval_key.to_string(), + approval_grouping_key, } } @@ -597,6 +603,7 @@ impl ApprovalView { decision, timed_out, approval_key: self.request.approval_key.clone(), + approval_grouping_key: self.request.approval_grouping_key.clone(), }) } diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index ed6482a6..88ce4949 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -25,40 +25,18 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph, Widget}, }; -use std::collections::HashSet; -use crate::config::{ - ApiProvider, model_completion_names_for_provider, provider_passes_model_through, -}; use crate::palette; use crate::tui::app::{App, ReasoningEffort}; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; -fn picker_models_for_provider(provider: ApiProvider) -> Vec<(String, String)> { - let mut rows = vec![("auto".to_string(), "select per turn".to_string())]; - if provider_passes_model_through(provider) { - return rows; - } - rows.extend( - model_completion_names_for_provider(provider) - .into_iter() - .map(|id| (id.to_string(), String::new())), - ); - rows -} - -fn picker_rows_from_model_ids(model_ids: Vec) -> Vec<(String, String)> { - let mut rows = vec![("auto".to_string(), "select per turn".to_string())]; - let mut seen = HashSet::from(["auto".to_string()]); - for model_id in model_ids { - let id = model_id.trim(); - if id.is_empty() || !seen.insert(id.to_string()) { - continue; - } - rows.push((id.to_string(), String::new())); - } - rows -} +/// Models the picker exposes by default. Kept short on purpose — power +/// users can still type `/model ` for anything else. +const PICKER_MODELS: &[(&str, &str)] = &[ + ("auto", "select per turn"), + ("deepseek-v4-pro", "flagship"), + ("deepseek-v4-flash", "fast / cheap"), +]; /// Thinking-effort rows shown in the picker, in the order DeepSeek /// behaviorally distinguishes them. @@ -78,7 +56,6 @@ enum Pane { pub struct ModelPickerView { initial_model: String, initial_effort: ReasoningEffort, - model_rows: Vec<(String, String)>, /// Working selection (separate from the initial values so we can offer a /// clean Esc-to-cancel without mutating App state). selected_model_idx: usize, @@ -87,29 +64,30 @@ pub struct ModelPickerView { /// True when the active model is one we don't list — we still show it /// so the picker doesn't quietly forget the user's chosen IDs. show_custom_model_row: bool, + /// When true, hide DeepSeek-specific model rows (pass-through providers + /// like openai don't support them). + hide_deepseek_models: bool, } impl ModelPickerView { #[must_use] pub fn new(app: &App) -> Self { - Self::new_with_rows(app, picker_models_for_provider(app.api_provider)) - } - - #[must_use] - pub fn new_with_models(app: &App, model_ids: Vec) -> Self { - Self::new_with_rows(app, picker_rows_from_model_ids(model_ids)) - } - - fn new_with_rows(app: &App, model_rows: Vec<(String, String)>) -> Self { + let hide_deepseek_models = crate::config::provider_passes_model_through(app.api_provider); let initial_model = if app.auto_model { "auto".to_string() } else { app.model.clone() }; - let mut selected_model_idx = model_rows.iter().position(|(id, _)| *id == initial_model); + // On pass-through providers, only show "auto" and the custom row. + let visible_models: Vec<&str> = if hide_deepseek_models { + vec!["auto"] + } else { + PICKER_MODELS.iter().map(|(id, _)| *id).collect() + }; + let mut selected_model_idx = visible_models.iter().position(|id| *id == initial_model); let show_custom_model_row = selected_model_idx.is_none(); if show_custom_model_row { - selected_model_idx = Some(model_rows.len()); + selected_model_idx = Some(visible_models.len()); } let selected_model_idx = selected_model_idx.unwrap_or(0); @@ -127,26 +105,35 @@ impl ModelPickerView { Self { initial_model, initial_effort, - model_rows, selected_model_idx, selected_effort_idx, focus: Pane::Model, show_custom_model_row, + hide_deepseek_models, + } + } + + fn visible_model_ids(&self) -> Vec<&'static str> { + if self.hide_deepseek_models { + vec!["auto"] + } else { + PICKER_MODELS.iter().map(|(id, _)| *id).collect() } } fn model_row_count(&self) -> usize { - self.model_rows.len() + if self.show_custom_model_row { 1 } else { 0 } + self.visible_model_ids().len() + if self.show_custom_model_row { 1 } else { 0 } } /// Resolve the currently highlighted model row to a model id. If the /// custom row is selected we return the original model from the App so /// "Apply" doesn't blow away an unrecognised id. fn resolved_model(&self) -> String { - if self.show_custom_model_row && self.selected_model_idx == self.model_rows.len() { + let visible = self.visible_model_ids(); + if self.show_custom_model_row && self.selected_model_idx == visible.len() { self.initial_model.clone() - } else if let Some((model, _)) = self.model_rows.get(self.selected_model_idx) { - model.clone() + } else if self.selected_model_idx < visible.len() { + visible[self.selected_model_idx].to_string() } else { self.initial_model.clone() } @@ -337,7 +324,14 @@ impl ModalView for ModelPickerView { .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) .split(inner); - let mut model_rows = self.model_rows.clone(); + let mut model_rows: Vec<(String, String)> = if self.hide_deepseek_models { + vec![("auto".to_string(), "select per turn".to_string())] + } else { + PICKER_MODELS + .iter() + .map(|(id, hint)| ((*id).to_string(), (*hint).to_string())) + .collect() + }; if self.show_custom_model_row { model_rows.push((self.initial_model.clone(), "current (custom)".to_string())); } @@ -478,8 +472,7 @@ mod tests { #[test] fn picker_exposes_auto_and_distinct_thinking_tiers() { - let model_rows = picker_models_for_provider(crate::config::ApiProvider::Deepseek); - let model_labels: Vec<_> = model_rows.iter().map(|(id, _)| id.as_str()).collect(); + let model_labels: Vec<_> = PICKER_MODELS.iter().map(|(id, _)| *id).collect(); assert_eq!( model_labels, vec!["auto", "deepseek-v4-pro", "deepseek-v4-flash"] @@ -502,26 +495,6 @@ mod tests { assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX"); } - #[test] - fn picker_uses_live_provider_model_ids_when_supplied() { - let (mut app, _lock) = create_test_app(); - app.api_provider = crate::config::ApiProvider::Openrouter; - app.model = "meta-llama/llama-3.1-405b-instruct".to_string(); - app.auto_model = false; - - let view = ModelPickerView::new_with_models( - &app, - vec![ - "deepseek/deepseek-chat-v3.1".to_string(), - "meta-llama/llama-3.1-405b-instruct".to_string(), - "qwen/qwen3-coder".to_string(), - ], - ); - - assert!(!view.show_custom_model_row); - assert_eq!(view.resolved_model(), "meta-llama/llama-3.1-405b-instruct"); - } - #[test] fn arrow_keys_move_within_focused_pane() { let (app, _lock) = create_test_app(); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index f2500da5..4d2748a8 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -150,8 +150,8 @@ const PERIODIC_FULL_REPAINT_EVERY_N: u64 = 50; const TURN_META_PREFIX: &str = ""; const SESSION_TITLE_MAX_CHARS: usize = 32; -fn is_session_approved_for_tool(app: &App, tool_name: &str, approval_key: &str) -> bool { - app.approval_session_approved.contains(approval_key) +fn is_session_approved_for_tool(app: &App, tool_name: &str, grouping_key: &str) -> bool { + app.approval_session_approved.contains(grouping_key) || app.approval_session_approved.contains(tool_name) } @@ -1694,9 +1694,10 @@ async fn run_event_loop( tool_name, description, approval_key, + approval_grouping_key, } => { let session_approved = - is_session_approved_for_tool(app, &tool_name, &approval_key); + is_session_approved_for_tool(app, &tool_name, &approval_grouping_key); let session_denied = is_session_denied_for_key(app, &approval_key); if session_denied { // The user already said no to this exact tool / @@ -4518,33 +4519,8 @@ async fn apply_command_result( } AppAction::OpenModelPicker => { if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) { - app.status_message = - Some(format!("Fetching {} models...", app.api_provider.as_str())); - let picker = match fetch_available_models(config).await { - Ok(models) if !models.is_empty() => { - app.status_message = Some(format!("Found {} model(s)", models.len())); - crate::tui::model_picker::ModelPickerView::new_with_models(app, models) - } - Ok(_) => { - app.status_message = Some(format!( - "{} returned no models; showing defaults", - app.api_provider.as_str() - )); - crate::tui::model_picker::ModelPickerView::new(app) - } - Err(error) => { - app.add_message(HistoryCell::System { - content: format!( - "Failed to fetch {} models: {error}. Showing built-in defaults.", - app.api_provider.as_str() - ), - }); - app.status_message = - Some("Model fetch failed; showing defaults".to_string()); - crate::tui::model_picker::ModelPickerView::new(app) - } - }; - app.view_stack.push(picker); + app.view_stack + .push(crate::tui::model_picker::ModelPickerView::new(app)); } } AppAction::OpenProviderPicker => { @@ -5718,12 +5694,15 @@ async fn handle_view_events( decision, timed_out, approval_key, + approval_grouping_key, } => { if decision == ReviewDecision::ApprovedForSession { - // Store both the tool name (backward compat) and the - // approval key (fingerprint-based). + // Store the tool name (backward compat) and the lossy + // grouping key so later flag variants of the same + // command family are also auto-approved (v0.8.37). app.approval_session_approved.insert(tool_name.clone()); - app.approval_session_approved.insert(approval_key.clone()); + app.approval_session_approved + .insert(approval_grouping_key.clone()); } match decision { diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index f5635f10..0b3367b3 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -92,8 +92,10 @@ pub enum ViewEvent { tool_name: String, decision: ReviewDecision, timed_out: bool, - /// Fingerprint key for per‑call approval caching (§5.A). + /// Exact-argument fingerprint, used to scope *denials* (#1617). approval_key: String, + /// Lossy / arity-aware fingerprint, used to scope *approvals*. + approval_grouping_key: String, }, ElevationDecision { tool_id: String, From 373fbd95a0316c3e9b7222560c7bdba54bab4767 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 17 May 2026 16:24:44 +0800 Subject: [PATCH 2/2] chore(release): prepare v0.8.39 Bump workspace, inter-crate, and npm package versions 0.8.38 -> 0.8.39. Roll CHANGELOG [Unreleased] into [0.8.39] with all fixes: - Revert v0.8.38 /model picker rework (back to instant curated picker) - Restore approval grouping (lossy v0.8.37 logic for approvals, exact key for denials) - Thinking-only turn surface fix (#1727) - ACP server JSON-RPC id stringification (#1696) - Chat client: reasoning_content for generic providers (#1673) - Compaction: user text query preservation (#1704) - Engine: system prompt override survival (#1688) - Pager: G/End overshoot fix (#1706), mouse scroll (#1716) - Composer: scroll with text (#1677), multiline arrows (#1721) - macOS system theme detection (#1670) - rlm_open blank source fields (#1712) - Terminal resize paging fix (#1724) - Docker first-run permission (#1684) - README Rust 1.88+ requirement note (#1718) Tests: 3149 passed, 0 failed (deepseek-tui crate) clippy: clean on --all-targets --all-features --- CHANGELOG.md | 68 ++++++++++++++++- Cargo.lock | 28 +++---- Cargo.toml | 2 +- Dockerfile | 3 +- README.md | 4 +- crates/agent/Cargo.toml | 2 +- crates/app-server/Cargo.toml | 18 ++--- crates/cli/Cargo.toml | 14 ++-- crates/config/Cargo.toml | 2 +- crates/core/Cargo.toml | 16 ++-- crates/execpolicy/Cargo.toml | 2 +- crates/hooks/Cargo.toml | 2 +- crates/tools/Cargo.toml | 2 +- crates/tui/CHANGELOG.md | 68 ++++++++++++++++- crates/tui/Cargo.toml | 4 +- crates/tui/src/acp_server.rs | 39 ++++++++++ crates/tui/src/client/chat.rs | 88 +++++++++++++++++++--- crates/tui/src/compaction.rs | 56 ++++++++++++++ crates/tui/src/core/engine.rs | 7 ++ crates/tui/src/core/engine/tests.rs | 45 +++++++++++ crates/tui/src/core/ops.rs | 1 + crates/tui/src/core/session.rs | 4 + crates/tui/src/main.rs | 1 + crates/tui/src/palette.rs | 98 ++++++++++++++++++++++-- crates/tui/src/runtime_threads.rs | 1 + crates/tui/src/tools/approval_cache.rs | 3 +- crates/tui/src/tools/rlm.rs | 65 +++++++++++++--- crates/tui/src/tui/app.rs | 16 +++- crates/tui/src/tui/composer_ui.rs | 17 ++--- crates/tui/src/tui/pager.rs | 100 +++++++++++++++++++++++-- crates/tui/src/tui/ui.rs | 9 +++ crates/tui/src/tui/ui/tests.rs | 85 ++++++++++++++++++++- npm/deepseek-tui/package.json | 4 +- 33 files changed, 777 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aff05feb..be88ef4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.8.39] - 2026-05-17 ### Fixed @@ -22,6 +22,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `cargo build` also covers `cargo build --release`. Denials keep the exact per-call fingerprint from #1617, so denying one call no longer over-blocks later, different calls to the same tool. +- **Docker first-run state directories are writable.** The image now + pre-creates `/home/deepseek/.deepseek` with `deepseek` ownership so the + documented named-volume launch can create runtime thread state on first use + (#1684). +- **Runtime API system prompt overrides survive the first turn.** Threads + created with a `system_prompt` override now keep that prompt through + mode/context refreshes before the model request is built (#1688). +- **Compaction keeps a user text query in tool-heavy histories.** Automatic + compaction now pins the latest user text message when the retained tail only + contains tool calls/results, avoiding OpenAI-compatible Jinja template + failures on the next request (#1704). +- **Pager jumps land at the visible bottom.** Pressing `G` or End in the pager + no longer overshoots the render clamp, so `k`/Up scrolls upward immediately + afterward, and mouse wheels now scroll pager overlays directly (#1706, + #1716). +- **Mouse-wheel-as-arrow scrolling preserves composer drafts.** When + `composer_arrows_scroll` is enabled, Up/Down now scroll the transcript even + with text in the composer instead of replacing the draft with input history + (#1677). +- **Multiline composer arrows move between input lines.** Plain Up/Down now + move the cursor within multiline drafts before falling back to input history, + while single-line mouse-wheel-as-arrow scrolling remains unchanged (#1721). +- **Third-party `reasoning_content` streams no longer corrupt text output.** + Generic OpenAI-compatible providers that stream answer text in + `reasoning_content` now render it as normal text unless the selected provider + is one whose reasoning-content semantics are supported (#1673). +- **macOS system theme detection recognizes Light mode.** When `COLORFGBG` is + missing or unusable, `theme = "system"` now falls back to macOS appearance + detection and treats a missing `AppleInterfaceStyle` key as Light mode + (#1670). +- **`rlm_open` accepts schema-filled blank source fields.** Empty `file_path`, + `content`, and `url` strings now count as absent, so calls that provide one + real source no longer fail the exactly-one-source validator (#1712). +- **Resize keeps transcript paging usable immediately.** After a terminal + resize, PageUp/PageDown now use the resized viewport height instead of + falling back to one-line jumps before the next render (#1724). +- **ACP responses stringify JSON-RPC ids.** `serve --acp` now returns string + ids even when clients send numeric ids, matching Zed's stricter ACP client + expectations (#1696). + +### Thanks + +Thanks to **Matt Van Horn ([@mvanhorn](https://github.com/mvanhorn))** for the +Docker first-run permission fix in #1699 and the runtime system-prompt +regression tests harvested from #1702. Thanks to **Kristopher Clark +([@krisclarkdev](https://github.com/krisclarkdev))** for the compaction +user-query preservation fix in #1704. Thanks to **Stephen Xu +([@wlon](https://github.com/wlon))** for the pager jump-bottom fix in #1706. +Thanks to **tdccccc ([@tdccccc](https://github.com/tdccccc))** for the +composer scroll fix in #1715 and pager mouse-wheel support in #1716. +Thanks to **Paulo Aboim Pinto +([@aboimpinto](https://github.com/aboimpinto))** for the multiline composer +arrow navigation tests harvested from #1719. Thanks to **LittleBlacky +([@LittleBlacky](https://github.com/LittleBlacky))** for the provider-gated +`reasoning_content` stream fix in #1680. +Thanks to **Eosin Ai ([@Aitensa](https://github.com/Aitensa))** for the macOS +system appearance fallback in #1674. +Thanks to **Anaheim ([@AnaheimEX](https://github.com/AnaheimEX))** for the +`rlm_open` schema validation report in #1712. +Thanks to **THatch26 ([@THatch26](https://github.com/THatch26))** for the +terminal resize paging fix in #1724. +Thanks to **Alvin ([@alvin1](https://github.com/alvin1))** for the Zed ACP id +compatibility report in #1696. ## [0.8.38] - 2026-05-15 @@ -4301,7 +4364,8 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.38...HEAD +[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.39...HEAD +[0.8.39]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.38...v0.8.39 [0.8.38]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.37...v0.8.38 [0.8.37]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.36...v0.8.37 [0.8.36]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.35...v0.8.36 diff --git a/Cargo.lock b/Cargo.lock index 3ac2d21d..f8fd5cf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1160,7 +1160,7 @@ dependencies = [ [[package]] name = "deepseek-agent" -version = "0.8.38" +version = "0.8.39" dependencies = [ "deepseek-config", "serde", @@ -1168,7 +1168,7 @@ dependencies = [ [[package]] name = "deepseek-app-server" -version = "0.8.38" +version = "0.8.39" dependencies = [ "anyhow", "axum", @@ -1190,7 +1190,7 @@ dependencies = [ [[package]] name = "deepseek-config" -version = "0.8.38" +version = "0.8.39" dependencies = [ "anyhow", "deepseek-secrets", @@ -1202,7 +1202,7 @@ dependencies = [ [[package]] name = "deepseek-core" -version = "0.8.38" +version = "0.8.39" dependencies = [ "anyhow", "chrono", @@ -1220,7 +1220,7 @@ dependencies = [ [[package]] name = "deepseek-execpolicy" -version = "0.8.38" +version = "0.8.39" dependencies = [ "anyhow", "deepseek-protocol", @@ -1229,7 +1229,7 @@ dependencies = [ [[package]] name = "deepseek-hooks" -version = "0.8.38" +version = "0.8.39" dependencies = [ "anyhow", "async-trait", @@ -1243,7 +1243,7 @@ dependencies = [ [[package]] name = "deepseek-mcp" -version = "0.8.38" +version = "0.8.39" dependencies = [ "anyhow", "serde", @@ -1252,7 +1252,7 @@ dependencies = [ [[package]] name = "deepseek-protocol" -version = "0.8.38" +version = "0.8.39" dependencies = [ "serde", "serde_json", @@ -1260,7 +1260,7 @@ dependencies = [ [[package]] name = "deepseek-secrets" -version = "0.8.38" +version = "0.8.39" dependencies = [ "dirs", "keyring", @@ -1273,7 +1273,7 @@ dependencies = [ [[package]] name = "deepseek-state" -version = "0.8.38" +version = "0.8.39" dependencies = [ "anyhow", "chrono", @@ -1285,7 +1285,7 @@ dependencies = [ [[package]] name = "deepseek-tools" -version = "0.8.38" +version = "0.8.39" dependencies = [ "anyhow", "async-trait", @@ -1298,7 +1298,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.8.38" +version = "0.8.39" dependencies = [ "anyhow", "arboard", @@ -1361,7 +1361,7 @@ dependencies = [ [[package]] name = "deepseek-tui-cli" -version = "0.8.38" +version = "0.8.39" dependencies = [ "anyhow", "chrono", @@ -1386,7 +1386,7 @@ dependencies = [ [[package]] name = "deepseek-tui-core" -version = "0.8.38" +version = "0.8.39" [[package]] name = "deltae" diff --git a/Cargo.toml b/Cargo.toml index b790cc9d..b6a89361 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.38" +version = "0.8.39" 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 diff --git a/Dockerfile b/Dockerfile index 17753674..65bdf693 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,7 +74,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Non-root user with explicit UID/GID for filesystem ownership clarity. RUN groupadd --gid 1000 deepseek \ - && useradd --create-home --shell /bin/bash --uid 1000 --gid 1000 deepseek + && useradd --create-home --shell /bin/bash --uid 1000 --gid 1000 deepseek \ + && install -d -m 0700 -o deepseek -g deepseek /home/deepseek/.deepseek USER deepseek WORKDIR /home/deepseek diff --git a/README.md b/README.md index 12331e22..84f1171c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ agent runtime itself. # matching prebuilt Rust binaries from GitHub Releases. npm install -g deepseek-tui -# 2. Cargo — no Node needed. +# 2. Cargo — no Node needed. Requires Rust 1.88+ (the crates use the +# 2024 edition; older toolchains fail with "feature `edition2024` is +# required"). Run `rustup update` first, or use a non-Cargo path below. cargo install deepseek-tui-cli --locked # `deepseek` (entry point) cargo install deepseek-tui --locked # `deepseek-tui` (TUI binary) diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 6697dfea..d4073e9c 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,5 +7,5 @@ repository.workspace = true description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture" [dependencies] -deepseek-config = { path = "../config", version = "0.8.38" } +deepseek-config = { path = "../config", version = "0.8.39" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index 9ccc3ea6..3232b90a 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect anyhow.workspace = true axum.workspace = true clap.workspace = true -deepseek-agent = { path = "../agent", version = "0.8.38" } -deepseek-config = { path = "../config", version = "0.8.38" } -deepseek-core = { path = "../core", version = "0.8.38" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.38" } -deepseek-hooks = { path = "../hooks", version = "0.8.38" } -deepseek-mcp = { path = "../mcp", version = "0.8.38" } -deepseek-protocol = { path = "../protocol", version = "0.8.38" } -deepseek-state = { path = "../state", version = "0.8.38" } -deepseek-tools = { path = "../tools", version = "0.8.38" } +deepseek-agent = { path = "../agent", version = "0.8.39" } +deepseek-config = { path = "../config", version = "0.8.39" } +deepseek-core = { path = "../core", version = "0.8.39" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.39" } +deepseek-hooks = { path = "../hooks", version = "0.8.39" } +deepseek-mcp = { path = "../mcp", version = "0.8.39" } +deepseek-protocol = { path = "../protocol", version = "0.8.39" } +deepseek-state = { path = "../state", version = "0.8.39" } +deepseek-tools = { path = "../tools", version = "0.8.39" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 9ed3cb49..1b1d7a37 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -14,13 +14,13 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -deepseek-agent = { path = "../agent", version = "0.8.38" } -deepseek-app-server = { path = "../app-server", version = "0.8.38" } -deepseek-config = { path = "../config", version = "0.8.38" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.38" } -deepseek-mcp = { path = "../mcp", version = "0.8.38" } -deepseek-secrets = { path = "../secrets", version = "0.8.38" } -deepseek-state = { path = "../state", version = "0.8.38" } +deepseek-agent = { path = "../agent", version = "0.8.39" } +deepseek-app-server = { path = "../app-server", version = "0.8.39" } +deepseek-config = { path = "../config", version = "0.8.39" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.39" } +deepseek-mcp = { path = "../mcp", version = "0.8.39" } +deepseek-secrets = { path = "../secrets", version = "0.8.39" } +deepseek-state = { path = "../state", version = "0.8.39" } chrono.workspace = true dirs.workspace = true serde.workspace = true diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index b3c36950..9da35b31 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,7 +8,7 @@ description = "Config schema and precedence model for DeepSeek workspace archite [dependencies] anyhow.workspace = true -deepseek-secrets = { path = "../secrets", version = "0.8.38" } +deepseek-secrets = { path = "../secrets", version = "0.8.39" } dirs.workspace = true serde.workspace = true toml.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 5fc3ddca..7f93d0ee 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -deepseek-agent = { path = "../agent", version = "0.8.38" } -deepseek-config = { path = "../config", version = "0.8.38" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.38" } -deepseek-hooks = { path = "../hooks", version = "0.8.38" } -deepseek-mcp = { path = "../mcp", version = "0.8.38" } -deepseek-protocol = { path = "../protocol", version = "0.8.38" } -deepseek-state = { path = "../state", version = "0.8.38" } -deepseek-tools = { path = "../tools", version = "0.8.38" } +deepseek-agent = { path = "../agent", version = "0.8.39" } +deepseek-config = { path = "../config", version = "0.8.39" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.39" } +deepseek-hooks = { path = "../hooks", version = "0.8.39" } +deepseek-mcp = { path = "../mcp", version = "0.8.39" } +deepseek-protocol = { path = "../protocol", version = "0.8.39" } +deepseek-state = { path = "../state", version = "0.8.39" } +deepseek-tools = { path = "../tools", version = "0.8.39" } serde_json.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 47f6490c..660115d0 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace [dependencies] anyhow.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.8.38" } +deepseek-protocol = { path = "../protocol", version = "0.8.39" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index 074b76d5..8f91c970 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.8.38" } +deepseek-protocol = { path = "../protocol", version = "0.8.39" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index b4811b9b..5508e08a 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral [dependencies] anyhow.workspace = true async-trait.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.8.38" } +deepseek-protocol = { path = "../protocol", version = "0.8.39" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index aff05feb..be88ef4b 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.8.39] - 2026-05-17 ### Fixed @@ -22,6 +22,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `cargo build` also covers `cargo build --release`. Denials keep the exact per-call fingerprint from #1617, so denying one call no longer over-blocks later, different calls to the same tool. +- **Docker first-run state directories are writable.** The image now + pre-creates `/home/deepseek/.deepseek` with `deepseek` ownership so the + documented named-volume launch can create runtime thread state on first use + (#1684). +- **Runtime API system prompt overrides survive the first turn.** Threads + created with a `system_prompt` override now keep that prompt through + mode/context refreshes before the model request is built (#1688). +- **Compaction keeps a user text query in tool-heavy histories.** Automatic + compaction now pins the latest user text message when the retained tail only + contains tool calls/results, avoiding OpenAI-compatible Jinja template + failures on the next request (#1704). +- **Pager jumps land at the visible bottom.** Pressing `G` or End in the pager + no longer overshoots the render clamp, so `k`/Up scrolls upward immediately + afterward, and mouse wheels now scroll pager overlays directly (#1706, + #1716). +- **Mouse-wheel-as-arrow scrolling preserves composer drafts.** When + `composer_arrows_scroll` is enabled, Up/Down now scroll the transcript even + with text in the composer instead of replacing the draft with input history + (#1677). +- **Multiline composer arrows move between input lines.** Plain Up/Down now + move the cursor within multiline drafts before falling back to input history, + while single-line mouse-wheel-as-arrow scrolling remains unchanged (#1721). +- **Third-party `reasoning_content` streams no longer corrupt text output.** + Generic OpenAI-compatible providers that stream answer text in + `reasoning_content` now render it as normal text unless the selected provider + is one whose reasoning-content semantics are supported (#1673). +- **macOS system theme detection recognizes Light mode.** When `COLORFGBG` is + missing or unusable, `theme = "system"` now falls back to macOS appearance + detection and treats a missing `AppleInterfaceStyle` key as Light mode + (#1670). +- **`rlm_open` accepts schema-filled blank source fields.** Empty `file_path`, + `content`, and `url` strings now count as absent, so calls that provide one + real source no longer fail the exactly-one-source validator (#1712). +- **Resize keeps transcript paging usable immediately.** After a terminal + resize, PageUp/PageDown now use the resized viewport height instead of + falling back to one-line jumps before the next render (#1724). +- **ACP responses stringify JSON-RPC ids.** `serve --acp` now returns string + ids even when clients send numeric ids, matching Zed's stricter ACP client + expectations (#1696). + +### Thanks + +Thanks to **Matt Van Horn ([@mvanhorn](https://github.com/mvanhorn))** for the +Docker first-run permission fix in #1699 and the runtime system-prompt +regression tests harvested from #1702. Thanks to **Kristopher Clark +([@krisclarkdev](https://github.com/krisclarkdev))** for the compaction +user-query preservation fix in #1704. Thanks to **Stephen Xu +([@wlon](https://github.com/wlon))** for the pager jump-bottom fix in #1706. +Thanks to **tdccccc ([@tdccccc](https://github.com/tdccccc))** for the +composer scroll fix in #1715 and pager mouse-wheel support in #1716. +Thanks to **Paulo Aboim Pinto +([@aboimpinto](https://github.com/aboimpinto))** for the multiline composer +arrow navigation tests harvested from #1719. Thanks to **LittleBlacky +([@LittleBlacky](https://github.com/LittleBlacky))** for the provider-gated +`reasoning_content` stream fix in #1680. +Thanks to **Eosin Ai ([@Aitensa](https://github.com/Aitensa))** for the macOS +system appearance fallback in #1674. +Thanks to **Anaheim ([@AnaheimEX](https://github.com/AnaheimEX))** for the +`rlm_open` schema validation report in #1712. +Thanks to **THatch26 ([@THatch26](https://github.com/THatch26))** for the +terminal resize paging fix in #1724. +Thanks to **Alvin ([@alvin1](https://github.com/alvin1))** for the Zed ACP id +compatibility report in #1696. ## [0.8.38] - 2026-05-15 @@ -4301,7 +4364,8 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.38...HEAD +[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.39...HEAD +[0.8.39]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.38...v0.8.39 [0.8.38]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.37...v0.8.38 [0.8.37]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.36...v0.8.37 [0.8.36]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.35...v0.8.36 diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 9784397a..da469181 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -21,8 +21,8 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.100" arboard = "3.4" -deepseek-secrets = { path = "../secrets", version = "0.8.38" } -deepseek-tools = { path = "../tools", version = "0.8.38" } +deepseek-secrets = { path = "../secrets", version = "0.8.39" } +deepseek-tools = { path = "../tools", version = "0.8.39" } schemaui = { version = "0.12.0", default-features = false, optional = true } async-stream = "0.3.6" async-trait = "0.1" diff --git a/crates/tui/src/acp_server.rs b/crates/tui/src/acp_server.rs index 4cc4fdb3..1b2cbd38 100644 --- a/crates/tui/src/acp_server.rs +++ b/crates/tui/src/acp_server.rs @@ -359,6 +359,7 @@ async fn write_jsonrpc_result(writer: &mut W, id: Value, result: Value) -> Re where W: AsyncWrite + Unpin, { + let id = jsonrpc_response_id(id); write_json_line( writer, json!({ @@ -379,6 +380,7 @@ async fn write_jsonrpc_error( where W: AsyncWrite + Unpin, { + let id = id.map(jsonrpc_response_id); write_json_line( writer, json!({ @@ -403,6 +405,15 @@ where Ok(()) } +fn jsonrpc_response_id(id: Value) -> Value { + match id { + Value::Null => Value::Null, + Value::String(_) => id, + Value::Number(number) => Value::String(number.to_string()), + other => Value::String(other.to_string()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -458,4 +469,32 @@ mod tests { assert_eq!(value["params"]["sessionId"], "sess_1"); assert_eq!(value["params"]["update"]["content"]["text"], "hello\nworld"); } + + #[tokio::test] + async fn jsonrpc_result_stringifies_numeric_ids_for_zed_acp() { + let mut out = Vec::new(); + + write_jsonrpc_result(&mut out, json!(1), json!({"ok": true})) + .await + .expect("write result"); + + let line = String::from_utf8(out).expect("utf8"); + let value: Value = serde_json::from_str(line.trim()).expect("json"); + assert_eq!(value["id"], "1"); + assert_eq!(value["result"], json!({"ok": true})); + } + + #[tokio::test] + async fn jsonrpc_error_keeps_absent_id_null() { + let mut out = Vec::new(); + + write_jsonrpc_error(&mut out, None, -32700, "invalid json") + .await + .expect("write error"); + + let line = String::from_utf8(out).expect("utf8"); + let value: Value = serde_json::from_str(line.trim()).expect("json"); + assert_eq!(value["id"], Value::Null); + assert_eq!(value["error"]["code"], -32700); + } } diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 770cbdef..8e9af335 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -215,6 +215,7 @@ impl DeepSeekClient { } let model = request.model.clone(); + let api_provider = self.api_provider; // Capture transport-shape headers before we consume `response` into // `bytes_stream()`. They are surfaced in the decode-error log path so @@ -251,7 +252,8 @@ impl DeepSeekClient { let mut text_started = false; let mut thinking_started = false; let mut tool_indices: std::collections::HashMap = std::collections::HashMap::new(); - let is_reasoning_model = requires_reasoning_content(&model); + let is_reasoning_model = + requires_reasoning_content(&model) && provider_accepts_reasoning_content(api_provider); let mut byte_stream = std::pin::pin!(byte_stream); let idle = stream_idle_timeout(); @@ -1637,6 +1639,7 @@ fn provider_accepts_reasoning_content(provider: ApiProvider) -> bool { provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN + | ApiProvider::NvidiaNim | ApiProvider::Openrouter | ApiProvider::Novita | ApiProvider::Fireworks @@ -1899,11 +1902,14 @@ pub(super) fn parse_sse_chunk( .map(str::to_string); if let Some(delta) = delta { + let reasoning_text = reasoning_field(delta).filter(|s| !s.is_empty()); + let content_text = delta + .get("content") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()); + // Handle reasoning_content / reasoning thinking deltas. - if is_reasoning_model - && let Some(reasoning) = reasoning_field(delta) - && !reasoning.is_empty() - { + if is_reasoning_model && let Some(reasoning) = reasoning_text { if !*thinking_started { events.push(StreamEvent::ContentBlockStart { index: *content_index, @@ -1921,10 +1927,18 @@ pub(super) fn parse_sse_chunk( }); } + // Generic OpenAI-compatible proxies sometimes stream answer text + // in `reasoning_content`. If this provider is not one whose + // reasoning-content semantics we support, render that field as + // normal text when no `content` delta is present. + let effective_content = match content_text { + Some(content) => Some(content), + None if !is_reasoning_model => reasoning_text, + None => None, + }; + // Handle regular content - if let Some(content) = delta.get("content").and_then(Value::as_str) - && !content.is_empty() - { + if let Some(content) = effective_content { // Close thinking block if transitioning to text if *thinking_started { events.push(StreamEvent::ContentBlockStop { @@ -2196,6 +2210,10 @@ mod stream_decoder_tests { /// Decode a raw SSE-data JSON chunk into our internal events, mirroring /// the per-event call shape used by `handle_chat_completion_stream`. fn decode_chunk(json_text: &str) -> Vec { + decode_chunk_with_reasoning(json_text, true) + } + + fn decode_chunk_with_reasoning(json_text: &str, is_reasoning_model: bool) -> Vec { let chunk: Value = serde_json::from_str(json_text).expect("valid SSE JSON"); let mut content_index = 0u32; let mut text_started = false; @@ -2207,7 +2225,7 @@ mod stream_decoder_tests { &mut text_started, &mut thinking_started, &mut tool_indices, - true, + is_reasoning_model, ) } @@ -2265,6 +2283,45 @@ mod stream_decoder_tests { ); } + #[test] + fn decoder_treats_reasoning_content_as_text_when_provider_does_not_support_reasoning() { + let events = decode_chunk_with_reasoning( + r#"{"choices":[{"delta":{"reasoning_content":"hello"}}]}"#, + false, + ); + + assert!( + matches!( + events.first(), + Some(StreamEvent::ContentBlockStart { + content_block: ContentBlockStart::Text { .. }, + .. + }) + ), + "first event should open a text block; got {events:?}" + ); + assert!( + events.iter().any(|e| matches!( + e, + StreamEvent::ContentBlockDelta { + delta: Delta::TextDelta { text }, + .. + } if text == "hello" + )), + "should yield a TextDelta carrying 'hello'; got {events:?}" + ); + assert!( + !events.iter().any(|e| matches!( + e, + StreamEvent::ContentBlockDelta { + delta: Delta::ThinkingDelta { .. }, + .. + } + )), + "should not emit thinking deltas for generic providers; got {events:?}" + ); + } + #[test] fn decoder_yields_no_events_for_keepalive_chunk() { // DeepSeek often sends `{"choices":[]}` keepalive chunks before @@ -2769,7 +2826,11 @@ mod alias_thinking_detection_tests { //! in the thinking mode must be passed back to the API") on the second //! turn. See upstream API docs: //! https://api-docs.deepseek.com/guides/thinking_mode - use super::{requires_reasoning_content, should_replay_reasoning_content}; + use super::{ + provider_accepts_reasoning_content, requires_reasoning_content, + should_replay_reasoning_content, + }; + use crate::config::ApiProvider; #[test] fn aliases_routed_to_v4_require_reasoning_content() { @@ -2829,4 +2890,11 @@ mod alias_thinking_detection_tests { Some("medium") )); } + + #[test] + fn generic_openai_provider_does_not_accept_reasoning_content_semantics() { + assert!(!provider_accepts_reasoning_content(ApiProvider::Openai)); + assert!(provider_accepts_reasoning_content(ApiProvider::Deepseek)); + assert!(provider_accepts_reasoning_content(ApiProvider::NvidiaNim)); + } } diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 8ffd73ac..0493ed31 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -296,6 +296,14 @@ fn message_text(msg: &Message) -> String { text } +fn is_user_text_query(msg: &Message) -> bool { + msg.role == "user" + && msg + .content + .iter() + .any(|block| matches!(block, ContentBlock::Text { .. })) +} + fn extract_paths_from_message(message: &Message, workspace: Option<&Path>) -> Vec { let mut paths = Vec::new(); for block in &message.content { @@ -437,6 +445,21 @@ pub fn plan_compaction( // Ensure tool result messages are not kept without their corresponding tool call. enforce_tool_call_pairs(messages, &mut pinned_indices); + // Some OpenAI-compatible chat templates require at least one user text + // message. Tool-heavy tails can otherwise compact down to only tool calls + // and tool results, which makes those backends reject the next request. + if !pinned_indices + .iter() + .any(|&idx| is_user_text_query(&messages[idx])) + && let Some(idx) = messages + .iter() + .enumerate() + .rev() + .find_map(|(idx, msg)| is_user_text_query(msg).then_some(idx)) + { + pinned_indices.insert(idx); + } + let summarize_indices = (0..len) .filter(|idx| !pinned_indices.contains(idx)) .collect(); @@ -2303,6 +2326,39 @@ mod tests { assert_eq!(pinned.len(), messages.len()); } + #[test] + fn plan_compaction_keeps_at_least_one_user_text_query() { + let mut messages = vec![msg( + "user", + "This is the original query that started the chain.", + )]; + + for i in 0..10 { + messages.push(Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: format!("call-{i}"), + name: "test_tool".to_string(), + input: json!({}), + caller: None, + }], + }); + messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: format!("call-{i}"), + content: "tool output".to_string(), + is_error: None, + content_blocks: None, + }], + }); + } + + let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None); + + assert!(plan.pinned_indices.contains(&0)); + } + // ======================================================================== // Additional Compaction Trigger Tests // ======================================================================== diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 633f4825..5bc237ba 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -733,6 +733,7 @@ impl Engine { session_id, messages, system_prompt, + system_prompt_override, model, workspace, } => { @@ -745,6 +746,8 @@ impl Engine { self.session.compaction_summary_prompt = extract_compaction_summary_prompt(system_prompt.clone()); self.session.system_prompt = system_prompt; + self.session.system_prompt_override = + system_prompt_override && self.session.system_prompt.is_some(); self.session.auto_model = model.trim().eq_ignore_ascii_case("auto"); self.session.model = model; self.session.workspace = workspace.clone(); @@ -1795,6 +1798,10 @@ impl Engine { let stable_prompt = merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone()); let stable_hash = system_prompt_hash(stable_prompt.as_ref()); + if self.session.system_prompt_override { + self.session.last_system_prompt_hash = Some(stable_hash); + return; + } if self.session.last_system_prompt_hash != Some(stable_hash) { self.session.system_prompt = stable_prompt; self.session.last_system_prompt_hash = Some(stable_hash); diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 0072aa9b..ecf7c176 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -1297,6 +1297,51 @@ fn refresh_system_prompt_is_noop_when_unchanged() { assert_eq!(engine.session.system_prompt, first_prompt); } +fn sync_runtime_system_prompt_override(engine: &mut Engine, system_prompt: SystemPrompt) { + engine.session.compaction_summary_prompt = + extract_compaction_summary_prompt(Some(system_prompt.clone())); + engine.session.system_prompt = Some(system_prompt); + engine.session.system_prompt_override = true; +} + +#[test] +fn text_system_prompt_override_via_runtime_sync_survives_refresh() { + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + ..Default::default() + }; + let (mut engine, _handle) = Engine::new(config, &Config::default()); + let prompt = SystemPrompt::Text("TANGERINE-7".to_string()); + let expected = Some(prompt.clone()); + + sync_runtime_system_prompt_override(&mut engine, prompt); + engine.refresh_system_prompt(AppMode::Agent); + + assert_eq!(engine.session.system_prompt, expected); +} + +#[test] +fn blocks_system_prompt_override_via_runtime_sync_survives_mode_change_refresh() { + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + ..Default::default() + }; + let (mut engine, _handle) = Engine::new(config, &Config::default()); + let prompt = SystemPrompt::Blocks(vec![SystemBlock { + block_type: "text".to_string(), + text: "TANGERINE-7".to_string(), + cache_control: None, + }]); + let expected = Some(prompt.clone()); + + sync_runtime_system_prompt_override(&mut engine, prompt); + engine.refresh_system_prompt(AppMode::Plan); + + assert_eq!(engine.session.system_prompt, expected); +} + #[test] fn compaction_summary_stays_in_stable_system_prompt() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index 77dc8fcc..a77a2625 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -68,6 +68,7 @@ pub enum Op { session_id: Option, messages: Vec, system_prompt: Option, + system_prompt_override: bool, model: String, workspace: PathBuf, }, diff --git a/crates/tui/src/core/session.rs b/crates/tui/src/core/session.rs index 60aa9187..cde29b73 100644 --- a/crates/tui/src/core/session.rs +++ b/crates/tui/src/core/session.rs @@ -32,6 +32,9 @@ pub struct Session { /// System prompt (optional) pub system_prompt: Option, + /// True when `system_prompt` came from an explicit runtime API override + /// and should not be replaced by mode/context refreshes. + pub system_prompt_override: bool, /// Hash of the last assembled stable system prompt. Used to avoid /// replacing `system_prompt` when unchanged. pub last_system_prompt_hash: Option, @@ -141,6 +144,7 @@ impl Session { auto_model: false, workspace, system_prompt: None, + system_prompt_override: false, compaction_summary_prompt: None, messages: Vec::new(), total_usage: SessionUsage::default(), diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index ee4874c0..85736310 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -4701,6 +4701,7 @@ async fn run_exec_agent( session_id: Some(saved_id.clone()), messages: saved.messages, system_prompt: saved.system_prompt.map(SystemPrompt::Text), + system_prompt_override: false, model: saved.metadata.model, workspace: saved.metadata.workspace, }) diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index 054c1a86..e37e75c8 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -1,6 +1,8 @@ //! DeepSeek color palette and semantic roles. use ratatui::style::Color; +#[cfg(target_os = "macos")] +use std::process::Command; pub const DEEPSEEK_BLUE_RGB: (u8, u8, u8) = (53, 120, 229); // #3578E5 pub const DEEPSEEK_SKY_RGB: (u8, u8, u8) = (106, 174, 242); @@ -264,17 +266,57 @@ impl PaletteMode { Some(if bg >= 8 { Self::Light } else { Self::Dark }) } - /// Detect whether the terminal profile is light. Missing or unparsable - /// values default to dark so existing terminal setups keep the tuned theme. + /// Detect the active palette mode. `COLORFGBG` wins when present; macOS + /// appearance is a fallback for terminals that omit terminal color hints. + /// Missing or unparsable values default to dark so existing terminal setups + /// keep the tuned theme. #[must_use] pub fn detect() -> Self { - std::env::var("COLORFGBG") - .ok() - .and_then(|value| Self::from_colorfgbg(&value)) + Self::detect_from_sources( + std::env::var("COLORFGBG").ok().as_deref(), + detect_macos_palette_mode(), + ) + } + + #[must_use] + fn detect_from_sources(colorfgbg: Option<&str>, macos_fallback: Option) -> Self { + colorfgbg + .and_then(Self::from_colorfgbg) + .or(macos_fallback) .unwrap_or(Self::Dark) } } +#[cfg(target_os = "macos")] +fn detect_macos_palette_mode() -> Option { + let output = Command::new("defaults") + .args(["read", "-g", "AppleInterfaceStyle"]) + .output() + .ok()?; + + if output.status.success() { + Some(palette_mode_from_apple_interface_style( + &String::from_utf8_lossy(&output.stdout), + )) + } else { + Some(PaletteMode::Light) + } +} + +#[cfg(not(target_os = "macos"))] +fn detect_macos_palette_mode() -> Option { + None +} + +#[cfg(any(target_os = "macos", test))] +fn palette_mode_from_apple_interface_style(value: &str) -> PaletteMode { + if value.trim().eq_ignore_ascii_case("dark") { + PaletteMode::Dark + } else { + PaletteMode::Light + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UiTheme { pub name: &'static str, @@ -540,7 +582,7 @@ impl ThemeId { #[must_use] pub const fn tagline(self) -> &'static str { match self { - Self::System => "Follow terminal background (COLORFGBG)", + Self::System => "Follow terminal background (COLORFGBG / macOS appearance)", Self::Whale => "Default DeepSeek dark blue", Self::WhaleLight => "DeepSeek light, paper-ish", Self::Grayscale => "Color-minimal high contrast", @@ -1333,6 +1375,50 @@ mod tests { assert_eq!(PaletteMode::from_colorfgbg("not-a-color"), None); } + #[test] + fn palette_mode_detect_prefers_colorfgbg_over_macos_fallback() { + assert_eq!( + PaletteMode::detect_from_sources(Some("0;15"), Some(PaletteMode::Dark)), + PaletteMode::Light + ); + assert_eq!( + PaletteMode::detect_from_sources(Some("15;0"), Some(PaletteMode::Light)), + PaletteMode::Dark + ); + } + + #[test] + fn palette_mode_detect_uses_macos_fallback_when_colorfgbg_missing_or_invalid() { + assert_eq!( + PaletteMode::detect_from_sources(None, Some(PaletteMode::Light)), + PaletteMode::Light + ); + assert_eq!( + PaletteMode::detect_from_sources(Some("not-a-color"), Some(PaletteMode::Light)), + PaletteMode::Light + ); + assert_eq!( + PaletteMode::detect_from_sources(None, None), + PaletteMode::Dark + ); + } + + #[test] + fn apple_interface_style_maps_dark_and_missing_key_to_expected_modes() { + assert_eq!( + super::palette_mode_from_apple_interface_style("Dark\n"), + PaletteMode::Dark + ); + assert_eq!( + super::palette_mode_from_apple_interface_style("Light\n"), + PaletteMode::Light + ); + assert_eq!( + super::palette_mode_from_apple_interface_style(""), + PaletteMode::Light + ); + } + #[test] fn ui_theme_selects_light_variant() { let theme = super::UiTheme::for_mode(PaletteMode::Light); diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 07ad8d6f..5ec3c8f6 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1998,6 +1998,7 @@ impl RuntimeThreadManager { session_id: None, messages: session_messages, system_prompt: sys_prompt, + system_prompt_override: thread.system_prompt.is_some(), model: thread.model.clone(), workspace: thread.workspace.clone(), }) diff --git a/crates/tui/src/tools/approval_cache.rs b/crates/tui/src/tools/approval_cache.rs index fd5852b3..17d83e36 100644 --- a/crates/tui/src/tools/approval_cache.rs +++ b/crates/tui/src/tools/approval_cache.rs @@ -395,7 +395,8 @@ mod tests { #[test] fn denial_key_stays_exact_while_grouping_key_collapses() { let exact_a = build_approval_key("exec_shell", &json!({"command": "cargo build"})); - let exact_b = build_approval_key("exec_shell", &json!({"command": "cargo build --release"})); + let exact_b = + build_approval_key("exec_shell", &json!({"command": "cargo build --release"})); assert_ne!(exact_a, exact_b, "denials must remain exact-call scoped"); let group_a = build_approval_grouping_key("exec_shell", &json!({"command": "cargo build"})); diff --git a/crates/tui/src/tools/rlm.rs b/crates/tui/src/tools/rlm.rs index 086181e8..e3cdbb04 100644 --- a/crates/tui/src/tools/rlm.rs +++ b/crates/tui/src/tools/rlm.rs @@ -81,10 +81,7 @@ impl ToolSpec for RlmOpenTool { } async fn execute(&self, input: Value, context: &ToolContext) -> Result { - let source_count = ["file_path", "content", "url"] - .iter() - .filter(|key| input.get(**key).and_then(Value::as_str).is_some()) - .count(); + let source_count = rlm_open_source_count(&input); if source_count != 1 { return Err(ToolError::invalid_input( "rlm_open: provide exactly one of `file_path`, `content`, or `url`", @@ -417,7 +414,7 @@ async fn load_source( input: &Value, context: &ToolContext, ) -> Result<(String, String, Option), ToolError> { - if let Some(path) = input.get("file_path").and_then(Value::as_str) { + if let Some(path) = rlm_open_source_field(input, "file_path").map(str::trim) { let resolved = context.resolve_path(path)?; let body = tokio::fs::read_to_string(&resolved).await.map_err(|e| { ToolError::execution_failed(format!("rlm_open: read {}: {e}", resolved.display())) @@ -425,7 +422,7 @@ async fn load_source( return Ok((body, "file".to_string(), Some(path.to_string()))); } - if let Some(content) = input.get("content").and_then(Value::as_str) { + if let Some(content) = rlm_open_source_field(input, "content") { if content.chars().count() > MAX_INLINE_CONTENT_CHARS { return Err(ToolError::invalid_input(format!( "rlm_open: inline content is {} chars (cap {MAX_INLINE_CONTENT_CHARS})", @@ -435,9 +432,8 @@ async fn load_source( return Ok((content.to_string(), "content".to_string(), None)); } - let url = input - .get("url") - .and_then(Value::as_str) + let url = rlm_open_source_field(input, "url") + .map(str::trim) .ok_or_else(|| ToolError::invalid_input("rlm_open: missing source"))?; let result = FetchUrlTool .execute(json!({"url": url, "format": "raw"}), context) @@ -458,6 +454,20 @@ async fn load_source( Ok((body, source_type, Some(url.to_string()))) } +fn rlm_open_source_count(input: &Value) -> usize { + ["file_path", "content", "url"] + .iter() + .filter(|field| rlm_open_source_field(input, field).is_some()) + .count() +} + +fn rlm_open_source_field<'a>(input: &'a Value, field: &str) -> Option<&'a str> { + input + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) +} + async fn get_session( context: &ToolContext, name: &str, @@ -519,6 +529,43 @@ mod tests { assert_eq!(RlmCloseTool.name(), "rlm_close"); } + #[test] + fn rlm_open_source_count_ignores_empty_string_defaults() { + assert_eq!( + rlm_open_source_count( + &json!({"name": "url-doc", "file_path": "", "content": "", "url": "https://example.com/doc"}) + ), + 1 + ); + assert_eq!( + rlm_open_source_count( + &json!({"name": "inline-doc", "file_path": "", "content": "body", "url": ""}) + ), + 1 + ); + assert_eq!( + rlm_open_source_count(&json!({"content": "body", "url": "https://example.com/doc"})), + 2 + ); + } + + #[tokio::test] + async fn rlm_open_ignores_blank_source_defaults_from_schema_fillers() { + let ctx = ctx(); + RlmOpenTool + .execute( + json!({"name": "blank-defaults", "file_path": "", "content": "body", "url": ""}), + &ctx, + ) + .await + .expect("open with blank sibling source fields"); + + RlmCloseTool + .execute(json!({"name": "blank-defaults"}), &ctx) + .await + .expect("close"); + } + #[tokio::test] async fn rlm_session_open_eval_close_lifecycle() { let ctx = ctx(); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 093771b4..7d034653 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -2687,7 +2687,9 @@ impl App { self.viewport.last_transcript_area = None; self.viewport.last_transcript_top = 0; - self.viewport.last_transcript_visible = 0; + // Seed visible height from the resize event so paging keys use a + // useful page size immediately, before the next render updates it. + self.viewport.last_transcript_visible = (_height as usize).saturating_sub(2).max(1); self.viewport.last_transcript_total = 0; self.viewport.last_transcript_padding_top = 0; self.viewport.jump_to_latest_button_area = None; @@ -4895,6 +4897,18 @@ mod tests { assert!(app.viewport.transcript_scroll.is_at_tail()); } + #[test] + fn resize_seeds_visible_height_for_paging_before_next_render() { + let mut app = App::new(test_options(false), &Config::default()); + app.viewport.last_transcript_visible = 12; + + app.handle_resize(120, 40); + assert_eq!(app.viewport.last_transcript_visible, 38); + + app.handle_resize(120, 1); + assert_eq!(app.viewport.last_transcript_visible, 1); + } + #[test] fn test_add_message() { let mut app = App::new(test_options(false), &Config::default()); diff --git a/crates/tui/src/tui/composer_ui.rs b/crates/tui/src/tui/composer_ui.rs index dd1b3f61..c33c5db7 100644 --- a/crates/tui/src/tui/composer_ui.rs +++ b/crates/tui/src/tui/composer_ui.rs @@ -56,26 +56,25 @@ pub(crate) fn handle_composer_history_arrow( return false; } - // When `composer_arrows_scroll` is enabled and the composer is empty, - // plain Up/Down scroll the transcript. This helps terminals that map - // trackpad gestures to arrow keys. Otherwise arrows always navigate - // input history regardless of composer state (#1117). - let scroll_on_empty = app.composer_arrows_scroll && app.input.trim().is_empty(); + // When `composer_arrows_scroll` is enabled, plain Up/Down scroll the + // transcript for single-line drafts. Multiline composers keep editor-like + // line navigation, with history fallback at the first/last line. + let scroll_transcript = app.composer_arrows_scroll && !app.input.contains('\n'); match key.code { KeyCode::Up => { - if scroll_on_empty { + if scroll_transcript { app.scroll_up(COMPOSER_ARROW_SCROLL_LINES); } else { - app.history_up(); + app.vim_move_up(); } true } KeyCode::Down => { - if scroll_on_empty { + if scroll_transcript { app.scroll_down(COMPOSER_ARROW_SCROLL_LINES); } else { - app.history_down(); + app.vim_move_down(); } true } diff --git a/crates/tui/src/tui/pager.rs b/crates/tui/src/tui/pager.rs index f646c2db..a67339c7 100644 --- a/crates/tui/src/tui/pager.rs +++ b/crates/tui/src/tui/pager.rs @@ -15,7 +15,7 @@ use std::cell::Cell; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; use ratatui::{ buffer::Buffer, layout::Rect, @@ -124,10 +124,9 @@ impl PagerView { } fn max_scroll(&self) -> usize { - // Match the existing 1-line scroll convention used by `j`/`k`. Render - // clamps `self.scroll` to `lines.len() - visible_height` for display - // purposes, so over-scrolling here is harmless. - self.lines.len().saturating_sub(1) + // Match the render-side clamp so G/End land at the visible bottom and + // k/Up immediately scroll back up by one line. + self.lines.len().saturating_sub(self.page_height()) } fn start_search(&mut self) { @@ -351,6 +350,22 @@ impl ModalView for PagerView { } } + fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { + match mouse.kind { + MouseEventKind::ScrollUp => { + self.scroll_up(3); + self.pending_g = false; + ViewAction::None + } + MouseEventKind::ScrollDown => { + self.scroll_down(3, self.max_scroll()); + self.pending_g = false; + ViewAction::None + } + _ => ViewAction::None, + } + } + fn render(&self, area: Rect, buf: &mut Buffer) { let popup_width = area.width.saturating_sub(2).max(1); let popup_height = area.height.saturating_sub(2).max(1); @@ -617,6 +632,32 @@ mod tests { assert_eq!(p.scroll, p.max_scroll()); } + #[test] + fn up_immediately_scrolls_after_shift_g_to_bottom() { + let mut p = make_pager(50); + prime_layout(&mut p, 22); + let bottom = p.max_scroll(); + + let _ = p.handle_key(key(KeyCode::Char('G'))); + assert_eq!(p.scroll, bottom); + let _ = p.handle_key(key(KeyCode::Up)); + assert_eq!(p.scroll, bottom - 1); + let _ = p.handle_key(key(KeyCode::Char('k'))); + assert_eq!(p.scroll, bottom - 2); + } + + #[test] + fn k_immediately_scrolls_after_end_to_bottom() { + let mut p = make_pager(50); + prime_layout(&mut p, 22); + let bottom = p.max_scroll(); + + let _ = p.handle_key(key(KeyCode::End)); + assert_eq!(p.scroll, bottom); + let _ = p.handle_key(key(KeyCode::Char('k'))); + assert_eq!(p.scroll, bottom - 1); + } + #[test] fn ctrl_d_half_page_down() { let mut p = make_pager(200); @@ -913,6 +954,55 @@ mod tests { ); } + #[test] + fn mouse_scroll_up_scrolls_content() { + let mut p = make_pager(50); + p.scroll = 10; + let action = p.handle_mouse(MouseEvent { + kind: MouseEventKind::ScrollUp, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }); + + assert_eq!(p.scroll, 7); + assert!(matches!(action, ViewAction::None)); + } + + #[test] + fn mouse_scroll_down_scrolls_content() { + let mut p = make_pager(50); + prime_layout(&mut p, 20); + p.scroll = 10; + let action = p.handle_mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }); + + assert_eq!(p.scroll, 13); + assert!(matches!(action, ViewAction::None)); + } + + #[test] + fn mouse_scroll_down_clamps_to_pager_bottom() { + let mut p = make_pager(50); + prime_layout(&mut p, 20); + let bottom = p.max_scroll(); + + for _ in 0..100 { + let _ = p.handle_mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }); + } + + assert_eq!(p.scroll, bottom); + } + #[test] fn wrap_text_breaks_overlong_cjk_runs() { let text = "这是一个非常长的中文字符串".repeat(10); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 4d2748a8..520bda73 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -496,6 +496,7 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), + system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), }) @@ -2300,6 +2301,7 @@ async fn run_event_loop( session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), + system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), }) @@ -3177,6 +3179,7 @@ async fn run_event_loop( session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), + system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), }) @@ -4244,6 +4247,7 @@ async fn switch_provider( session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), + system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), }) @@ -4356,6 +4360,7 @@ async fn apply_command_result( session_id, messages, system_prompt, + system_prompt_override: false, model, workspace, }) @@ -4673,6 +4678,7 @@ async fn apply_command_result( session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), + system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), }) @@ -4819,6 +4825,7 @@ async fn switch_workspace( session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), + system_prompt_override: false, model: app.model.clone(), workspace: workspace.clone(), }) @@ -5808,6 +5815,7 @@ async fn handle_view_events( session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), + system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), }) @@ -5951,6 +5959,7 @@ async fn handle_view_events( session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), + system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), }) diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index b6d00045..edbabbae 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5330,6 +5330,9 @@ fn history_arrow_handles_whitespace_input() { #[test] fn history_arrow_handles_nonempty_input() { let mut app = create_test_app(); + // Explicitly disable arrows-scroll so this test covers the + // history-navigation path regardless of the mouse-capture default. + app.composer_arrows_scroll = false; app.input = "hello".to_string(); app.cursor_position = app.input.chars().count(); app.input_history.push("previous prompt".to_string()); @@ -5375,20 +5378,98 @@ fn composer_arrows_scroll_empty_down() { } #[test] -fn composer_arrows_scroll_nonempty_still_navigates_history() { +fn composer_arrows_scroll_nonempty_also_scrolls() { let mut app = create_test_app(); app.composer_arrows_scroll = true; app.input = "hello".to_string(); app.cursor_position = app.input.chars().count(); app.input_history.push("previous prompt".to_string()); - // Even with the option on, non-empty composer still navigates history. + // #1677: terminals that convert mouse-wheel to arrow keys should scroll + // the transcript without mutating a draft the user is editing. assert!(handle_composer_history_arrow( &mut app, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), false, false, )); + assert_eq!(app.viewport.pending_scroll_delta, -3); + assert_eq!(app.input, "hello"); +} + +#[test] +fn composer_arrow_up_moves_within_multiline_input() { + let mut app = create_test_app(); + app.composer_arrows_scroll = false; + app.input = "line one\nline two".to_string(); + app.cursor_position = app.input.chars().count(); + app.input_history.push("previous prompt".to_string()); + + assert!(handle_composer_history_arrow( + &mut app, + KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), + false, + false, + )); + + assert_eq!(app.input, "line one\nline two"); + assert!(app.cursor_position < app.input.chars().count()); +} + +#[test] +fn composer_arrow_down_moves_within_multiline_input() { + let mut app = create_test_app(); + app.composer_arrows_scroll = false; + app.input = "line one\nline two".to_string(); + app.cursor_position = 0; + app.input_history.push("next prompt".to_string()); + app.history_index = Some(app.input_history.len() - 1); + + assert!(handle_composer_history_arrow( + &mut app, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + false, + false, + )); + + assert_eq!(app.input, "line one\nline two"); + assert!(app.cursor_position >= "line one\n".chars().count()); +} + +#[test] +fn composer_arrows_scroll_multiline_input_navigates_lines() { + let mut app = create_test_app(); + app.composer_arrows_scroll = true; + app.input = "line one\nline two".to_string(); + app.cursor_position = app.input.chars().count(); + + assert!(handle_composer_history_arrow( + &mut app, + KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), + false, + false, + )); + + assert_eq!(app.input, "line one\nline two"); + assert!(app.cursor_position < app.input.chars().count()); + assert_eq!(app.viewport.pending_scroll_delta, 0); +} + +#[test] +fn composer_arrow_up_at_first_line_falls_back_to_history_up() { + let mut app = create_test_app(); + app.composer_arrows_scroll = false; + app.input = "line one\nline two".to_string(); + app.cursor_position = 0; + app.input_history.push("previous prompt".to_string()); + + assert!(handle_composer_history_arrow( + &mut app, + KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), + false, + false, + )); + assert_eq!(app.input, "previous prompt"); } diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index f7fb6f2e..e24432ff 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,7 +1,7 @@ { "name": "deepseek-tui", - "version": "0.8.38", - "deepseekBinaryVersion": "0.8.38", + "version": "0.8.39", + "deepseekBinaryVersion": "0.8.39", "description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.", "author": "Hmbown", "license": "MIT",