From 8dcb467bf5e4bce2fdaeae7750a64cc7d18a8aae Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 9 May 2026 00:11:28 -0500 Subject: [PATCH 01/11] feat(commands): scan workspace-local .deepseek/.cursor/.claude commands Extend load_user_commands() to scan workspace-local command directories in addition to the global ~/.deepseek/commands/. Precedence model mirrors skills_directories(): project-local shadows global by name. Scanned directories (in precedence order): 1. /.deepseek/commands/ 2. /.claude/commands/ (Claude Code interop) 3. /.cursor/commands/ (Cursor interop) 4. ~/.deepseek/commands/ (user-global fallback) Workspace context threaded through: - try_dispatch_user_command (has App reference) - user_commands_matching (new workspace parameter) - all_command_names_matching (new workspace parameter) - slash_completion_hints (new workspace parameter) Closes #1259 --- crates/tui/src/commands/mod.rs | 10 +- crates/tui/src/commands/user_commands.rs | 253 ++++++++++++++++++++--- crates/tui/src/tui/slash_menu.rs | 22 +- crates/tui/src/tui/widgets/mod.rs | 13 +- 4 files changed, 255 insertions(+), 43 deletions(-) diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 67d118c7..9446b7c3 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -719,7 +719,13 @@ pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> { /// Get all command names matching a prefix, including both built-in /// static commands and user-defined commands, formatted as `/name`. -pub fn all_command_names_matching(prefix: &str) -> Vec { +/// +/// `workspace` is used to also scan workspace-local command directories; +/// pass `None` when no workspace context is available. +pub fn all_command_names_matching( + prefix: &str, + workspace: Option<&std::path::Path>, +) -> Vec { let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase(); let mut result: Vec = COMMANDS .iter() @@ -730,7 +736,7 @@ pub fn all_command_names_matching(prefix: &str) -> Vec { .collect(); // Add user-defined commands - result.extend(user_commands::user_commands_matching(&prefix)); + result.extend(user_commands::user_commands_matching(&prefix, workspace)); result.sort(); result.dedup(); diff --git a/crates/tui/src/commands/user_commands.rs b/crates/tui/src/commands/user_commands.rs index 65b93c6d..d1314019 100644 --- a/crates/tui/src/commands/user_commands.rs +++ b/crates/tui/src/commands/user_commands.rs @@ -1,36 +1,54 @@ -//! User-defined slash commands from `~/.deepseek/commands/.md`. +//! User-defined slash commands from `~/.deepseek/commands/.md` and +//! workspace-local `/.deepseek/commands/.md`. //! -//! Users drop `.md` files into `~/.deepseek/commands/` and the filename +//! Users drop `.md` files into a commands directory and the filename //! (without `.md` extension) becomes a slash command. When invoked via //! `/name`, the file contents are sent as a user message. +//! +//! ## Precedence +//! +//! Workspace-local directories shadow user-global by name: +//! +//! 1. `/.deepseek/commands/` (project-local, highest) +//! 2. `/.claude/commands/` (Claude Code interop) +//! 3. `/.cursor/commands/` (Cursor interop) +//! 4. `~/.deepseek/commands/` (user-global, lowest) -use std::path::PathBuf; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; use crate::tui::app::{App, AppAction}; use super::CommandResult; -/// Path to the user commands directory: `~/.deepseek/commands/`. -fn commands_dir() -> PathBuf { +/// Path to the global user commands directory: `~/.deepseek/commands/`. +fn global_commands_dir() -> PathBuf { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")); home.join(".deepseek").join("commands") } -/// Scan `~/.deepseek/commands/` for `.md` files and return `(name, content)` pairs. -/// -/// The name is the filename without the `.md` extension, normalized to -/// lowercase. Files that fail to read are silently skipped. The directory -/// is re-scanned on every call so newly-added commands show up immediately -/// without requiring a restart. -pub fn load_user_commands() -> Vec<(String, String)> { - let dir = commands_dir(); +/// Return all candidate commands directories in precedence order. +fn commands_dirs(workspace: Option<&Path>) -> Vec { + let mut dirs = Vec::new(); + if let Some(ws) = workspace { + dirs.push(ws.join(".deepseek").join("commands")); + dirs.push(ws.join(".claude").join("commands")); + dirs.push(ws.join(".cursor").join("commands")); + } + dirs.push(global_commands_dir()); + dirs +} + +/// Scan a single commands directory for `.md` files and return +/// `(name, content)` pairs. Errors are silently skipped. +fn load_commands_from_dir(dir: &Path) -> Vec<(String, String)> { let mut commands: Vec<(String, String)> = Vec::new(); - if !dir.exists() { + if !dir.is_dir() { return commands; } - let entries = match std::fs::read_dir(&dir) { + let entries = match std::fs::read_dir(dir) { Ok(entries) => entries, Err(_) => return commands, }; @@ -51,6 +69,27 @@ pub fn load_user_commands() -> Vec<(String, String)> { commands.push((stem, content)); } + commands +} + +/// Scan every candidate commands directory and return merged +/// `(name, content)` pairs. Workspace-local directories shadow +/// user-global by name — the first occurrence of a name wins. +/// +/// Pass `None` for the workspace to scan only the global directory +/// (backward-compatible with callers that don't have workspace context). +pub fn load_user_commands(workspace: Option<&Path>) -> Vec<(String, String)> { + let mut seen: HashSet = HashSet::new(); + let mut commands: Vec<(String, String)> = Vec::new(); + + for dir in commands_dirs(workspace) { + for (name, content) in load_commands_from_dir(&dir) { + if seen.insert(name.clone()) { + commands.push((name, content)); + } + } + } + // Sort by name for deterministic ordering. commands.sort_by(|a, b| a.0.cmp(&b.0)); commands @@ -72,13 +111,13 @@ fn apply_template(template: &str, args: &str) -> String { result } -pub fn try_dispatch_user_command(_app: &mut App, input: &str) -> Option { +pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option { let parts: Vec<&str> = input.trim().splitn(2, ' ').collect(); let command = parts[0].to_lowercase(); let command = command.strip_prefix('/').unwrap_or(&command); let args = parts.get(1).copied().unwrap_or("").trim(); - let user_commands = load_user_commands(); + let user_commands = load_user_commands(Some(&app.workspace)); for (name, content) in &user_commands { if name == command { @@ -94,9 +133,12 @@ pub fn try_dispatch_user_command(_app: &mut App, input: &str) -> Option Vec { +/// +/// `workspace` is used to also scan workspace-local command directories; +/// pass `None` when no workspace context is available. +pub fn user_commands_matching(prefix: &str, workspace: Option<&Path>) -> Vec { let prefix = prefix.to_lowercase(); - load_user_commands() + load_user_commands(workspace) .into_iter() .filter(|(name, _)| name.starts_with(&prefix)) .map(|(name, _)| format!("/{}", name)) @@ -106,10 +148,11 @@ pub fn user_commands_matching(prefix: &str) -> Vec { #[cfg(test)] mod tests { use super::*; + use tempfile::TempDir; #[test] - fn test_commands_dir_contains_deepseek_commands() { - let dir = commands_dir(); + fn test_global_commands_dir_contains_deepseek_commands() { + let dir = global_commands_dir(); let parts: Vec<_> = dir .components() .filter_map(|component| component.as_os_str().to_str()) @@ -124,13 +167,9 @@ mod tests { } #[test] - fn test_load_user_commands_when_dir_absent() { - // Use a temp dir that definitely doesn't have a commands dir. - let _tmp = std::env::temp_dir().join("deepseek-test-nonexistent"); - // Temporarily override the home for this test by checking the - // function with a non-existent directory path. - let cmds = load_user_commands(); - // Should not panic; returns empty vec when dir doesn't exist. + fn test_load_user_commands_when_no_dir_exists() { + let cmds = load_user_commands(None); + // Should not panic; returns empty vec when no directories exist. assert!(cmds.is_empty() || !cmds.is_empty()); } @@ -166,8 +205,162 @@ mod tests { } #[test] - fn test_user_commands_matching_with_prefix() { - let matches = user_commands_matching("zzzznotfound"); + fn test_user_commands_matching_with_prefix_no_workspace() { + let matches = user_commands_matching("zzzznotfound", None); assert!(matches.is_empty()); } + + // ── Workspace-local commands tests ───────────────────────────────── + + fn write_command(dir: &Path, name: &str, body: &str) { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join(format!("{name}.md")), body).unwrap(); + } + + #[test] + fn load_user_commands_scans_workspace_local_dir() { + let tmp = TempDir::new().unwrap(); + let ws = tmp.path(); + let cmds_dir = ws.join(".deepseek").join("commands"); + write_command(&cmds_dir, "hello", "echo hi"); + + let cmds = load_user_commands(Some(ws)); + let names: Vec<&str> = cmds.iter().map(|(n, _)| n.as_str()).collect(); + assert!( + names.contains(&"hello"), + "expected 'hello' in workspace-local commands: {names:?}" + ); + } + + #[test] + fn load_user_commands_scans_claude_and_cursor_dirs() { + let tmp = TempDir::new().unwrap(); + let ws = tmp.path(); + write_command( + &ws.join(".claude").join("commands"), + "claude-cmd", + "claude body", + ); + write_command( + &ws.join(".cursor").join("commands"), + "cursor-cmd", + "cursor body", + ); + + let cmds = load_user_commands(Some(ws)); + let names: Vec<&str> = cmds.iter().map(|(n, _)| n.as_str()).collect(); + assert!( + names.contains(&"claude-cmd"), + "expected 'claude-cmd': {names:?}" + ); + assert!( + names.contains(&"cursor-cmd"), + "expected 'cursor-cmd': {names:?}" + ); + } + + #[test] + fn workspace_local_shadows_global_by_name() { + let tmp = TempDir::new().unwrap(); + let ws = tmp.path(); + + // Workspace-local version + write_command( + &ws.join(".deepseek").join("commands"), + "shared", + "workspace version", + ); + // Global version — simulate by putting it in a "global" temp dir. + // Since we can't easily override `dirs::home_dir()`, we test the + // first-match-wins semantics by putting the same name in both + // workspace-scanned dirs. The first dir in precedence order wins. + write_command( + &ws.join(".claude").join("commands"), + "shared", + "claude version", + ); + + let cmds = load_user_commands(Some(ws)); + let shared = cmds + .iter() + .find(|(n, _)| n == "shared") + .expect("shared present"); + assert_eq!( + shared.1, "workspace version", + "workspace-local (.deepseek) must shadow later dirs" + ); + } + + #[test] + fn load_user_commands_without_workspace_falls_back_to_global_only() { + // When no workspace is passed, only the global ~/.deepseek/commands/ + // is scanned. On test machines this dir often doesn't exist, so we + // just verify we don't panic. + let cmds = load_user_commands(None); + // This should not panic; can be empty or have user's real commands. + let _ = cmds; + } + + #[test] + fn try_dispatch_uses_workspace_local_command() { + use crate::config::Config; + use crate::tui::app::TuiOptions; + + let tmp = TempDir::new().unwrap(); + let ws = tmp.path().to_path_buf(); + write_command( + &ws.join(".deepseek").join("commands"), + "hello", + "Hello, $ARGUMENTS!", + ); + + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: ws.clone(), + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: PathBuf::from("."), + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.txt"), + mcp_config_path: PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }; + let mut app = App::new(options, &Config::default()); + let result = try_dispatch_user_command(&mut app, "/hello world"); + assert!(result.is_some()); + let cmd_result = result.unwrap(); + match cmd_result.action { + Some(AppAction::SendMessage(msg)) => { + assert!(msg.contains("Hello, world!"), "got: {msg}"); + } + other => panic!("expected SendMessage action, got: {other:?}"), + } + } + + #[test] + fn user_commands_matching_with_workspace() { + let tmp = TempDir::new().unwrap(); + let ws = tmp.path(); + write_command( + &ws.join(".deepseek").join("commands"), + "project-cmd", + "body", + ); + + let matches = user_commands_matching("project", Some(ws)); + assert!( + matches.contains(&"/project-cmd".to_string()), + "got: {matches:?}" + ); + } } diff --git a/crates/tui/src/tui/slash_menu.rs b/crates/tui/src/tui/slash_menu.rs index ca87470a..a6ffd7c3 100644 --- a/crates/tui/src/tui/slash_menu.rs +++ b/crates/tui/src/tui/slash_menu.rs @@ -20,7 +20,13 @@ pub fn visible_slash_menu_entries(app: &App, limit: usize) -> Vec bool { return false; } - let candidates = slash_completion_hints(&app.input, 128, &app.cached_skills, app.ui_locale) - .into_iter() - .map(|entry| entry.name) - .collect::>(); + let candidates = slash_completion_hints( + &app.input, + 128, + &app.cached_skills, + app.ui_locale, + Some(&app.workspace), + ) + .into_iter() + .map(|entry| entry.name) + .collect::>(); if candidates.is_empty() { return false; diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index a5da3500..4fcf47d9 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1953,6 +1953,7 @@ pub(crate) fn slash_completion_hints( limit: usize, cached_skills: &[(String, String)], locale: crate::localization::Locale, + workspace: Option<&std::path::Path>, ) -> Vec { if !input.starts_with('/') { return Vec::new(); @@ -1970,7 +1971,7 @@ pub(crate) fn slash_completion_hints( // built-in ones from the static registry and use a generic label for // user-defined commands. if completing_skill_arg.is_none() { - for name in commands::all_command_names_matching(prefix) { + for name in commands::all_command_names_matching(prefix, workspace) { let command_key = name.trim_start_matches('/'); let description = if let Some(info) = commands::get_command_info(command_key) { info.description_for(locale).to_string() @@ -2359,14 +2360,14 @@ mod tests { #[test] fn slash_completion_hints_include_links_and_config() { - let hints = slash_completion_hints("/", 128, &[], Locale::En); + let hints = slash_completion_hints("/", 128, &[], Locale::En, None); assert!(hints.iter().any(|hint| hint.name == "/config")); assert!(hints.iter().any(|hint| hint.name == "/links")); } #[test] fn slash_completion_hints_exclude_set_and_deepseek_commands() { - let hints = slash_completion_hints("/", 128, &[], Locale::En); + let hints = slash_completion_hints("/", 128, &[], Locale::En, None); assert!(!hints.iter().any(|hint| hint.name == "/set")); assert!(!hints.iter().any(|hint| hint.name == "/deepseek")); } @@ -2377,7 +2378,7 @@ mod tests { ("search-files".to_string(), "Search files".to_string()), ("my-review".to_string(), "Review code".to_string()), ]; - let hints = slash_completion_hints("/", 128, &cached_skills, Locale::En); + let hints = slash_completion_hints("/", 128, &cached_skills, Locale::En, None); assert!( hints .iter() @@ -2396,7 +2397,7 @@ mod tests { ("search-files".to_string(), "Search files".to_string()), ("my-review".to_string(), "Review code".to_string()), ]; - let hints = slash_completion_hints("/se", 128, &cached_skills, Locale::En); + let hints = slash_completion_hints("/se", 128, &cached_skills, Locale::En, None); assert!( hints .iter() @@ -2411,7 +2412,7 @@ mod tests { ("search-files".to_string(), "Search files".to_string()), ("my-review".to_string(), "Review code".to_string()), ]; - let hints = slash_completion_hints("/skill my", 128, &cached_skills, Locale::En); + let hints = slash_completion_hints("/skill my", 128, &cached_skills, Locale::En, None); assert_eq!(hints.len(), 1); assert_eq!(hints[0].name, "/skill my-review"); assert!(hints[0].is_skill); From 408708f13e3bcd81041f086b8fd61259a1e1792b Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 9 May 2026 00:11:40 -0500 Subject: [PATCH 02/11] fix(walk): include AI-tool dot-dirs in @-completion despite gitignore Files inside .deepseek/, .cursor/, .claude/, and .agents/ were invisible to @-mention Tab-completion when those directories were listed in .gitignore (the common case). The walk_for_completions and build_file_index paths now walk these directories separately with gitignore disabled, merging results into the main candidate list. Also excludes .deepseek/snapshots/ from the dot-dir walk so the snapshot side repo (which can reach hundreds of GB, see #1112) is never indexed. The fix applies to: - @-mention Tab-completion (walk_for_completions) - Fuzzy file resolution (build_file_index) - Ctrl+P file picker (collect_candidates) --- crates/tui/src/tui/file_picker.rs | 38 +++++ crates/tui/src/working_set.rs | 272 ++++++++++++++++++++++++++++-- 2 files changed, 299 insertions(+), 11 deletions(-) diff --git a/crates/tui/src/tui/file_picker.rs b/crates/tui/src/tui/file_picker.rs index c74d1b69..a4f81c67 100644 --- a/crates/tui/src/tui/file_picker.rs +++ b/crates/tui/src/tui/file_picker.rs @@ -434,6 +434,44 @@ fn collect_candidates(root: &Path) -> Vec { break; } } + + // Whitelist AI-tool dot-directories so they're discoverable even when + // gitignored. Walk each one separately with gitignore disabled. + for dir in [".deepseek", ".cursor", ".claude", ".agents"] { + let dot_dir = root.join(dir); + if !dot_dir.is_dir() { + continue; + } + let mut dot_builder = WalkBuilder::new(&dot_dir); + dot_builder + .hidden(true) + .follow_links(false) + .git_ignore(false) + .ignore(false) + .max_depth(Some(WALK_DEPTH.saturating_sub(1))); + for entry in dot_builder.build().flatten() { + // Exclude machine-generated bulk (e.g. .deepseek/snapshots/). + if entry.path().starts_with(root.join(".deepseek/snapshots")) { + continue; + } + if !entry.file_type().is_some_and(|ft| ft.is_file()) { + continue; + } + let path = entry.path(); + let rel = path.strip_prefix(root).unwrap_or(path); + if rel.as_os_str().is_empty() { + continue; + } + let display = path_to_workspace_string(rel); + if !display.is_empty() { + out.push(display); + } + if out.len() >= MAX_CANDIDATES { + break; + } + } + } + out.sort(); out } diff --git a/crates/tui/src/working_set.rs b/crates/tui/src/working_set.rs index 21eb94d5..2688b0d3 100644 --- a/crates/tui/src/working_set.rs +++ b/crates/tui/src/working_set.rs @@ -93,11 +93,7 @@ impl Workspace { fn build_file_index(&self) -> HashMap> { let mut index: HashMap> = HashMap::new(); - let mut builder = WalkBuilder::new(&self.root); - builder.hidden(true).follow_links(false).max_depth(Some(6)); - // Honor `.deepseekignore` in addition to the defaults the `ignore` crate - // already respects (`.gitignore`, `.git/info/exclude`, `.ignore`). - let _ = builder.add_custom_ignore_filename(".deepseekignore"); + let builder = discovery_walk_builder(&self.root, Some(6)); for entry in builder.build().flatten() { if entry @@ -111,6 +107,37 @@ impl Workspace { .push(entry.path().to_path_buf()); } } + + // Also index AI-tool dot-directories with gitignore disabled. + for dir_name in DISCOVERY_ALWAYS_DIRS { + let dot_dir = self.root.join(dir_name); + if !dot_dir.is_dir() { + continue; + } + let mut dot_builder = WalkBuilder::new(&dot_dir); + dot_builder + .hidden(true) + .follow_links(false) + .git_ignore(false) + .ignore(false) + .max_depth(Some(5)); + for entry in dot_builder.build().flatten() { + // Exclude machine-generated bulk (e.g. .deepseek/snapshots/). + if path_is_excluded_from_discovery(&self.root, entry.path()) { + continue; + } + if entry + .file_type() + .is_some_and(|ft| ft.is_file() || ft.is_dir()) + { + let name = entry.file_name().to_string_lossy().to_lowercase(); + index + .entry(name) + .or_default() + .push(entry.path().to_path_buf()); + } + } + } index } @@ -180,6 +207,111 @@ impl Workspace { /// monorepos. const COMPLETIONS_WALK_DEPTH: usize = 6; +/// Directories that must remain discoverable for `@`-mention completion and +/// fuzzy file resolution even when excluded by `.gitignore`. AI-tool +/// convention directories (`.deepseek/`, `.cursor/`, `.claude/`, `.agents/`) +/// are routinely gitignored, but users need to `@`-mention files inside them. +const DISCOVERY_ALWAYS_DIRS: &[&str] = &[".deepseek", ".cursor", ".claude", ".agents"]; + +/// Subdirectories under `DISCOVERY_ALWAYS_DIRS` that must NOT be indexed +/// even when the parent dir is walked with gitignore disabled. These are +/// large, machine-generated, or sensitive paths that would blow up the +/// walker (e.g. `.deepseek/snapshots/` — the snapshot side repo that +/// #1112 caps at 500 MB; indexing it would trigger the same OOM/hang +/// the cap was built to prevent). +const DISCOVERY_EXCLUDED_SUBDIRS: &[&str] = &[".deepseek/snapshots"]; + +/// Check whether a path resolved against `walk_root` falls inside any +/// `DISCOVERY_EXCLUDED_SUBDIRS` entry. Used to keep the snapshot side +/// repo (`.deepseek/snapshots/`) out of the completion/index walk. +fn path_is_excluded_from_discovery(walk_root: &Path, path: &Path) -> bool { + for excluded in DISCOVERY_EXCLUDED_SUBDIRS { + if path.starts_with(walk_root.join(excluded)) { + return true; + } + } + false +} + +/// Configure a `WalkBuilder` for workspace discovery: hidden files, no +/// symlink following, depth-limited, custom `.deepseekignore` honored, +/// and gitignore overrides for AI-tool dot-directories so `@`-completion +/// finds them even when they're gitignored. +fn discovery_walk_builder(root: &Path, max_depth: Option) -> WalkBuilder { + let mut builder = WalkBuilder::new(root); + builder.hidden(true).follow_links(false); + if let Some(depth) = max_depth { + builder.max_depth(Some(depth)); + } + let _ = builder.add_custom_ignore_filename(".deepseekignore"); + builder +} + +/// Walk the AI-tool dot-directories (`.deepseek/`, `.cursor/`, `.claude/`, +/// `.agents/`) with gitignore disabled so their contents are discoverable +/// even when the project's `.gitignore` / `.ignore` excludes them. +#[allow(clippy::too_many_arguments)] +fn walk_always_discoverable_dirs( + walk_root: &Path, + display_root: &Path, + needle: &str, + limit: usize, + prefix_hits: &mut Vec, + substring_hits: &mut Vec, + seen: &mut HashSet, + max_depth: Option, +) { + for dir_name in DISCOVERY_ALWAYS_DIRS { + let dot_dir = walk_root.join(dir_name); + if !dot_dir.is_dir() { + continue; + } + let mut builder = WalkBuilder::new(&dot_dir); + builder + .hidden(true) + .follow_links(false) + .git_ignore(false) + .ignore(false); + if let Some(depth) = max_depth { + builder.max_depth(Some(depth.saturating_sub(1))); + } + for entry in builder.build().flatten() { + if prefix_hits.len() + substring_hits.len() >= limit { + break; + } + let path = entry.path(); + // Exclude machine-generated bulk (e.g. .deepseek/snapshots/) + // even though gitignore is disabled for this walk. + if path_is_excluded_from_discovery(walk_root, path) { + continue; + } + let Ok(rel) = path.strip_prefix(display_root) else { + continue; + }; + let rel_str = rel.to_string_lossy().replace('\\', "/"); + if rel_str.is_empty() { + continue; + } + let abs = path.to_path_buf(); + if !seen.insert(abs) { + continue; + } + let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir()); + let candidate = if is_dir { + format!("{rel_str}/") + } else { + rel_str.clone() + }; + let lower = candidate.to_lowercase(); + if needle.is_empty() || lower.starts_with(needle) { + prefix_hits.push(candidate); + } else if lower.contains(needle) { + substring_hits.push(candidate); + } + } + } +} + #[allow(clippy::too_many_arguments)] fn walk_for_completions( walk_root: &Path, @@ -190,12 +322,7 @@ fn walk_for_completions( substring_hits: &mut Vec, seen: &mut HashSet, ) { - let mut builder = WalkBuilder::new(walk_root); - builder - .hidden(true) - .follow_links(false) - .max_depth(Some(COMPLETIONS_WALK_DEPTH)); - let _ = builder.add_custom_ignore_filename(".deepseekignore"); + let builder = discovery_walk_builder(walk_root, Some(COMPLETIONS_WALK_DEPTH)); for entry in builder.build().flatten() { if prefix_hits.len() + substring_hits.len() >= limit { @@ -228,6 +355,19 @@ fn walk_for_completions( substring_hits.push(candidate); } } + + // Also walk the AI-tool dot-directories with gitignore disabled so + // `.deepseek/`, `.cursor/`, etc. are always discoverable. + walk_always_discoverable_dirs( + walk_root, + display_root, + needle, + limit, + prefix_hits, + substring_hits, + seen, + Some(COMPLETIONS_WALK_DEPTH), + ); } impl Clone for Workspace { @@ -1195,4 +1335,114 @@ mod tests { // Index was populated exactly once (subsequent lookups reuse it). assert!(ws.file_index.get().is_some()); } + + /// Regression: `@`-mention completion must discover files inside + /// `.deepseek/`, `.cursor/`, `.claude/`, `.agents/` even when + /// those directories are excluded by `.gitignore` (or `.ignore`). + /// The `discovery_walk_builder` override un-ignores them. + #[test] + fn completions_discovers_files_inside_gitignored_dot_dirs() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + // `.ignore` works even outside a git repo; use it to simulate + // a project that gitignores its AI-tool dot-directories. + std::fs::write( + root.join(".ignore"), + ".deepseek/\n.cursor/\n.claude/\n.agents/\n", + ) + .unwrap(); + + // Create files inside each dot-dir. + std::fs::create_dir_all(root.join(".deepseek/commands")).unwrap(); + std::fs::write(root.join(".deepseek/commands/build.md"), "build cmd").unwrap(); + std::fs::create_dir_all(root.join(".cursor/commands")).unwrap(); + std::fs::write(root.join(".cursor/commands/run.md"), "run cmd").unwrap(); + std::fs::create_dir_all(root.join(".claude/commands")).unwrap(); + std::fs::write(root.join(".claude/commands/test.md"), "test cmd").unwrap(); + std::fs::create_dir_all(root.join(".agents/skills/example")).unwrap(); + std::fs::write( + root.join(".agents/skills/example/SKILL.md"), + "name: example\n", + ) + .unwrap(); + + let ws = Workspace::with_cwd(root.to_path_buf(), None); + + // Completions should find entries inside the dot-dirs. + { + let entries = ws.completions("build", 16); + assert!( + entries.iter().any(|e| e.contains("build.md")), + "expected build.md in completions although .deepseek/ is ignored; got: {entries:?}" + ); + } + { + let entries = ws.completions("run", 16); + assert!( + entries.iter().any(|e| e.contains("run.md")), + "expected run.md from .cursor/; got: {entries:?}" + ); + } + { + let entries = ws.completions("test", 16); + assert!( + entries.iter().any(|e| e.contains("test.md")), + "expected test.md from .claude/; got: {entries:?}" + ); + } + + // Fuzzy resolution should also work. + let f = ws.resolve("build.md").unwrap(); + assert!(f.ends_with("build.md")); + let f2 = ws.resolve("SKILL.md").unwrap(); + assert!(f2.ends_with("SKILL.md")); + } + + /// Regression: the dot-dir walk must NOT index `.deepseek/snapshots/`, + /// which is the snapshot side repo that can grow to hundreds of GB. + /// Indexing it would re-create the same OOM/hang that #1112 was built + /// to prevent. + #[test] + fn dot_dir_walk_excludes_snapshot_side_repo() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + // Create a snapshot-like directory tree. + std::fs::create_dir_all(root.join(".deepseek/snapshots/deadbeef/deadbeef/.git/objects")) + .unwrap(); + std::fs::write( + root.join(".deepseek/snapshots/deadbeef/deadbeef/.git/objects/snapshot.pack"), + b"fake pack data", + ) + .unwrap(); + // Also create a legitimate file in .deepseek/ that should be found. + std::fs::create_dir_all(root.join(".deepseek/commands")).unwrap(); + std::fs::write(root.join(".deepseek/commands/build.md"), "build cmd").unwrap(); + + let ws = Workspace::with_cwd(root.to_path_buf(), None); + + // Searching for "build" must find build.md. + let entries = ws.completions("build", 16); + assert!( + entries.iter().any(|e| e.contains("build.md")), + "build.md must still be found; got: {entries:?}" + ); + // Searching for "snapshot" must NOT return snapshot files. + let snap_entries = ws.completions("snapshot", 16); + assert!( + !snap_entries.iter().any(|e| e.contains("snapshot")), + "snapshot files must NOT appear in completions; got: {snap_entries:?}" + ); + + // Fuzzy index must also exclude snapshots. + let f = ws.resolve("build.md").unwrap(); + assert!(f.ends_with("build.md")); + // snapshot.pack should NOT resolve. + let result = ws.resolve("snapshot.pack"); + assert!( + result.is_err(), + "snapshot.pack must not resolve via fuzzy index" + ); + } } From 808e981f56ae230a09e2c713b6ff797cdcc0ce4d Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 9 May 2026 00:11:51 -0500 Subject: [PATCH 03/11] fix(mcp): paginate tools/resources/prompts discovery via nextCursor (#1250, #1256) MCP servers are allowed by spec to paginate list responses. The old implementation made a single request and stopped, silently dropping subsequent pages. Servers that paginate at fewer items than their total tool count (e.g. gbrain at 5 per page) would appear to expose only those first few tools. All four discovery methods now follow nextCursor until the server signals no more pages, accumulating results across all pages: - discover_tools - discover_resources - discover_resource_templates - discover_prompts Thanks to Liu-Vince for the original diagnosis and fix (PR #1256). --- crates/tui/src/mcp.rs | 174 +++++++++++++++++++++++++++++------------- 1 file changed, 120 insertions(+), 54 deletions(-) diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index 8a3d36ab..57807ae2 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -774,94 +774,160 @@ impl McpConnection { /// Discover available tools from the MCP server async fn discover_tools(&mut self) -> Result<()> { - let list_id = self.next_id(); - self.send(serde_json::json!({ - "jsonrpc": "2.0", - "id": list_id, - "method": "tools/list", - "params": {} - })) - .await?; + let mut cursor: Option = None; + loop { + let list_id = self.next_id(); + let params = match &cursor { + Some(c) => serde_json::json!({ "cursor": c }), + None => serde_json::json!({}), + }; + self.send(serde_json::json!({ + "jsonrpc": "2.0", + "id": list_id, + "method": "tools/list", + "params": params + })) + .await?; - let response = self.recv(list_id).await?; + let response = self.recv(list_id).await?; + let Some(result) = response.get("result") else { + break; + }; - if let Some(result) = response.get("result") - && let Some(tools) = result.get("tools") - { - self.tools = serde_json::from_value(tools.clone()).unwrap_or_default(); + if let Some(tools) = result.get("tools") { + let page: Vec = serde_json::from_value(tools.clone()).unwrap_or_default(); + self.tools.extend(page); + } + + cursor = result + .get("nextCursor") + .and_then(|v| v.as_str()) + .map(str::to_owned); + if cursor.is_none() { + break; + } } - Ok(()) } /// Discover available resources from the MCP server async fn discover_resources(&mut self) -> Result<()> { - let list_id = self.next_id(); - self.send(serde_json::json!({ - "jsonrpc": "2.0", - "id": list_id, - "method": "resources/list", - "params": {} - })) - .await?; + let mut cursor: Option = None; + loop { + let list_id = self.next_id(); + let params = match &cursor { + Some(c) => serde_json::json!({ "cursor": c }), + None => serde_json::json!({}), + }; + self.send(serde_json::json!({ + "jsonrpc": "2.0", + "id": list_id, + "method": "resources/list", + "params": params + })) + .await?; - let response = self.recv(list_id).await?; + let response = self.recv(list_id).await?; + let Some(result) = response.get("result") else { + break; + }; - if let Some(result) = response.get("result") - && let Some(resources) = result.get("resources") - { - self.resources = serde_json::from_value(resources.clone()).unwrap_or_default(); + if let Some(resources) = result.get("resources") { + let page: Vec = + serde_json::from_value(resources.clone()).unwrap_or_default(); + self.resources.extend(page); + } + + cursor = result + .get("nextCursor") + .and_then(|v| v.as_str()) + .map(str::to_owned); + if cursor.is_none() { + break; + } } - Ok(()) } /// Discover available resource templates from the MCP server async fn discover_resource_templates(&mut self) -> Result<()> { - let list_id = self.next_id(); - self.send(serde_json::json!({ - "jsonrpc": "2.0", - "id": list_id, - "method": "resources/templates/list", - "params": {} - })) - .await?; + let mut cursor: Option = None; + loop { + let list_id = self.next_id(); + let params = match &cursor { + Some(c) => serde_json::json!({ "cursor": c }), + None => serde_json::json!({}), + }; + self.send(serde_json::json!({ + "jsonrpc": "2.0", + "id": list_id, + "method": "resources/templates/list", + "params": params + })) + .await?; - let response = self.recv(list_id).await?; + let response = self.recv(list_id).await?; + let Some(result) = response.get("result") else { + break; + }; - if let Some(result) = response.get("result") { let templates = result .get("resourceTemplates") .or_else(|| result.get("templates")) .or_else(|| result.get("resource_templates")); if let Some(templates) = templates { - self.resource_templates = + let page: Vec = serde_json::from_value(templates.clone()).unwrap_or_default(); + self.resource_templates.extend(page); + } + + cursor = result + .get("nextCursor") + .and_then(|v| v.as_str()) + .map(str::to_owned); + if cursor.is_none() { + break; } } - Ok(()) } /// Discover available prompts from the MCP server async fn discover_prompts(&mut self) -> Result<()> { - let list_id = self.next_id(); - self.send(serde_json::json!({ - "jsonrpc": "2.0", - "id": list_id, - "method": "prompts/list", - "params": {} - })) - .await?; + let mut cursor: Option = None; + loop { + let list_id = self.next_id(); + let params = match &cursor { + Some(c) => serde_json::json!({ "cursor": c }), + None => serde_json::json!({}), + }; + self.send(serde_json::json!({ + "jsonrpc": "2.0", + "id": list_id, + "method": "prompts/list", + "params": params + })) + .await?; - let response = self.recv(list_id).await?; + let response = self.recv(list_id).await?; + let Some(result) = response.get("result") else { + break; + }; - if let Some(result) = response.get("result") - && let Some(prompts) = result.get("prompts") - { - self.prompts = serde_json::from_value(prompts.clone()).unwrap_or_default(); + if let Some(prompts) = result.get("prompts") { + let page: Vec = + serde_json::from_value(prompts.clone()).unwrap_or_default(); + self.prompts.extend(page); + } + + cursor = result + .get("nextCursor") + .and_then(|v| v.as_str()) + .map(str::to_owned); + if cursor.is_none() { + break; + } } - Ok(()) } From b29d63b0c72fb9b651e746815dc2319ba849e8f2 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 9 May 2026 00:12:03 -0500 Subject: [PATCH 04/11] fix(snapshot): hard-cap side repo at 500 MB (#1112) Adds a disk-space guard before each snapshot: if the snapshot directory exceeds MAX_SNAPSHOT_SIZE_MB (500 MB), the oldest snapshots are pruned aggressively to stay under PRUNE_TARGET_MB (400 MB). If pruning cannot get under the limit, the history is wiped so the next snapshot starts fresh. The guard runs in snapshot() before git add/write-tree/commit-tree so disk pressure is relieved BEFORE taking another snapshot. Includes dir_size_mb() helper and regression tests. Thanks to Giggitycountless for the original max_size_mb proposal (PR #1131). --- crates/tui/src/snapshot/repo.rs | 115 ++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/crates/tui/src/snapshot/repo.rs b/crates/tui/src/snapshot/repo.rs index 53e6cc2a..296b94ca 100644 --- a/crates/tui/src/snapshot/repo.rs +++ b/crates/tui/src/snapshot/repo.rs @@ -50,6 +50,15 @@ pub struct SnapshotRepo { const STALE_TMP_PACK_AGE: Duration = Duration::from_secs(60 * 60); +/// Maximum total snapshot storage in megabytes before pruning kicks in at +/// snapshot time. Keeps the side repo from blowing up the user's disk during +/// long-running or high-churn sessions (#1112). +const MAX_SNAPSHOT_SIZE_MB: u64 = 500; + +/// Grace margin below `MAX_SNAPSHOT_SIZE_MB` used as the prune target +/// so the repo doesn't hit the limit again one snapshot later. +const PRUNE_TARGET_MB: u64 = 400; + const BUILTIN_EXCLUDES: &str = "\ # DeepSeek TUI built-in snapshot exclusions node_modules/ @@ -203,8 +212,53 @@ impl SnapshotRepo { /// `git add -A` honours the user's workspace ignore rules while staging /// into the side repo's index. /// + /// Before committing, checks whether the snapshot directory exceeds + /// [`MAX_SNAPSHOT_SIZE_MB`] and prunes the oldest snapshots if it does. + /// /// Returns the snapshot's commit SHA. pub fn snapshot(&self, label: &str) -> io::Result { + // Guard against disk blowup (#1112): if the snapshot directory has + // grown beyond the limit, prune aggressively before adding more. + if let Ok(current_mb) = dir_size_mb(&self.git_dir) + && current_mb > MAX_SNAPSHOT_SIZE_MB + { + tracing::warn!( + target: "snapshot", + current_mb, + limit_mb = MAX_SNAPSHOT_SIZE_MB, + "snapshot storage approaching limit — pruning aggressively" + ); + // Walk backward from a 1-second retention to zero until + // we're under the target, or until there's nothing left. + let mut age = Duration::from_secs(1); + for _ in 0..10 { + let _ = self.prune_older_than(age); + if let Ok(new_size) = dir_size_mb(&self.git_dir) + && new_size <= PRUNE_TARGET_MB + { + tracing::info!( + target: "snapshot", + new_size_mb = new_size, + "pruned snapshot storage back under limit" + ); + break; + } + age = age.saturating_sub(Duration::from_millis(100)); + } + // Fallback: if even 0-second pruning didn't help (shouldn't + // happen but belt-and-suspenders), nuke the refs so the next + // snapshot starts a fresh history. + if let Ok(final_size) = dir_size_mb(&self.git_dir) + && final_size > MAX_SNAPSHOT_SIZE_MB + { + tracing::warn!( + target: "snapshot", + "snapshot storage still over limit after pruning; wiping history" + ); + let _ = self.prune_older_than(Duration::ZERO); + let _ = self.prune_unreachable_objects(); + } + } // Stage every tracked + untracked path the workspace exposes. // `--all` here means `add` + `update` + `remove` — the same set // `git status` would show. @@ -517,6 +571,32 @@ fn write_builtin_excludes(git_dir: &Path) -> io::Result<()> { std::fs::write(info_dir.join("exclude"), BUILTIN_EXCLUDES) } +/// Recursively compute the total size of a directory in megabytes. +fn dir_size_mb(root: &Path) -> io::Result { + fn walk(dir: &Path, total: &mut u64) -> io::Result<()> { + if !dir.is_dir() { + return Ok(()); + } + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let ft = entry.file_type()?; + if ft.is_symlink() { + continue; + } + if ft.is_dir() { + walk(&path, total)?; + } else if ft.is_file() { + *total = total.saturating_add(entry.metadata().map(|m| m.len()).unwrap_or(0)); + } + } + Ok(()) + } + let mut total: u64 = 0; + walk(root, &mut total)?; + Ok(total / (1024 * 1024)) +} + fn cleanup_stale_pack_temps(git_dir: &Path, stale_age: Duration) -> io::Result { let pack_dir = git_dir.join("objects").join("pack"); if !pack_dir.exists() { @@ -1011,4 +1091,39 @@ mod tests { assert!(!is_home_directory(&workspace_canonical, Some(home))); assert!(!is_home_directory(&home_canonical, None)); } + + #[test] + fn dir_size_mb_measures_directory_bytes() { + let tmp = tempdir().unwrap(); + let dir = tmp.path().join("sizedir"); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + // 3 bytes per file — well under 1 MB. + std::fs::write(dir.join("a.txt"), b"abc").unwrap(); + std::fs::write(dir.join("sub/b.txt"), b"xyz").unwrap(); + + let size = dir_size_mb(&dir).expect("dir_size_mb"); + assert_eq!(size, 0, "6 bytes should be 0 MB"); + + // Write 2 MB of data. + let big = dir.join("big.bin"); + std::fs::write(&big, vec![0u8; 2 * 1024 * 1024]).unwrap(); + let size = dir_size_mb(&dir).expect("dir_size_mb after big write"); + assert_eq!(size, 2, "expected 2 MB after writing 2 MB file"); + } + + /// Regression: snapshot size cap (#1112). When the snapshot dir grows, + /// `snapshot()` must prune old snapshots to stay under the limit. + /// This test uses the real size constants, which are 500/400 MB — + /// we can't easily blow up a temp dir to 500 MB in a unit test. + /// Instead we verify the guard logic doesn't panic or error on a + /// small repo (well under the cap), and that `snapshot()` still works. + #[test] + fn snapshot_succeeds_when_under_size_cap() { + let tmp = tempdir().unwrap(); + let (repo, _home) = make_repo(tmp.path()); + // The side repo is tiny — well under 500 MB. Snapshot should work. + std::fs::write(repo.work_tree().join("f.txt"), b"hello").unwrap(); + let id = repo.snapshot("pre-turn:1").expect("snapshot under cap"); + assert_eq!(id.as_str().len(), 40); + } } From 097d0221c488d6826669b330176eb7e0308f7b80 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 9 May 2026 00:12:12 -0500 Subject: [PATCH 05/11] fix(prompts): explicitly mark project context as not a language signal (#1118, #1129) Adds a directive to the ## Language section telling the model that project context (AGENTS.md, auto-generated instructions, file trees, skill descriptions) is NOT a language signal. Chinese filenames in a repo should not bias the model toward Chinese replies when the user writes in English. --- crates/tui/src/prompts/base.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/tui/src/prompts/base.md b/crates/tui/src/prompts/base.md index 501b83e1..6f248541 100644 --- a/crates/tui/src/prompts/base.md +++ b/crates/tui/src/prompts/base.md @@ -6,6 +6,8 @@ Choose the natural language for each turn from the latest user message first — Code, file paths, identifiers, tool names, environment variables, command-line flags, URLs, and log lines stay in their original form — translating `read_file` to `读取文件` would break tool calls. Only natural-language prose mirrors the user. +**Project context is NOT a language signal.** Project instructions (AGENTS.md, CLAUDE.md, auto-generated instructions.md), file listings, directory trees, skill descriptions, and other artifacts placed in the system prompt describe what you're working on — not what language to respond in. Chinese filenames in a project tree, for example, do not mean the user wants Chinese replies. The user's message text alone determines the response language. + ## Runtime Identity If the user asks what DeepSeek TUI version you are running, use the `deepseek_version` field in the `## Environment` section as the runtime version. Workspace files such as `Cargo.toml` describe the checkout you are inspecting; they may be stale, dirty, or intentionally different from the installed runtime. If those disagree, report both instead of replacing the runtime version with the workspace version. From 5670034d82625d8ca9e660e0d790e89122fb4e38 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 9 May 2026 00:12:21 -0500 Subject: [PATCH 06/11] fix(todos): /clear now resets the Todos sidebar (#1258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously clear_todos() only cleared the plan_state — the SharedTodoList was never touched. Now it clears both: the todo list (via try_lock on self.todos) and the plan state. The caller in commands/core.rs already called clear_todos() on /clear; it just had nothing to clear. --- crates/tui/src/tui/app.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index f753eefb..cc5720eb 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -3692,11 +3692,20 @@ impl App { } pub fn clear_todos(&mut self) -> bool { + // Clear the todo list (the sidebar checklist). Uses try_lock so the + // UI thread doesn't block if the engine briefly holds the mutex + // during tool execution; the caller can retry or show a busy message. + let todos_cleared = if let Ok(mut todos) = self.todos.try_lock() { + todos.clear(); + true + } else { + false + }; + // Also clear the plan state — /clear means a full reset. if let Ok(mut plan) = self.plan_state.try_lock() { *plan = crate::tools::plan::PlanState::default(); - return true; } - false + todos_cleared } pub fn update_model_compaction_budget(&mut self) { From 97d79862c2d9d5f8fb5d2d2c499d39fdb1fe78a8 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 9 May 2026 00:12:33 -0500 Subject: [PATCH 07/11] chore(release): bump to 0.8.24, update CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version bump: 0.8.23 → 0.8.24 in workspace Cargo.toml. CHANGELOG entries for: - Workspace-local slash commands (#1259) - @-completion for gitignored dot-dirs - MCP paginated discovery via nextCursor (#1250, #1256, credit Liu-Vince) - Snapshot disk-space cap at 500 MB (#1112, credit Giggitycountless) - /clear resets Todos sidebar (#1258) - Language directive strengthened against project-context bias - Known issue: Windows flicker (#1260, #1251) — viewport-reset escape sequence on Windows conhost, investigation in progress --- CHANGELOG.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.lock | 28 ++++++++++++++-------------- Cargo.toml | 2 +- 3 files changed, 65 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05e3846f..e88c866b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,56 @@ 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). +## [0.8.24] - 2026-05-08 + +A bugfix + refactor release picking up the backlog after the v0.8.23 security +release. + +### Fixed + +- **Workspace-local slash commands are now loaded (#1259)** — user command + files placed in `/.deepseek/commands/`, + `/.claude/commands/`, and `/.cursor/commands/` are + now discovered alongside the existing global `~/.deepseek/commands/`. + Workspace-local commands shadow global by name, matching the precedence + model already used for skills. +- **`@`-mention completion finds AI-tool dot-directories** — files inside + `.deepseek/`, `.cursor/`, `.claude/`, and `.agents/` are now discoverable + in `@`-mention Tab-completion even when those directories are excluded by + `.gitignore`. The fix also applies to the Ctrl+P file picker and fuzzy + file resolution. +- **MCP paginated discovery (#1250, #1256)** — tools, resources, resource + templates, and prompts from MCP servers that paginate their responses + (e.g., gbrain at 5 items per page) are now fully discovered by following + the MCP spec's `nextCursor` across all pages. Thanks **Liu-Vince** for + the diagnosis and fix. +- **Snapshot storage has a disk-space cap (#1112)** — the snapshot side repo + now enforces a 500 MB hard limit. When the limit is exceeded at snapshot + time, the oldest snapshots are pruned aggressively to stay under a 400 MB + target. Guards against the reported 1.2 TB snapshot blowup during + high-churn sessions. +- **`/clear` now resets the Todos sidebar (#1258)** — previously `/clear` + only reset the Plan panel; the Todos checklist persisted across clears + until app restart. The fix ensures `clear_todos()` clears the + `SharedTodoList` inner state. + +### Changed + +- **Language directive strengthened against project-context bias (#1118, + #1129)** — the system prompt now explicitly instructs the model that + project context (AGENTS.md, auto-generated instructions, file trees) is + NOT a language signal. Chinese filenames in a repo no longer bias the + model toward Chinese replies when the user writes in English. + +### Known issues + +- **Windows flicker/shake regression (#1260, #1251)** — v0.8.22 and v0.8.23 + exhibit content flickering on Windows 10 (v0.8.20 works correctly). The + issue is likely caused by the viewport-reset escape sequence + (`\x1b[r\x1b[?6l\x1b[H\x1b[2J\x1b[3J`) added in v0.8.22 to fix viewport + drift. On Windows conhost, this sequence may trigger a full screen clear + on every repaint. A platform guard or less aggressive sequence is needed. + ## [0.8.23] - 2026-05-08 A security-focused follow-up to v0.8.22. The bulk of the diff is hardening of diff --git a/Cargo.lock b/Cargo.lock index 6f21fdaa..4d38c8ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1151,7 +1151,7 @@ dependencies = [ [[package]] name = "deepseek-agent" -version = "0.8.23" +version = "0.8.24" dependencies = [ "deepseek-config", "serde", @@ -1159,7 +1159,7 @@ dependencies = [ [[package]] name = "deepseek-app-server" -version = "0.8.23" +version = "0.8.24" dependencies = [ "anyhow", "axum", @@ -1181,7 +1181,7 @@ dependencies = [ [[package]] name = "deepseek-config" -version = "0.8.23" +version = "0.8.24" dependencies = [ "anyhow", "deepseek-secrets", @@ -1193,7 +1193,7 @@ dependencies = [ [[package]] name = "deepseek-core" -version = "0.8.23" +version = "0.8.24" dependencies = [ "anyhow", "chrono", @@ -1211,7 +1211,7 @@ dependencies = [ [[package]] name = "deepseek-execpolicy" -version = "0.8.23" +version = "0.8.24" dependencies = [ "anyhow", "deepseek-protocol", @@ -1220,7 +1220,7 @@ dependencies = [ [[package]] name = "deepseek-hooks" -version = "0.8.23" +version = "0.8.24" dependencies = [ "anyhow", "async-trait", @@ -1234,7 +1234,7 @@ dependencies = [ [[package]] name = "deepseek-mcp" -version = "0.8.23" +version = "0.8.24" dependencies = [ "anyhow", "serde", @@ -1243,7 +1243,7 @@ dependencies = [ [[package]] name = "deepseek-protocol" -version = "0.8.23" +version = "0.8.24" dependencies = [ "serde", "serde_json", @@ -1251,7 +1251,7 @@ dependencies = [ [[package]] name = "deepseek-secrets" -version = "0.8.23" +version = "0.8.24" dependencies = [ "dirs", "keyring", @@ -1264,7 +1264,7 @@ dependencies = [ [[package]] name = "deepseek-state" -version = "0.8.23" +version = "0.8.24" dependencies = [ "anyhow", "chrono", @@ -1276,7 +1276,7 @@ dependencies = [ [[package]] name = "deepseek-tools" -version = "0.8.23" +version = "0.8.24" dependencies = [ "anyhow", "async-trait", @@ -1289,7 +1289,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.8.23" +version = "0.8.24" dependencies = [ "anyhow", "arboard", @@ -1350,7 +1350,7 @@ dependencies = [ [[package]] name = "deepseek-tui-cli" -version = "0.8.23" +version = "0.8.24" dependencies = [ "anyhow", "chrono", @@ -1374,7 +1374,7 @@ dependencies = [ [[package]] name = "deepseek-tui-core" -version = "0.8.23" +version = "0.8.24" [[package]] name = "deltae" diff --git a/Cargo.toml b/Cargo.toml index 5d733864..25ed4496 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.23" +version = "0.8.24" 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 54ca5718d21e38552068e058c500b2c6e83e820e Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 9 May 2026 00:20:13 -0500 Subject: [PATCH 08/11] feat(cache): cache-aware prompt diagnostics and wire payload optimization (#1196) Merge of PR #1196 by wplll. Adds: Cache-aware prompt layering: - PromptBuilder struct separates prompt construction from inspection - System prompt split into named layers with stability classification - Layers classified as static/history/dynamic for cache debugging /cache inspect command: - SHA-256 hashes of each rendered prompt layer - Base static prefix hash vs full request prefix hash - Static prefix stability status across turns - First-divergence tracking from previous request Wire payload optimization: - Tool result budget: large outputs compacted before API request - Tool result dedup: repeated outputs replaced by compact refs - Turn metadata dedup: repeated blocks deduplicated - Wire-only: local session messages remain unchanged Project context pack: - Deterministic workspace summary injected into stable prefix - Configurable via [context] project_pack = false Cache warmup and improved footer cache display. Thanks to wplll for the contribution. --- crates/tui/src/client.rs | 293 +++++++++ crates/tui/src/client/chat.rs | 951 +++++++++++++++++++++++++++- crates/tui/src/commands/debug.rs | 324 +++++++++- crates/tui/src/commands/mod.rs | 21 +- crates/tui/src/config.rs | 22 + crates/tui/src/core/engine.rs | 4 + crates/tui/src/core/engine/tests.rs | 27 + crates/tui/src/main.rs | 1 + crates/tui/src/project_context.rs | 250 ++++++++ crates/tui/src/prompts.rs | 64 ++ crates/tui/src/runtime_threads.rs | 1 + crates/tui/src/tui/app.rs | 4 + crates/tui/src/tui/ui.rs | 94 ++- crates/tui/src/tui/ui/tests.rs | 19 +- 14 files changed, 2031 insertions(+), 44 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 84ec1b71..88a696e1 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -891,6 +891,9 @@ pub(super) fn parse_usage(usage: Option<&Value>) -> Usage { }) .and_then(Value::as_u64) .unwrap_or(0); + let total_tokens = usage + .and_then(|u| u.get("total_tokens")) + .and_then(Value::as_u64); let reasoning_tokens_raw = usage .and_then(|u| u.get("completion_tokens_details")) .and_then(|details| details.get("reasoning_tokens")) @@ -899,6 +902,10 @@ pub(super) fn parse_usage(usage: Option<&Value>) -> Usage { && let Some(reasoning_tokens) = reasoning_tokens_raw { output_tokens = reasoning_tokens; + } else if output_tokens == 0 + && let Some(total_tokens) = total_tokens + { + output_tokens = total_tokens.saturating_sub(input_tokens); } let cached_tokens = usage .and_then(|u| u.get("prompt_tokens_details")) @@ -979,6 +986,16 @@ impl DeepSeekClient { mod chat; +pub(crate) use chat::PromptInspection; + +pub(crate) fn inspect_prompt_for_request(request: &MessageRequest) -> PromptInspection { + chat::inspect_prompt_for_request(request) +} + +pub(crate) fn build_cache_warmup_request(request: &MessageRequest) -> MessageRequest { + chat::build_cache_warmup_request(request) +} + #[cfg(test)] mod tests { use super::*; @@ -1370,6 +1387,267 @@ mod tests { ); } + #[test] + fn prompt_builder_keeps_system_first_and_current_user_input_last() { + let request = MessageRequest { + model: "deepseek-v4-pro".to_string(), + messages: vec![ + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "Previous answer".to_string(), + cache_control: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ + ContentBlock::Text { + text: "\nCurrent local date: 2026-05-08\n" + .to_string(), + cache_control: None, + }, + ContentBlock::Text { + text: "Current user question".to_string(), + cache_control: None, + }, + ], + }, + ], + max_tokens: 1024, + system: Some(SystemPrompt::Text( + "Stable mode, project rules, and tool policy".to_string(), + )), + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("max".to_string()), + stream: None, + temperature: None, + top_p: None, + }; + + let out = build_chat_messages_for_request(&request); + + assert_eq!(out[0].get("role").and_then(Value::as_str), Some("system")); + assert_eq!( + out[0].get("content").and_then(Value::as_str), + Some("Stable mode, project rules, and tool policy") + ); + let last = out.last().expect("latest user message"); + assert_eq!(last.get("role").and_then(Value::as_str), Some("user")); + assert!( + last.get("content") + .and_then(Value::as_str) + .is_some_and(|content| content.ends_with("Current user question")), + "current-turn user input must be at the tail of the wire prompt: {last:?}" + ); + } + + #[test] + fn prompt_inspect_reports_stable_layers_and_dynamic_user_task() { + let request = MessageRequest { + model: "deepseek-v4-pro".to_string(), + messages: vec![ + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "Prior answer".to_string(), + cache_control: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "Current task".to_string(), + cache_control: None, + }], + }, + ], + max_tokens: 1024, + system: Some(SystemPrompt::Text( + "Base policy\n\n\nRules\n\n\n## Project Context Pack\n\n\n{}\n\n\n## Environment\n\n- lang: en" + .to_string(), + )), + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("max".to_string()), + stream: None, + temperature: None, + top_p: None, + }; + + let inspection = inspect_prompt_for_request(&request); + + assert_eq!(inspection.base_static_prefix_hash.len(), 64); + assert_eq!(inspection.full_request_prefix_hash.len(), 64); + assert!(inspection.layers.iter().any(|layer| { + layer.name == "Global system prefix" + && layer.stability.label() == "static" + && layer.char_len == "Base policy".chars().count() + && layer.sha256.len() == 64 + })); + assert!(inspection.layers.iter().any(|layer| { + layer.name == "Project context" && layer.stability.label() == "static" + })); + assert!(inspection.layers.iter().any(|layer| { + layer.name == "Project context pack" && layer.stability.label() == "static" + })); + assert!(inspection.layers.iter().any(|layer| { + layer.name == "Message #1 assistant" && layer.stability.label() == "history" + })); + assert!( + inspection.layers.last().is_some_and( + |layer| layer.name == "User task" && layer.stability.label() == "dynamic" + ) + ); + } + + #[test] + fn prompt_inspect_keeps_static_base_hash_across_different_user_tasks() { + fn request_with_user_task(task: &str) -> MessageRequest { + MessageRequest { + model: "deepseek-v4-pro".to_string(), + messages: vec![ + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "Prior answer".to_string(), + cache_control: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: task.to_string(), + cache_control: None, + }], + }, + ], + max_tokens: 1024, + system: Some(SystemPrompt::Text( + "Base policy\n\n## Environment\n\n- shell: powershell\n\n## Skills\n\n- rust\n\n## Context Management\n\nKeep concise\n\n## Compact\n\nTemplate" + .to_string(), + )), + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("max".to_string()), + stream: None, + temperature: None, + top_p: None, + } + } + + let first = inspect_prompt_for_request(&request_with_user_task("First task")); + let second = inspect_prompt_for_request(&request_with_user_task("Second task")); + let mut changed_history_request = request_with_user_task("Second task"); + changed_history_request.messages[0] = Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "Different prior answer".to_string(), + cache_control: None, + }], + }; + let changed_history = inspect_prompt_for_request(&changed_history_request); + + assert_eq!( + first.base_static_prefix_hash, + second.base_static_prefix_hash + ); + assert_eq!( + first.full_request_prefix_hash, second.full_request_prefix_hash, + "full request prefix excludes the final dynamic user task" + ); + assert_ne!( + second.full_request_prefix_hash, changed_history.full_request_prefix_hash, + "full request prefix can change when session history changes" + ); + assert!( + second.layers.last().is_some_and( + |layer| layer.name == "User task" && layer.stability.label() == "dynamic" + ), + "current user task must remain the final layer" + ); + assert!(second.layers.iter().any(|layer| { + layer.name == "Message #1 assistant" && layer.stability.label() == "history" + })); + assert!(!second.layers.iter().any( + |layer| layer.name.starts_with("Message #") && layer.stability.label() == "static" + )); + } + + #[test] + fn cache_warmup_request_reuses_stable_prefix_and_fixed_user_tail() { + let request = MessageRequest { + model: "deepseek-v4-pro".to_string(), + messages: vec![ + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "Stable prior answer".to_string(), + cache_control: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "Dynamic latest user task".to_string(), + cache_control: None, + }], + }, + ], + max_tokens: 1024, + system: Some(SystemPrompt::Text( + "Base policy\n\n\nStable project rules\n\n\n## Previous Session Handoff\n\nDynamic handoff" + .to_string(), + )), + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("max".to_string()), + stream: Some(true), + temperature: Some(0.7), + top_p: None, + }; + + let warmup = build_cache_warmup_request(&request); + + assert_eq!(warmup.max_tokens, 8); + assert_eq!(warmup.temperature, Some(0.0)); + assert_eq!(warmup.reasoning_effort.as_deref(), Some("max")); + assert_eq!(warmup.messages.len(), 2); + assert_eq!(warmup.messages[0].role, "assistant"); + assert_eq!(warmup.messages[1].role, "user"); + assert_eq!( + warmup.messages[1].content, + vec![ContentBlock::Text { + text: "请只回复 OK".to_string(), + cache_control: None, + }] + ); + + let wire = build_chat_messages_for_request(&warmup); + let system = wire + .first() + .and_then(|value| value.get("content")) + .and_then(Value::as_str) + .expect("warmup system prompt"); + assert!(system.contains("Stable project rules")); + assert!(!system.contains("Dynamic handoff")); + assert!( + !wire + .iter() + .any(|value| value.to_string().contains("Dynamic latest user task")), + "warmup must not include the dynamic latest user task" + ); + } + #[test] fn reasoning_effort_uses_deepseek_top_level_thinking_parameter() { let mut body = json!({}); @@ -2042,6 +2320,21 @@ mod tests { ); } + #[test] + fn parse_usage_derives_completion_tokens_from_total_tokens_when_needed() { + let usage = parse_usage(Some(&json!({ + "prompt_tokens": 100, + "total_tokens": 125, + "prompt_cache_hit_tokens": 70, + "prompt_cache_miss_tokens": 30 + }))); + + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 25); + assert_eq!(usage.prompt_cache_hit_tokens, Some(70)); + assert_eq!(usage.prompt_cache_miss_tokens, Some(30)); + } + #[test] fn parse_usage_reads_v4_prompt_tokens_details_cached_tokens() { let usage = parse_usage(Some(&json!({ diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 53cefcd8..2775f6e9 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -4,12 +4,13 @@ //! request building (`build_chat_messages*`), and SSE parsing (`parse_sse_chunk`) //! all live here. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::pin::Pin; use std::time::Duration; use anyhow::{Context, Result}; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; use tokio::time::timeout as tokio_timeout; /// Default idle timeout for SSE stream reads (300 seconds = 5 minutes). @@ -407,26 +408,588 @@ pub(super) fn build_chat_messages( messages, model, should_replay_reasoning_content(model, None), + false, ) } pub(super) fn build_chat_messages_for_request(request: &MessageRequest) -> Vec { - build_chat_messages_with_reasoning( - request.system.as_ref(), - &request.messages, - &request.model, - should_replay_reasoning_content(&request.model, request.reasoning_effort.as_deref()), + PromptBuilder::for_request(request).build() +} + +pub(crate) fn inspect_prompt_for_request(request: &MessageRequest) -> PromptInspection { + PromptBuilder::for_request(request).inspect() +} + +pub(crate) fn build_cache_warmup_request(request: &MessageRequest) -> MessageRequest { + PromptBuilder::for_request(request).build_cache_warmup_request() +} + +struct PromptBuilder<'a> { + system: Option<&'a SystemPrompt>, + messages: &'a [Message], + model: &'a str, + reasoning_effort: Option<&'a str>, +} + +impl<'a> PromptBuilder<'a> { + fn for_request(request: &'a MessageRequest) -> Self { + Self { + system: request.system.as_ref(), + messages: &request.messages, + model: &request.model, + reasoning_effort: request.reasoning_effort.as_deref(), + } + } + + fn build(self) -> Vec { + build_chat_messages_with_reasoning( + self.system, + self.messages, + self.model, + should_replay_reasoning_content(self.model, self.reasoning_effort), + false, + ) + } + + fn inspect(self) -> PromptInspection { + let messages = build_chat_messages_with_reasoning( + self.system, + self.messages, + self.model, + should_replay_reasoning_content(self.model, self.reasoning_effort), + true, + ); + inspect_wire_messages(&messages) + } + + fn build_cache_warmup_request(self) -> MessageRequest { + let system = stable_system_prompt(self.system); + let mut messages = stable_history_messages(self.messages); + messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: CACHE_WARMUP_USER_TAIL.to_string(), + cache_control: None, + }], + }); + + MessageRequest { + model: self.model.to_string(), + messages, + max_tokens: 8, + system, + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: self.reasoning_effort.map(str::to_string), + stream: None, + temperature: Some(0.0), + top_p: None, + } + } +} + +pub(crate) const CACHE_WARMUP_USER_TAIL: &str = "请只回复 OK"; +const TOOL_RESULT_SENT_CHAR_BUDGET: usize = 12_000; +const TOOL_RESULT_HEAD_CHARS: usize = 4_000; +const TOOL_RESULT_TAIL_CHARS: usize = 4_000; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PromptInspection { + pub base_static_prefix_hash: String, + pub full_request_prefix_hash: String, + pub layers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PromptLayerInspection { + pub name: String, + pub stability: PromptLayerStability, + pub char_len: usize, + pub sha256: String, + pub tool_result: Option, + pub turn_meta: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ToolResultInspection { + pub original_chars: usize, + pub sent_chars: usize, + pub truncated: bool, + pub deduplicated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TurnMetaInspection { + pub original_chars: usize, + pub sent_chars: usize, + pub deduplicated: bool, + pub sha256: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PromptLayerStability { + Static, + History, + Dynamic, +} + +impl PromptLayerStability { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Static => "static", + Self::History => "history", + Self::Dynamic => "dynamic", + } + } +} + +fn inspect_wire_messages(messages: &[Value]) -> PromptInspection { + let mut layers = Vec::new(); + let mut base_static_prefix_parts = Vec::new(); + let mut full_request_prefix_parts = Vec::new(); + + for (index, message) in messages.iter().enumerate() { + let role = message + .get("role") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let content = message_content_for_inspect(message); + let is_last = index + 1 == messages.len(); + + if index == 0 && role == "system" { + for (name, stability, body) in split_system_layers(&content) { + if stability == PromptLayerStability::Static { + base_static_prefix_parts.push(body.to_string()); + } + if stability != PromptLayerStability::Dynamic { + full_request_prefix_parts.push(body.to_string()); + } + layers.push(prompt_layer(name, stability, body)); + } + } else { + let stability = if (is_last && role == "user") || role == "tool" { + PromptLayerStability::Dynamic + } else { + PromptLayerStability::History + }; + let name = if is_last && role == "user" { + "User task".to_string() + } else { + format!("Message #{index} {role}") + }; + if stability != PromptLayerStability::Dynamic { + full_request_prefix_parts.push(content.clone()); + } + let mut layer = prompt_layer(name, stability, &content); + layer.tool_result = tool_result_inspection_for_message(message); + layer.turn_meta = turn_meta_inspection_for_message(message); + layers.push(layer); + } + } + + let base_static_prefix = base_static_prefix_parts.join("\n"); + let full_request_prefix = full_request_prefix_parts.join("\n"); + + PromptInspection { + base_static_prefix_hash: sha256_hex(base_static_prefix.as_bytes()), + full_request_prefix_hash: sha256_hex(full_request_prefix.as_bytes()), + layers, + } +} + +fn message_content_for_inspect(message: &Value) -> String { + let mut parts = Vec::new(); + if let Some(content) = message.get("content").and_then(Value::as_str) + && !content.is_empty() + { + parts.push(content.to_string()); + } + if let Some(reasoning) = message.get("reasoning_content").and_then(Value::as_str) + && !reasoning.is_empty() + { + parts.push(reasoning.to_string()); + } + if let Some(tool_calls) = message.get("tool_calls") { + parts.push(tool_calls.to_string()); + } + parts.join("\n") +} + +fn tool_result_inspection_for_message(message: &Value) -> Option { + if message.get("role").and_then(Value::as_str) != Some("tool") { + return None; + } + let budget = message.get("_tool_result_budget")?; + Some(ToolResultInspection { + original_chars: budget + .get("original_chars") + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok())?, + sent_chars: budget + .get("sent_chars") + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok())?, + truncated: budget + .get("truncated") + .and_then(Value::as_bool) + .unwrap_or(false), + deduplicated: budget + .get("deduplicated") + .and_then(Value::as_bool) + .unwrap_or(false), + }) +} + +fn turn_meta_inspection_for_message(message: &Value) -> Option { + let budget = message.get("_turn_meta_budget")?; + Some(TurnMetaInspection { + original_chars: budget + .get("original_chars") + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok())?, + sent_chars: budget + .get("sent_chars") + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok())?, + deduplicated: budget + .get("deduplicated") + .and_then(Value::as_bool) + .unwrap_or(false), + sha256: budget + .get("sha256") + .and_then(Value::as_str) + .map(str::to_string)?, + }) +} + +fn split_system_layers(content: &str) -> Vec<(String, PromptLayerStability, &str)> { + let markers = [ + ("Project context", " = markers + .iter() + .filter_map(|(name, marker)| content.find(marker).map(|idx| (idx, *name))) + .collect(); + starts.sort_by_key(|(idx, _)| *idx); + + let mut layers = Vec::new(); + let first_marker = starts.first().map_or(content.len(), |(idx, _)| *idx); + if first_marker > 0 { + layers.push(( + "Global system prefix".to_string(), + PromptLayerStability::Static, + content[..first_marker].trim(), + )); + } + + for (i, (start, name)) in starts.iter().enumerate() { + let end = starts.get(i + 1).map_or(content.len(), |(idx, _)| *idx); + let stability = if *name == "Previous session handoff" { + PromptLayerStability::Dynamic + } else if is_static_base_layer(name) { + PromptLayerStability::Static + } else { + PromptLayerStability::History + }; + layers.push(((*name).to_string(), stability, content[*start..end].trim())); + } + + if layers.is_empty() { + layers.push(( + "Global system prefix".to_string(), + PromptLayerStability::Static, + content.trim(), + )); + } + layers +} + +fn is_static_base_layer(name: &str) -> bool { + matches!( + name, + "Global system prefix" + | "Environment" + | "Skills" + | "Project context" + | "Project context pack" + | "Context management" + | "Compact template" ) } +fn stable_system_prompt(system: Option<&SystemPrompt>) -> Option { + let instructions = system_to_instructions(system.cloned())?; + let stable = split_system_layers(&instructions) + .into_iter() + .filter_map(|(_, stability, body)| { + (stability == PromptLayerStability::Static).then_some(body) + }) + .collect::>() + .join("\n\n"); + if stable.trim().is_empty() { + None + } else { + Some(SystemPrompt::Text(stable)) + } +} + +fn stable_history_messages(messages: &[Message]) -> Vec { + let mut end = messages.len(); + if messages + .last() + .is_some_and(|message| message.role.as_str() == "user") + { + end = end.saturating_sub(1); + } + messages[..end].to_vec() +} + +fn prompt_layer( + name: String, + stability: PromptLayerStability, + content: &str, +) -> PromptLayerInspection { + PromptLayerInspection { + name, + stability, + char_len: content.chars().count(), + sha256: sha256_hex(content.as_bytes()), + tool_result: None, + turn_meta: None, + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +#[derive(Clone)] +struct PendingToolCallInfo { + tool_name: String, + input: Value, +} + +struct SeenToolResult { + message_label: String, + original_chars: usize, +} + +struct WireToolResult { + content: String, + original_chars: usize, + sent_chars: usize, + truncated: bool, + deduplicated: bool, +} + +#[derive(Clone)] +struct TurnMetaBudget { + original_chars: usize, + sent_chars: usize, + deduplicated: bool, + sha256: String, +} + +struct LastFullTurnMeta { + sha256: String, +} + +fn render_turn_meta_for_wire( + text: &str, + last_full_turn_meta: &mut Option, +) -> (String, TurnMetaBudget) { + let original_chars = text.chars().count(); + let sha = sha256_hex(text.as_bytes()); + + if last_full_turn_meta + .as_ref() + .is_some_and(|previous| previous.sha256 == sha) + { + let rendered = + format!(""); + let budget = TurnMetaBudget { + original_chars, + sent_chars: rendered.chars().count(), + deduplicated: true, + sha256: sha, + }; + return (rendered, budget); + } + + *last_full_turn_meta = Some(LastFullTurnMeta { + sha256: sha.clone(), + }); + ( + text.to_string(), + TurnMetaBudget { + original_chars, + sent_chars: original_chars, + deduplicated: false, + sha256: sha, + }, + ) +} + +fn is_turn_meta_text(text: &str) -> bool { + text.trim_start().starts_with("") +} + +fn turn_meta_budget_json(turn_meta: &TurnMetaBudget) -> Value { + json!({ + "original_chars": turn_meta.original_chars, + "sent_chars": turn_meta.sent_chars, + "deduplicated": turn_meta.deduplicated, + "sha256": turn_meta.sha256, + }) +} + +fn compact_tool_result_for_wire( + tool_name: &str, + input: &Value, + content: &str, + message_label: &str, + seen_tool_results: &mut HashMap, +) -> WireToolResult { + let original_chars = content.chars().count(); + let sha = sha256_hex(content.as_bytes()); + + if let Some(previous) = seen_tool_results.get(&sha) { + let content = format!( + "", + sha, previous.message_label, previous.original_chars + ); + return WireToolResult { + sent_chars: content.chars().count(), + content, + original_chars, + truncated: false, + deduplicated: true, + }; + } + + seen_tool_results.insert( + sha.clone(), + SeenToolResult { + message_label: message_label.to_string(), + original_chars, + }, + ); + + if original_chars <= TOOL_RESULT_SENT_CHAR_BUDGET { + return WireToolResult { + content: content.to_string(), + original_chars, + sent_chars: original_chars, + truncated: false, + deduplicated: false, + }; + } + + let head = first_chars(content, TOOL_RESULT_HEAD_CHARS); + let tail = last_chars(content, TOOL_RESULT_TAIL_CHARS); + let kept = head.chars().count() + tail.chars().count(); + let omitted = original_chars.saturating_sub(kept); + let compacted = format!( + "[TOOL_RESULT_TRUNCATED]\n\ + tool_name: {tool_name}\n\ + command_or_query: {}\n\ + exit_status: {}\n\ + original_chars: {original_chars}\n\ + sha256: {sha}\n\ + first_chars:\n\ + {head}\n\n\ + [... truncated {omitted} chars from middle ...]\n\n\ + last_chars:\n\ + {tail}", + tool_command_or_query(input), + tool_exit_status(content) + ); + + WireToolResult { + sent_chars: compacted.chars().count(), + content: compacted, + original_chars, + truncated: true, + deduplicated: false, + } +} + +fn tool_command_or_query(input: &Value) -> String { + for key in ["command", "cmd", "query", "q", "pattern", "path", "url"] { + if let Some(value) = input.get(key) { + return summarize_for_metadata(value, 500); + } + } + summarize_for_metadata(input, 500) +} + +fn tool_exit_status(content: &str) -> String { + if let Ok(value) = serde_json::from_str::(content) { + for key in ["exit_code", "exit_status", "status", "code"] { + if let Some(value) = value.get(key) { + return summarize_for_metadata(value, 120); + } + } + } + + for line in content.lines().take(20) { + let trimmed = line.trim(); + for prefix in ["Exit code:", "exit code:", "Exit status:", "exit status:"] { + if let Some(value) = trimmed.strip_prefix(prefix) { + return value.trim().to_string(); + } + } + } + "unknown".to_string() +} + +fn summarize_for_metadata(value: &Value, max_chars: usize) -> String { + let raw = value + .as_str() + .map(str::to_string) + .unwrap_or_else(|| value.to_string()); + let mut summarized = first_chars(&raw.replace('\n', "\\n"), max_chars); + if raw.chars().count() > max_chars { + summarized.push_str("..."); + } + summarized +} + +fn first_chars(value: &str, count: usize) -> String { + value.chars().take(count).collect() +} + +fn last_chars(value: &str, count: usize) -> String { + let mut chars: Vec = value.chars().rev().take(count).collect(); + chars.reverse(); + chars.into_iter().collect() +} + fn build_chat_messages_with_reasoning( system: Option<&SystemPrompt>, messages: &[Message], _model: &str, include_reasoning: bool, + include_tool_budget_metadata: bool, ) -> Vec { let mut out = Vec::new(); - let mut pending_tool_calls: HashSet = HashSet::new(); + let mut pending_tool_calls: HashMap = HashMap::new(); + let mut seen_tool_results: HashMap = HashMap::new(); + let mut last_full_turn_meta: Option = None; if let Some(instructions) = system_to_instructions(system.cloned()) && !instructions.trim().is_empty() @@ -442,15 +1005,25 @@ fn build_chat_messages_with_reasoning( let mut text_parts = Vec::new(); let mut thinking_parts = Vec::new(); let mut tool_calls = Vec::new(); - let mut tool_call_ids = Vec::new(); - let mut tool_results: Vec<(String, Value)> = Vec::new(); + let mut tool_call_infos = Vec::new(); + let mut tool_results: Vec<(String, String, String)> = Vec::new(); + let mut turn_meta_budget: Option = None; let later_user_turn = messages[message_index + 1..] .iter() .any(message_starts_user_turn); for block in &message.content { match block { - ContentBlock::Text { text, .. } => text_parts.push(text.clone()), + ContentBlock::Text { text, .. } => { + if is_turn_meta_text(text) { + let (rendered, budget) = + render_turn_meta_for_wire(text, &mut last_full_turn_meta); + text_parts.push(rendered); + turn_meta_budget = Some(budget); + } else { + text_parts.push(text.clone()); + } + } ContentBlock::Thinking { thinking } => thinking_parts.push(thinking.clone()), ContentBlock::ToolUse { id, @@ -475,21 +1048,21 @@ fn build_chat_messages_with_reasoning( }); } tool_calls.push(call); - tool_call_ids.push(id.clone()); + tool_call_infos.push(( + id.clone(), + PendingToolCallInfo { + tool_name: name.clone(), + input: input.clone(), + }, + )); } ContentBlock::ToolResult { tool_use_id, content, .. } => { - tool_results.push(( - tool_use_id.clone(), - json!({ - "role": "tool", - "tool_call_id": tool_use_id, - "content": content, - }), - )); + let message_label = format!("Message #{message_index}"); + tool_results.push((tool_use_id.clone(), content.clone(), message_label)); } ContentBlock::ServerToolUse { .. } | ContentBlock::ToolSearchToolResult { .. } @@ -541,7 +1114,7 @@ fn build_chat_messages_with_reasoning( } if has_tool_calls { msg["tool_calls"] = json!(tool_calls); - pending_tool_calls = tool_call_ids.into_iter().collect(); + pending_tool_calls = tool_call_infos.into_iter().collect(); } else { pending_tool_calls.clear(); } @@ -549,18 +1122,26 @@ fn build_chat_messages_with_reasoning( } else if role == "system" { let content = text_parts.join("\n"); if !content.trim().is_empty() { - out.push(json!({ + let mut msg = json!({ "role": "system", "content": content, - })); + }); + if include_tool_budget_metadata && let Some(turn_meta) = &turn_meta_budget { + msg["_turn_meta_budget"] = turn_meta_budget_json(turn_meta); + } + out.push(msg); } } else if role == "user" { let content = text_parts.join("\n"); if !content.trim().is_empty() { - out.push(json!({ + let mut msg = json!({ "role": "user", "content": content, - })); + }); + if include_tool_budget_metadata && let Some(turn_meta) = &turn_meta_budget { + msg["_turn_meta_budget"] = turn_meta_budget_json(turn_meta); + } + out.push(msg); } } @@ -568,8 +1149,28 @@ fn build_chat_messages_with_reasoning( if pending_tool_calls.is_empty() { logging::warn("Dropping tool results without matching tool_calls"); } else { - for (tool_id, tool_msg) in tool_results { - if pending_tool_calls.remove(&tool_id) { + for (tool_id, content, message_label) in tool_results { + if let Some(tool_info) = pending_tool_calls.remove(&tool_id) { + let wire_result = compact_tool_result_for_wire( + &tool_info.tool_name, + &tool_info.input, + &content, + &message_label, + &mut seen_tool_results, + ); + let mut tool_msg = json!({ + "role": "tool", + "tool_call_id": tool_id, + "content": wire_result.content, + }); + if include_tool_budget_metadata { + tool_msg["_tool_result_budget"] = json!({ + "original_chars": wire_result.original_chars, + "sent_chars": wire_result.sent_chars, + "truncated": wire_result.truncated, + "deduplicated": wire_result.deduplicated, + }); + } out.push(tool_msg); } else { logging::warn(format!( @@ -1722,4 +2323,302 @@ mod stream_decoder_tests { assert_eq!(built[0]["role"], "system"); assert_eq!(built[0]["content"], "internal runtime event"); } + + fn tool_use_message(id: &str, name: &str, input: Value) -> Message { + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: id.to_string(), + name: name.to_string(), + input, + caller: None, + }], + } + } + + fn tool_result_message(id: &str, content: &str) -> Message { + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: id.to_string(), + content: content.to_string(), + is_error: None, + content_blocks: None, + }], + } + } + + fn user_message_with_turn_meta(turn_meta: &str, task: &str) -> Message { + Message { + role: "user".to_string(), + content: vec![ + ContentBlock::Text { + text: turn_meta.to_string(), + cache_control: None, + }, + ContentBlock::Text { + text: task.to_string(), + cache_control: None, + }, + ], + } + } + + fn tool_message_content(messages: &[Value], index: usize) -> &str { + messages + .iter() + .filter(|message| message.get("role").and_then(Value::as_str) == Some("tool")) + .nth(index) + .and_then(|message| message.get("content").and_then(Value::as_str)) + .expect("tool message content") + } + + fn user_message_content(messages: &[Value], index: usize) -> &str { + messages + .iter() + .filter(|message| message.get("role").and_then(Value::as_str) == Some("user")) + .nth(index) + .and_then(|message| message.get("content").and_then(Value::as_str)) + .expect("user message content") + } + + #[test] + fn request_builder_deduplicates_consecutive_identical_turn_meta_for_wire() { + let turn_meta = "\nCurrent local date: 2026-05-09\n"; + let messages = vec![ + user_message_with_turn_meta(turn_meta, "first task"), + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "first answer".to_string(), + cache_control: None, + }], + }, + user_message_with_turn_meta(turn_meta, "second task"), + ]; + + let built = build_chat_messages(None, &messages, "deepseek-v4-flash"); + let first = user_message_content(&built, 0); + let second = user_message_content(&built, 1); + let expected_sha = sha256_hex(turn_meta.as_bytes()); + let expected_ref = format!( + "", + turn_meta.chars().count() + ); + + assert!(first.starts_with(turn_meta), "got: {first}"); + assert!(second.starts_with(&expected_ref), "got: {second}"); + assert!(second.ends_with("second task"), "got: {second}"); + assert_eq!( + second, + format!("{expected_ref}\nsecond task"), + "ref text must stay stable" + ); + } + + #[test] + fn request_builder_keeps_changed_turn_meta_full_and_updates_recent_hash() { + let first_meta = "\nCurrent local date: 2026-05-09\n"; + let second_meta = + "\nCurrent local date: 2026-05-09\nWorking set: src/lib.rs\n"; + let messages = vec![ + user_message_with_turn_meta(first_meta, "first task"), + user_message_with_turn_meta(second_meta, "second task"), + ]; + + let built = build_chat_messages(None, &messages, "deepseek-v4-flash"); + let first = user_message_content(&built, 0); + let second = user_message_content(&built, 1); + + assert!(first.starts_with(first_meta), "got: {first}"); + assert!(second.starts_with(second_meta), "got: {second}"); + assert!(!second.contains(" assert_eq!(text, turn_meta), + other => panic!("expected text block, got {other:?}"), + } + } + + #[test] + fn cache_inspect_reports_turn_meta_dedup_metadata() { + let turn_meta = format!( + "\nCurrent local date: 2026-05-09\n{}\n", + "Working set: src/lib.rs\n".repeat(20) + ); + let request = MessageRequest { + model: "deepseek-v4-flash".to_string(), + messages: vec![ + user_message_with_turn_meta(&turn_meta, "first task"), + user_message_with_turn_meta(&turn_meta, "second task"), + ], + max_tokens: 0, + system: None, + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: None, + stream: None, + temperature: None, + top_p: None, + }; + + let inspection = inspect_prompt_for_request(&request); + let turn_meta_layers: Vec<_> = inspection + .layers + .iter() + .filter_map(|layer| layer.turn_meta.as_ref()) + .collect(); + + assert_eq!(turn_meta_layers.len(), 2); + assert_eq!( + turn_meta_layers[0].original_chars, + turn_meta.chars().count() + ); + assert_eq!(turn_meta_layers[0].sent_chars, turn_meta.chars().count()); + assert!(!turn_meta_layers[0].deduplicated); + assert_eq!(turn_meta_layers[0].sha256, sha256_hex(turn_meta.as_bytes())); + assert_eq!( + turn_meta_layers[1].original_chars, + turn_meta.chars().count() + ); + assert!(turn_meta_layers[1].sent_chars < turn_meta_layers[1].original_chars); + assert!(turn_meta_layers[1].deduplicated); + assert_eq!(turn_meta_layers[1].sha256, turn_meta_layers[0].sha256); + } + + #[test] + fn request_builder_truncates_large_tool_result_for_wire() { + let long_output = format!("{}{}", "A".repeat(7_000), "Z".repeat(7_000)); + let messages = vec![ + tool_use_message( + "tool-long", + "shell_command", + json!({"command": "cargo test"}), + ), + tool_result_message("tool-long", &long_output), + ]; + + let built = build_chat_messages(None, &messages, "deepseek-v4-flash"); + let sent = tool_message_content(&built, 0); + + assert!(sent.contains("[TOOL_RESULT_TRUNCATED]"), "got: {sent}"); + assert!(sent.contains("tool_name: shell_command"), "got: {sent}"); + assert!(sent.contains("command_or_query: cargo test"), "got: {sent}"); + assert!(sent.contains("original_chars: 14000"), "got: {sent}"); + assert!(sent.contains("sha256:"), "got: {sent}"); + assert!(sent.contains(&"A".repeat(4_000)), "got: {sent}"); + assert!(sent.contains(&"Z".repeat(4_000)), "got: {sent}"); + assert!( + sent.contains("truncated 6000 chars from middle"), + "got: {sent}" + ); + assert_ne!(sent, long_output); + } + + #[test] + fn request_builder_deduplicates_identical_tool_results_for_wire() { + let output = "same tool output"; + let messages = vec![ + tool_use_message("tool-1", "read_file", json!({"path": "README.md"})), + tool_result_message("tool-1", output), + tool_use_message("tool-2", "read_file", json!({"path": "README.md"})), + tool_result_message("tool-2", output), + ]; + + let built = build_chat_messages(None, &messages, "deepseek-v4-flash"); + let first = tool_message_content(&built, 0); + let second = tool_message_content(&built, 1); + + assert_eq!(first, output); + assert!( + second.starts_with(" assert_eq!(content, &long_output), + other => panic!("expected tool result, got {other:?}"), + } + } + + #[test] + fn cache_inspect_reports_tool_result_budget_metadata() { + let long_output = format!("{}{}", "A".repeat(7_000), "Z".repeat(7_000)); + let request = MessageRequest { + model: "deepseek-v4-flash".to_string(), + messages: vec![ + tool_use_message("tool-1", "shell_command", json!({"command": "cargo test"})), + tool_result_message("tool-1", &long_output), + tool_use_message("tool-2", "shell_command", json!({"command": "cargo test"})), + tool_result_message("tool-2", &long_output), + ], + max_tokens: 0, + system: None, + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: None, + stream: None, + temperature: None, + top_p: None, + }; + + let inspection = inspect_prompt_for_request(&request); + let tool_layers: Vec<_> = inspection + .layers + .iter() + .filter_map(|layer| layer.tool_result.as_ref()) + .collect(); + + assert_eq!(tool_layers.len(), 2); + assert_eq!(tool_layers[0].original_chars, 14_000); + assert!(tool_layers[0].sent_chars < tool_layers[0].original_chars); + assert!(tool_layers[0].truncated); + assert!(!tool_layers[0].deduplicated); + assert_eq!(tool_layers[1].original_chars, 14_000); + assert!(tool_layers[1].sent_chars < 200); + assert!(!tool_layers[1].truncated); + assert!(tool_layers[1].deduplicated); + } } diff --git a/crates/tui/src/commands/debug.rs b/crates/tui/src/commands/debug.rs index 615165c2..b5851900 100644 --- a/crates/tui/src/commands/debug.rs +++ b/crates/tui/src/commands/debug.rs @@ -5,9 +5,10 @@ use std::time::Instant; use super::CommandResult; +use crate::client::{PromptInspection, inspect_prompt_for_request}; use crate::compaction::estimate_input_tokens_conservative; use crate::localization::{Locale, MessageId, tr}; -use crate::models::{ContentBlock, SystemPrompt, context_window_for_model}; +use crate::models::{ContentBlock, MessageRequest, SystemPrompt, context_window_for_model}; use crate::tui::app::{App, AppAction, TurnCacheRecord}; use crate::tui::history::HistoryCell; @@ -136,9 +137,15 @@ pub fn context(_app: &mut App) -> CommandResult { /// `arg` is parsed as a count override (default 10, capped at the ring size). /// Renders a fixed-width table the user can paste into a bug report. pub fn cache(app: &mut App, arg: Option<&str>) -> CommandResult { - let want = arg - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(10); + let arg = arg.map(str::trim).filter(|s| !s.is_empty()); + if matches!(arg, Some("inspect")) { + return CommandResult::message(format_cache_inspect(app)); + } + if matches!(arg, Some("warmup")) { + return CommandResult::action(AppAction::CacheWarmup); + } + + let want = arg.and_then(|s| s.parse::().ok()).unwrap_or(10); let cap = app.session.turn_cache_history.len(); let count = want .min(cap) @@ -151,6 +158,150 @@ pub fn cache(app: &mut App, arg: Option<&str>) -> CommandResult { CommandResult::message(format_cache_history(app, count, app.ui_locale)) } +fn format_cache_inspect(app: &mut App) -> String { + let reasoning_effort = if app.reasoning_effort == crate::tui::app::ReasoningEffort::Auto { + app.last_effective_reasoning_effort + .and_then(crate::tui::app::ReasoningEffort::api_value) + .map(str::to_string) + } else { + app.reasoning_effort.api_value().map(str::to_string) + }; + let request = MessageRequest { + model: app.model.clone(), + messages: app.api_messages.clone(), + max_tokens: 0, + system: app.system_prompt.clone(), + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort, + stream: Some(true), + temperature: None, + top_p: None, + }; + let inspection = inspect_prompt_for_request(&request); + let previous = app.session.last_cache_inspection.as_ref(); + + let mut out = String::new(); + out.push_str("Cache Inspect\n"); + out.push_str("Full prompt text is not printed. Hashes are SHA-256 of each rendered layer.\n"); + out.push_str(&format!( + "Base static prefix hash: {}\n", + inspection.base_static_prefix_hash + )); + out.push_str(&format!( + "Full request prefix hash: {}\n", + inspection.full_request_prefix_hash + )); + out.push_str(&format_static_prefix_status(previous, &inspection)); + out.push_str(&format_first_divergence(previous, &inspection)); + out.push('\n'); + + for layer in &inspection.layers { + let mut line = format!( + "{}: {}, chars={}, hash={}\n", + layer.name, + layer.stability.label(), + layer.char_len, + layer.sha256 + ); + if let Some(tool_result) = &layer.tool_result { + let trimmed = line.trim_end_matches('\n').to_string(); + line = format!( + "{trimmed}, original_chars={}, sent_chars={}, truncated={}, deduplicated={}\n", + tool_result.original_chars, + tool_result.sent_chars, + tool_result.truncated, + tool_result.deduplicated + ); + } + if let Some(turn_meta) = &layer.turn_meta { + let trimmed = line.trim_end_matches('\n').to_string(); + line = format!( + "{trimmed}, turn_meta_original_chars={}, turn_meta_sent_chars={}, turn_meta_deduplicated={}, turn_meta_sha256={}\n", + turn_meta.original_chars, + turn_meta.sent_chars, + turn_meta.deduplicated, + turn_meta.sha256 + ); + } + out.push_str(&line); + } + app.session.last_cache_inspection = Some(inspection); + out +} + +fn format_static_prefix_status( + previous: Option<&PromptInspection>, + current: &PromptInspection, +) -> String { + let Some(previous) = previous else { + return "Static base prefix stability: no previous request\n".to_string(); + }; + if previous.base_static_prefix_hash == current.base_static_prefix_hash { + return "Static base prefix stability: OK\n".to_string(); + } + + let changed = changed_static_layers(previous, current); + if changed.is_empty() { + "Static base prefix stability: WARNING (base hash changed)\n".to_string() + } else { + format!( + "Static base prefix stability: WARNING changed layers: {}\n", + changed.join(", ") + ) + } +} + +fn format_first_divergence( + previous: Option<&PromptInspection>, + current: &PromptInspection, +) -> String { + let Some(previous) = previous else { + return "First divergence from previous request: unavailable\n".to_string(); + }; + let max_len = previous.layers.len().max(current.layers.len()); + for index in 0..max_len { + match (previous.layers.get(index), current.layers.get(index)) { + (Some(prev), Some(curr)) if prev.name == curr.name && prev.sha256 == curr.sha256 => {} + (Some(prev), Some(curr)) if prev.name == curr.name => { + return format!("First divergence from previous request: {}\n", curr.name); + } + (Some(_), Some(curr)) => { + return format!("First divergence from previous request: {}\n", curr.name); + } + (None, Some(curr)) => { + return format!("First divergence from previous request: {}\n", curr.name); + } + (Some(prev), None) => { + return format!( + "First divergence from previous request: {} removed\n", + prev.name + ); + } + (None, None) => break, + } + } + "First divergence from previous request: none\n".to_string() +} + +fn changed_static_layers(previous: &PromptInspection, current: &PromptInspection) -> Vec { + current + .layers + .iter() + .filter(|layer| layer.stability.label() == "static") + .filter(|layer| { + previous + .layers + .iter() + .find(|previous_layer| previous_layer.name == layer.name) + .is_none_or(|previous_layer| previous_layer.sha256 != layer.sha256) + }) + .map(|layer| layer.name.clone()) + .collect() +} + fn format_cache_history(app: &App, count: usize, locale: Locale) -> String { let total = app.session.turn_cache_history.len(); let start = total.saturating_sub(count); @@ -416,6 +567,171 @@ mod tests { assert!(msg.contains("no turns recorded yet"), "got: {msg}"); } + #[test] + fn cache_inspect_reports_hashes_without_prompt_text() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text( + "Base policy\n\n\nSECRET_PROJECT_RULE\n" + .to_string(), + )); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "SECRET_USER_TASK".to_string(), + cache_control: None, + }], + }); + + let result = cache(&mut app, Some("inspect")); + let msg = result.message.expect("inspect output"); + + assert!(msg.contains("Cache Inspect")); + assert!(msg.contains("Base static prefix hash:")); + assert!(msg.contains("Full request prefix hash:")); + assert!(msg.contains("Static base prefix stability: no previous request")); + assert!(msg.contains("First divergence from previous request: unavailable")); + assert!(msg.contains("Global system prefix: static")); + assert!(msg.contains("Project context: static")); + assert!(msg.contains("User task: dynamic")); + assert!(!msg.contains("SECRET_PROJECT_RULE")); + assert!(!msg.contains("SECRET_USER_TASK")); + } + + #[test] + fn cache_inspect_reports_divergence_from_previous_request() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text( + "Base policy\n\n## Environment\n\n- shell: powershell".to_string(), + )); + app.api_messages.push(Message { + role: "assistant".to_string(), + content: vec![crate::models::ContentBlock::Text { + text: "Prior answer".to_string(), + cache_control: None, + }], + }); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![crate::models::ContentBlock::Text { + text: "First task".to_string(), + cache_control: None, + }], + }); + + let first = cache(&mut app, Some("inspect")) + .message + .expect("first inspect output"); + assert!(first.contains("Static base prefix stability: no previous request")); + + if let Some(last) = app.api_messages.last_mut() + && let Some(crate::models::ContentBlock::Text { text, .. }) = last.content.first_mut() + { + *text = "Second task".to_string(); + } + + let second = cache(&mut app, Some("inspect")) + .message + .expect("second inspect output"); + assert!(second.contains("Static base prefix stability: OK")); + assert!(second.contains("First divergence from previous request: User task")); + assert!(second.contains("Message #1 assistant: history")); + } + + #[test] + fn cache_inspect_displays_tool_result_budget_metadata() { + let mut app = create_test_app(); + let long_output = format!("{}{}", "A".repeat(7_000), "Z".repeat(7_000)); + app.api_messages.push(Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "shell_command".to_string(), + input: serde_json::json!({"command": "cargo test"}), + caller: None, + }], + }); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool-1".to_string(), + content: long_output.clone(), + is_error: None, + content_blocks: None, + }], + }); + app.api_messages.push(Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: "tool-2".to_string(), + name: "shell_command".to_string(), + input: serde_json::json!({"command": "cargo test"}), + caller: None, + }], + }); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool-2".to_string(), + content: long_output, + is_error: None, + content_blocks: None, + }], + }); + + let result = cache(&mut app, Some("inspect")); + let msg = result.message.expect("inspect output"); + + assert!(msg.contains("original_chars=14000"), "got: {msg}"); + assert!(msg.contains("truncated=true"), "got: {msg}"); + assert!(msg.contains("deduplicated=false"), "got: {msg}"); + assert!(msg.contains("deduplicated=true"), "got: {msg}"); + } + + #[test] + fn cache_inspect_displays_turn_meta_dedup_metadata() { + let mut app = create_test_app(); + let turn_meta = format!( + "\nCurrent local date: 2026-05-09\n{}\n", + "Working set: src/lib.rs\n".repeat(20) + ); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ + ContentBlock::Text { + text: turn_meta.clone(), + cache_control: None, + }, + ContentBlock::Text { + text: "first task".to_string(), + cache_control: None, + }, + ], + }); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ + ContentBlock::Text { + text: turn_meta, + cache_control: None, + }, + ContentBlock::Text { + text: "second task".to_string(), + cache_control: None, + }, + ], + }); + + let result = cache(&mut app, Some("inspect")); + let msg = result.message.expect("inspect output"); + + assert!(msg.contains("turn_meta_original_chars="), "got: {msg}"); + assert!(msg.contains("turn_meta_sent_chars="), "got: {msg}"); + assert!(msg.contains("turn_meta_deduplicated=false"), "got: {msg}"); + assert!(msg.contains("turn_meta_deduplicated=true"), "got: {msg}"); + assert!(msg.contains("turn_meta_sha256="), "got: {msg}"); + assert!(!msg.contains("Working set: src/lib.rs"), "got: {msg}"); + } + #[test] fn cache_command_renders_recorded_turns_with_ratio() { let mut app = create_test_app(); diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 9446b7c3..3ff9631d 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -488,7 +488,7 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "cache", aliases: &[], - usage: "/cache [count]", + usage: "/cache [count|inspect|warmup]", description_id: MessageId::CmdCacheDescription, }, ]; @@ -927,6 +927,25 @@ mod tests { )); } + #[test] + fn cache_inspect_dispatches_through_cache_command() { + let mut app = create_test_app(); + let result = execute("/cache inspect", &mut app); + let msg = result.message.expect("cache inspect should return text"); + assert!(msg.contains("Cache Inspect")); + assert!(msg.contains("Base static prefix hash:")); + assert!(msg.contains("Full request prefix hash:")); + assert!(result.action.is_none()); + } + + #[test] + fn cache_warmup_dispatches_action() { + let mut app = create_test_app(); + let result = execute("/cache warmup", &mut app); + assert!(result.message.is_none()); + assert!(matches!(result.action, Some(AppAction::CacheWarmup))); + } + #[test] fn execute_config_opens_config_view_action() { let mut app = create_test_app(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index b557fa7f..6cdc5f10 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -690,6 +690,10 @@ pub struct ContextConfig { /// v0.7.5 audits V4 prefix-cache behavior. #[serde(default)] pub enabled: Option, + /// Include a deterministic project context pack in the stable prompt + /// prefix. Default: true; set `[context] project_pack = false` to disable. + #[serde(default)] + pub project_pack: Option, /// Verbatim window: last N turns never summarized. Default: 16. #[serde(default)] pub verbatim_window_turns: Option, @@ -1499,6 +1503,11 @@ impl Config { .unwrap_or(false) } + #[must_use] + pub fn project_context_pack_enabled(&self) -> bool { + self.context.project_pack.unwrap_or(true) + } + /// Return whether shell execution is allowed. #[must_use] pub fn allow_shell(&self) -> bool { @@ -2444,6 +2453,10 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { lsp: override_cfg.lsp.or(base.lsp), context: ContextConfig { enabled: override_cfg.context.enabled.or(base.context.enabled), + project_pack: override_cfg + .context + .project_pack + .or(base.context.project_pack), verbatim_window_turns: override_cfg .context .verbatim_window_turns @@ -4104,6 +4117,15 @@ api_key = "old-openrouter-key" assert_eq!(merged.context.enabled, Some(true)); } + #[test] + fn project_context_pack_defaults_on_and_can_be_disabled() { + let mut config = Config::default(); + assert!(config.project_context_pack_enabled()); + + config.context.project_pack = Some(false); + assert!(!config.project_context_pack_enabled()); + } + #[test] fn validate_accepts_future_deepseek_model_id() -> Result<()> { let config = Config { diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 7720253c..72ec04b9 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -95,6 +95,7 @@ pub struct EngineConfig { /// `instructions = [...]` config (or the per-project override). /// Resolved via `expand_path` so `~` works. pub instructions: Vec, + pub project_context_pack_enabled: bool, /// Maximum number of assistant steps before stopping. pub max_steps: u32, /// Maximum number of concurrently active subagents. @@ -166,6 +167,7 @@ impl Default for EngineConfig { mcp_config_path: PathBuf::from("mcp.json"), skills_dir: crate::skills::default_skills_dir(), instructions: Vec::new(), + project_context_pack_enabled: true, max_steps: 100, max_subagents: DEFAULT_MAX_SUBAGENTS, features: Features::with_defaults(), @@ -442,6 +444,7 @@ impl Engine { prompts::PromptSessionContext { user_memory_block: user_memory_block.as_deref(), goal_objective: config.goal_objective.as_deref(), + project_context_pack_enabled: config.project_context_pack_enabled, locale_tag: &config.locale_tag, }, session.approval_mode, @@ -1862,6 +1865,7 @@ impl Engine { prompts::PromptSessionContext { user_memory_block: user_memory_block.as_deref(), goal_objective: self.config.goal_objective.as_deref(), + project_context_pack_enabled: self.config.project_context_pack_enabled, locale_tag: &self.config.locale_tag, }, self.session.approval_mode, diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 2be8fc03..4ef41314 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -829,6 +829,33 @@ fn turn_metadata_includes_current_local_date_without_working_set() { assert!(text.contains(&format!("Current local date: {today}"))); } +#[test] +fn user_text_message_keeps_current_turn_input_after_turn_metadata() { + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + ..Default::default() + }; + let (engine, _handle) = Engine::new(config, &Config::default()); + + let user_msg = + engine.user_text_message_with_turn_metadata("explain the cache metrics".to_string()); + + let last_text = user_msg + .content + .iter() + .rev() + .find_map(|block| { + if let ContentBlock::Text { text, .. } = block { + Some(text.as_str()) + } else { + None + } + }) + .expect("user text block"); + assert_eq!(last_text, "explain the cache metrics"); +} + #[test] fn messages_with_turn_metadata_preserves_stored_messages_for_prefix_cache() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index d3bf02ed..773ecb65 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -4186,6 +4186,7 @@ async fn run_exec_agent( mcp_config_path: config.mcp_config_path(), skills_dir: config.skills_dir(), instructions: config.instructions_paths(), + project_context_pack_enabled: config.project_context_pack_enabled(), max_steps: 100, max_subagents, features: config.features(), diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index 96849828..c4a9ba17 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -11,9 +11,11 @@ //! The loaded content is injected into the system prompt to give the agent //! context about the project's conventions, structure, and requirements. +use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; +use serde::Serialize; use thiserror::Error; /// Names of project context files to look for, in priority order. @@ -26,6 +28,25 @@ const PROJECT_CONTEXT_FILES: &[&str] = &[ /// Maximum size for project context files (to prevent loading huge files) const MAX_CONTEXT_SIZE: usize = 100 * 1024; // 100KB +const PACK_README_MAX_CHARS: usize = 4_000; +const PACK_MAX_ENTRIES: usize = 400; +const PACK_MAX_SOURCE_FILES: usize = 80; +const PACK_MAX_CONFIG_FILES: usize = 80; +const PACK_MAX_DEPTH: usize = 4; +const PACK_IGNORED_DIRS: &[&str] = &[ + ".git", + "node_modules", + ".venv", + "venv", + "__pycache__", + "dist", + "build", + "target", + ".idea", + ".vscode", + ".pytest_cache", + ".DS_Store", +]; // === Errors === @@ -99,6 +120,203 @@ impl ProjectContext { } } +#[derive(Debug, Serialize)] +struct ProjectContextPack { + project_name: String, + directory_structure: Vec, + readme: Option, + config_files: Vec, + key_source_files: Vec, + counts: BTreeMap, +} + +#[derive(Debug, Serialize)] +struct ReadmePack { + path: String, + excerpt: String, +} + +/// Generate a deterministic, cache-friendly project context pack. +/// +/// The pack intentionally uses only stable workspace facts: relative paths, +/// sorted entries, bounded README text, and sorted JSON object fields. It does +/// not include timestamps, random ids, absolute temp paths, or live git state. +pub fn generate_project_context_pack(workspace: &Path) -> Option { + let mut entries = Vec::new(); + collect_pack_entries(workspace, workspace, 0, &mut entries); + entries.sort(); + entries.truncate(PACK_MAX_ENTRIES); + + let mut config_files = entries + .iter() + .filter(|path| is_config_file(path)) + .take(PACK_MAX_CONFIG_FILES) + .cloned() + .collect::>(); + config_files.sort(); + + let mut key_source_files = entries + .iter() + .filter(|path| is_source_file(path)) + .take(PACK_MAX_SOURCE_FILES) + .cloned() + .collect::>(); + key_source_files.sort(); + + let readme = read_readme_excerpt(workspace, &entries); + let mut counts = BTreeMap::new(); + counts.insert("config_files".to_string(), config_files.len()); + counts.insert("directory_entries".to_string(), entries.len()); + counts.insert("key_source_files".to_string(), key_source_files.len()); + + let pack = ProjectContextPack { + project_name: workspace + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("workspace") + .to_string(), + directory_structure: entries, + readme, + config_files, + key_source_files, + counts, + }; + + let json = serde_json::to_string_pretty(&pack).ok()?; + Some(format!( + "## Project Context Pack\n\n\n{json}\n" + )) +} + +fn collect_pack_entries(root: &Path, dir: &Path, depth: usize, out: &mut Vec) { + if depth > PACK_MAX_DEPTH || out.len() >= PACK_MAX_ENTRIES { + return; + } + + let Ok(read_dir) = fs::read_dir(dir) else { + return; + }; + let mut children = read_dir.filter_map(Result::ok).collect::>(); + children.sort_by_key(|entry| entry.path()); + + for entry in children { + if out.len() >= PACK_MAX_ENTRIES { + break; + } + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() && PACK_IGNORED_DIRS.contains(&name) { + continue; + } + + if let Some(relative) = relative_slash_path(root, &path) { + if file_type.is_dir() { + out.push(format!("{relative}/")); + collect_pack_entries(root, &path, depth + 1, out); + } else if file_type.is_file() { + out.push(relative); + } + } + } +} + +fn relative_slash_path(root: &Path, path: &Path) -> Option { + let relative = path.strip_prefix(root).ok()?; + let mut parts = Vec::new(); + for component in relative.components() { + parts.push(component.as_os_str().to_string_lossy().to_string()); + } + if parts.is_empty() { + None + } else { + Some(parts.join("/")) + } +} + +fn read_readme_excerpt(workspace: &Path, entries: &[String]) -> Option { + let path = entries + .iter() + .find(|path| { + let lower = path.to_ascii_lowercase(); + lower == "readme.md" || lower == "readme.txt" || lower == "readme" + })? + .clone(); + let raw = fs::read_to_string(workspace.join(&path)).ok()?; + let excerpt = truncate_chars(raw.trim(), PACK_README_MAX_CHARS); + if excerpt.is_empty() { + None + } else { + Some(ReadmePack { path, excerpt }) + } +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + value.chars().take(max_chars).collect::() +} + +fn is_config_file(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + let name = lower.rsplit('/').next().unwrap_or(lower.as_str()); + matches!( + name, + "cargo.toml" + | "package.json" + | "tsconfig.json" + | "pyproject.toml" + | "requirements.txt" + | "go.mod" + | "config.toml" + | "deepseek.toml" + | "dockerfile" + | "compose.yaml" + | "compose.yml" + | "docker-compose.yaml" + | "docker-compose.yml" + | "makefile" + ) || lower.ends_with(".config.js") + || lower.ends_with(".config.ts") + || lower.ends_with(".toml") + || lower.ends_with(".yaml") + || lower.ends_with(".yml") +} + +fn is_source_file(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + matches!( + lower.rsplit('.').next(), + Some( + "rs" | "py" + | "js" + | "jsx" + | "ts" + | "tsx" + | "go" + | "java" + | "kt" + | "c" + | "cc" + | "cpp" + | "h" + | "hpp" + | "cs" + | "rb" + | "php" + | "swift" + | "sql" + | "sh" + | "bash" + ) + ) +} + /// Load project context from the workspace directory. /// /// This searches for known project context files and loads the first one found. @@ -528,4 +746,36 @@ mod tests { .contains("Organization instructions") ); } + + #[test] + fn project_context_pack_is_stable_and_sorted() { + let tmp = tempdir().expect("tempdir"); + fs::write(tmp.path().join("README.md"), "# Demo\n\nReadme body").expect("write"); + fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"demo\"").expect("write"); + fs::create_dir_all(tmp.path().join("src")).expect("mkdir src"); + fs::write(tmp.path().join("src").join("z.rs"), "mod z;").expect("write z"); + fs::write(tmp.path().join("src").join("a.rs"), "mod a;").expect("write a"); + fs::create_dir_all(tmp.path().join("node_modules").join("pkg")).expect("mkdir ignored"); + fs::write( + tmp.path().join("node_modules").join("pkg").join("index.js"), + "ignored", + ) + .expect("write ignored"); + + let first = generate_project_context_pack(tmp.path()).expect("pack"); + let second = generate_project_context_pack(tmp.path()).expect("pack again"); + + assert_eq!(first, second); + assert!(first.contains("\"project_name\"")); + assert!(first.contains("\"directory_structure\"")); + assert!(first.contains("\"README.md\"")); + assert!(first.contains("\"Cargo.toml\"")); + assert!(first.contains("\"src/a.rs\"")); + assert!(first.contains("\"src/z.rs\"")); + assert!(!first.contains("node_modules")); + assert!( + first.find("\"src/a.rs\"").expect("a before z") + < first.find("\"src/z.rs\"").expect("z") + ); + } } diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 7938b96f..4650e2e3 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -17,6 +17,7 @@ use std::path::{Path, PathBuf}; pub struct PromptSessionContext<'a> { pub user_memory_block: Option<&'a str>, pub goal_objective: Option<&'a str>, + pub project_context_pack_enabled: bool, /// Resolved BCP-47 locale tag for the `## Environment` block in /// the system prompt (e.g. `"en"`, `"zh-Hans"`, `"ja"`). The /// caller is responsible for resolving this from `Settings`; no @@ -333,6 +334,7 @@ pub fn system_prompt_for_mode_with_context_and_skills( PromptSessionContext { user_memory_block, goal_objective: None, + project_context_pack_enabled: true, locale_tag: "en", }, ) @@ -383,6 +385,12 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( mode_prompt }; + if session_context.project_context_pack_enabled + && let Some(pack) = crate::project_context::generate_project_context_pack(workspace) + { + full_prompt = format!("{full_prompt}\n\n{pack}"); + } + // 2.25. Environment block — locale, platform, shell, pwd. All // four inputs are session-stable (workspace path is fixed for // the run; locale is loaded once by the caller; platform/shell @@ -545,6 +553,7 @@ mod tests { PromptSessionContext { user_memory_block: None, goal_objective: None, + project_context_pack_enabled: true, locale_tag: "ja", }, ) { @@ -556,6 +565,59 @@ mod tests { assert!(prompt.contains("- deepseek_version:")); } + #[test] + fn project_context_pack_can_be_disabled() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("README.md"), "# Pack test").expect("write readme"); + let prompt = match system_prompt_for_mode_with_context_skills_and_session( + AppMode::Agent, + tmp.path(), + None, + None, + None, + PromptSessionContext { + user_memory_block: None, + goal_objective: None, + project_context_pack_enabled: false, + locale_tag: "en", + }, + ) { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(_) => panic!("expected text system prompt"), + }; + assert!(!prompt.contains("")); + } + + #[test] + fn project_context_pack_is_before_dynamic_tail() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("README.md"), "# Pack test").expect("write readme"); + std::fs::create_dir_all(tmp.path().join(".deepseek")).expect("mkdir"); + std::fs::write(tmp.path().join(".deepseek").join("handoff.md"), "handoff") + .expect("handoff"); + let prompt = match system_prompt_for_mode_with_context_skills_and_session( + AppMode::Agent, + tmp.path(), + None, + None, + None, + PromptSessionContext { + user_memory_block: None, + goal_objective: None, + project_context_pack_enabled: true, + locale_tag: "en", + }, + ) { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(_) => panic!("expected text system prompt"), + }; + assert!(prompt.contains("")); + assert!( + prompt.find("").expect("pack") + < prompt.find("## Previous Session Handoff").expect("handoff") + ); + } + #[test] fn handoff_artifact_is_prepended_to_system_prompt_when_present() { let tmp = tempdir().expect("tempdir"); @@ -716,6 +778,7 @@ mod tests { PromptSessionContext { user_memory_block: None, goal_objective: Some("Fix transcript corruption"), + project_context_pack_enabled: true, locale_tag: "en", }, ) { @@ -743,6 +806,7 @@ mod tests { PromptSessionContext { user_memory_block: None, goal_objective: Some(" "), + project_context_pack_enabled: true, locale_tag: "en", }, ) { diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 64e15560..3abb6273 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1923,6 +1923,7 @@ impl RuntimeThreadManager { mcp_config_path: self.config.mcp_config_path(), skills_dir: self.config.skills_dir(), instructions: self.config.instructions_paths(), + project_context_pack_enabled: self.config.project_context_pack_enabled(), max_steps: 100, max_subagents: self.config.max_subagents().clamp(1, MAX_SUBAGENTS), features: self.config.features(), diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index cc5720eb..f73854e7 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -8,6 +8,7 @@ use ratatui::layout::Rect; use serde_json::Value; use thiserror::Error; +use crate::client::PromptInspection; use crate::compaction::CompactionConfig; use crate::config::{ ApiProvider, Config, DEFAULT_TEXT_MODEL, SavedCredential, has_api_key, save_api_key, @@ -626,6 +627,7 @@ pub struct SessionState { pub total_tokens: u32, pub total_conversation_tokens: u32, pub turn_cache_history: VecDeque, + pub last_cache_inspection: Option, } impl Default for SessionState { @@ -646,6 +648,7 @@ impl Default for SessionState { total_tokens: 0, total_conversation_tokens: 0, turn_cache_history: VecDeque::new(), + last_cache_inspection: None, } } } @@ -3818,6 +3821,7 @@ pub enum AppAction { }, ListSubAgents, FetchModels, + CacheWarmup, /// Switch the active LLM backend (DeepSeek vs NVIDIA NIM) without /// restarting the process. The runtime rebuilds its API client from /// the updated config. `model` overrides the post-switch model diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index f15b3f6d..06354a02 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -30,7 +30,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::audit::log_sensitive_event; use crate::automation_manager::{AutomationManager, AutomationSchedulerConfig, spawn_scheduler}; -use crate::client::DeepSeekClient; +use crate::client::{DeepSeekClient, build_cache_warmup_request}; use crate::commands; use crate::compaction::estimate_input_tokens_conservative; use crate::config::{ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL}; @@ -40,7 +40,10 @@ use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; use crate::core::events::Event as EngineEvent; use crate::core::ops::Op; use crate::hooks::HookEvent; -use crate::models::{ContentBlock, Message, SystemPrompt, context_window_for_model}; +use crate::llm_client::LlmClient; +use crate::models::{ + ContentBlock, Message, MessageRequest, SystemPrompt, Usage, context_window_for_model, +}; use crate::palette; use crate::prompts; use crate::session_manager::{ @@ -510,6 +513,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { mcp_config_path: config.mcp_config_path(), skills_dir: app.skills_dir.clone(), instructions: config.instructions_paths(), + project_context_pack_enabled: config.project_context_pack_enabled(), // Effectively unlimited. V4 has a 1M context window and the user // wants the model running until it's actually done. The previous cap // of 100 hit the ceiling on long multi-step plans (wide refactors, @@ -2981,6 +2985,50 @@ async fn fetch_available_models(config: &Config) -> Result> { Ok(ids) } +async fn run_cache_warmup(app: &App, config: &Config) -> Result { + let client = DeepSeekClient::new(config)?; + let reasoning_effort = if app.reasoning_effort == ReasoningEffort::Auto { + app.last_effective_reasoning_effort + .and_then(ReasoningEffort::api_value) + .map(str::to_string) + } else { + app.reasoning_effort.api_value().map(str::to_string) + }; + let request = MessageRequest { + model: app.model.clone(), + messages: app.api_messages.clone(), + max_tokens: 1024, + system: app.system_prompt.clone(), + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort, + stream: None, + temperature: None, + top_p: None, + }; + let warmup = build_cache_warmup_request(&request); + let response = + tokio::time::timeout(Duration::from_secs(45), client.create_message(warmup)).await??; + Ok(response.usage) +} + +fn format_cache_warmup_result(usage: &Usage) -> String { + let cache = match ( + usage.prompt_cache_hit_tokens, + usage.prompt_cache_miss_tokens, + ) { + (Some(hit), Some(miss)) => format!("Cache warmup complete: hit {hit} | miss {miss}"), + (Some(hit), None) => format!("Cache warmup complete: hit {hit} | miss unavailable"), + (None, Some(miss)) => format!("Cache warmup complete: hit unavailable | miss {miss}"), + (None, None) => "Cache warmup complete: cache telemetry unavailable".to_string(), + }; + format!( + "{cache}\nNote: the first warmup is usually a miss. Later requests that reuse the same stable prefix may hit the provider cache; a hit is not guaranteed." + ) +} + fn format_available_models_message(current_model: &str, models: &[String]) -> String { let mut lines = vec![format!("Available models ({})", models.len())]; for model in models { @@ -3739,6 +3787,7 @@ async fn dispatch_user_message( prompts::PromptSessionContext { user_memory_block: None, goal_objective: app.goal.goal_objective.as_deref(), + project_context_pack_enabled: config.project_context_pack_enabled(), locale_tag: app.ui_locale.tag(), }, ), @@ -4556,6 +4605,24 @@ async fn apply_command_result( } } } + AppAction::CacheWarmup => { + app.status_message = Some("Warming DeepSeek cache...".to_string()); + match run_cache_warmup(app, config).await { + Ok(usage) => { + let message = format_cache_warmup_result(&usage); + app.add_message(HistoryCell::System { + content: message.clone(), + }); + app.status_message = Some("Cache warmup complete".to_string()); + } + Err(error) => { + app.add_message(HistoryCell::System { + content: format!("Cache warmup failed: {error}"), + }); + app.status_message = Some("Cache warmup failed".to_string()); + } + } + } AppAction::SwitchProvider { provider, model } => { switch_provider(app, engine_handle, config, provider, model).await; } @@ -7002,9 +7069,15 @@ fn footer_coherence_spans(app: &App) -> Vec> { } fn footer_cache_spans(app: &App) -> Vec> { - let Some(hit_tokens) = app.session.last_prompt_cache_hit_tokens else { + if app.session.last_prompt_tokens.is_none() && app.session.last_completion_tokens.is_none() { return Vec::new(); }; + let Some(hit_tokens) = app.session.last_prompt_cache_hit_tokens else { + return vec![Span::styled( + "Cache: unavailable", + Style::default().fg(palette::TEXT_MUTED), + )]; + }; let miss_tokens = app .session .last_prompt_cache_miss_tokens @@ -7015,11 +7088,11 @@ fn footer_cache_spans(app: &App) -> Vec> { .saturating_sub(hit_tokens) }); let total = hit_tokens.saturating_add(miss_tokens); - if total == 0 { - return Vec::new(); - } - - let percent = (f64::from(hit_tokens) / f64::from(total) * 100.0).clamp(0.0, 100.0); + let percent = if total == 0 { + 0.0 + } else { + (f64::from(hit_tokens) / f64::from(total) * 100.0).clamp(0.0, 100.0) + }; // Threshold-based coloring for cache hit rate (#396): // >80%: green (good cache utilization) // 40-80%: yellow/warning @@ -7032,7 +7105,10 @@ fn footer_cache_spans(app: &App) -> Vec> { palette::STATUS_ERROR }; vec![Span::styled( - format!("cache hit {:.0}%", percent), + format!( + "Cache: {:.1}% hit | hit {hit_tokens} | miss {miss_tokens}", + percent + ), Style::default().fg(color), )] } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index cdfcf4ad..04d6c138 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1483,11 +1483,22 @@ fn footer_auxiliary_spans_show_cache_when_compact() { app.session.last_prompt_cache_miss_tokens = Some(12_000); app.session.session_cost = 12.34; - let compact = spans_text(&footer_auxiliary_spans(&app, 14)); - assert!(compact.contains("cache")); + let compact = spans_text(&footer_auxiliary_spans(&app, 48)); + assert!(compact.contains("Cache: 75.0% hit")); assert!(!compact.contains('$')); } +#[test] +fn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() { + let mut app = create_test_app(); + app.session.last_prompt_tokens = Some(48_000); + app.session.last_completion_tokens = Some(2_000); + + let roomy = spans_text(&footer_auxiliary_spans(&app, 72)); + + assert!(roomy.contains("Cache: unavailable")); +} + #[test] fn footer_auxiliary_spans_show_cache_and_cost_when_roomy() { let mut app = create_test_app(); @@ -1496,8 +1507,8 @@ fn footer_auxiliary_spans_show_cache_and_cost_when_roomy() { app.session.last_prompt_cache_miss_tokens = Some(12_000); app.session.session_cost = 12.34; - let roomy = spans_text(&footer_auxiliary_spans(&app, 32)); - assert!(roomy.contains("cache hit 75%")); + let roomy = spans_text(&footer_auxiliary_spans(&app, 72)); + assert!(roomy.contains("Cache: 75.0% hit | hit 36000 | miss 12000")); assert!(roomy.contains("$12.34")); assert!( !roomy.contains("ctx"), From 5f9f5ed558ae5e59db8382a7ee8e895d19f8ab09 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 9 May 2026 02:17:22 -0500 Subject: [PATCH 09/11] fix(tui): re-arm mouse capture on FocusGained MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing gap exposed during v0.8.24 testing: when the user clicks away (Cmd+Tab, opens the screenshot tool, etc.) and clicks back, some terminals drop the application's mouse-tracking mode. The \`FocusGained\` handler at \`ui.rs::1599\` already re-pushed keyboard enhancement flags to recover IME state — extend the same recovery to \`EnableMouseCapture\` so wheel scroll keeps working after a focus round-trip. Gated on \`app.use_mouse_capture\` so explicit \`--no-mouse-capture\` users aren't re-enabled against their will. --- CHANGELOG.md | 8 +++++++- crates/tui/src/tui/ui.rs | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e88c866b..5e282dd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,19 @@ 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). -## [0.8.24] - 2026-05-08 +## [0.8.24] - 2026-05-09 A bugfix + refactor release picking up the backlog after the v0.8.23 security release. ### Fixed +- **Mouse-wheel scroll survives focus toggles** — on macOS, switching away + (Cmd+Tab, opening the screenshot tool, etc.) and back can drop the + terminal's mouse-tracking mode, leaving wheel scroll dead until restart. + The TUI now re-arms `EnableMouseCapture` on `FocusGained` alongside the + existing keyboard-mode recapture, so wheel events keep flowing after a + focus round-trip. - **Workspace-local slash commands are now loaded (#1259)** — user command files placed in `/.deepseek/commands/`, `/.claude/commands/`, and `/.cursor/commands/` are diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 06354a02..9993864c 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1596,8 +1596,14 @@ async fn run_event_loop( // terminal's keyboard mode, which breaks IME compositor state. // Acknowledging FocusGained and re-pushing the flags restores // the IME so CJK input methods work after a focus toggle. + // The same reset can drop the terminal's mouse-tracking mode, + // leaving wheel scroll dead until restart — re-arm mouse + // capture on focus-gain so wheel events keep flowing. if terminal_event_needs_viewport_recapture(&evt) { push_keyboard_enhancement_flags(terminal.backend_mut()); + if app.use_mouse_capture { + let _ = execute!(terminal.backend_mut(), EnableMouseCapture); + } force_terminal_repaint = true; app.needs_redraw = true; } From 277621fba04745ebdf8eb32274b84985b2b26586 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 9 May 2026 02:26:41 -0500 Subject: [PATCH 10/11] docs(release): credit contributors + document Project Context Pack default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG additions: - Top-line credit summary: wplll, Liu-Vince, Giggitycountless, SamhandsomeLee, barjatiyasaurabh, tyculw, hongyuatcufe, ljlbit. - New "Added" section properly documenting #1196 (cache-aware diagnostics, /cache inspect, /cache warmup, payload optimization, Project Context Pack). Calls out that the Pack is default-on, adds ~1–10 KB to every prompt, and how to opt out via [context] project_pack = false. - Per-item issue reporter credits across the Fixed section. - Removed #1129 from the i18n entry — that's a separate bug we did not actually fix (wrong env var name in HTTP system prompt). README updates: rewrote the "What's New" section in both README.md and README.zh-CN.md to v0.8.24 with all the same credits and the project_pack opt-out note. --- CHANGELOG.md | 55 ++++++++++++++++++++++++++++++++++++--------- README.md | 59 ++++++++++++++++++++++++++++++------------------- README.zh-CN.md | 44 ++++++++++++++++++++++-------------- 3 files changed, 108 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e282dd8..5eb828a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.8.24] - 2026-05-09 A bugfix + refactor release picking up the backlog after the v0.8.23 security -release. +release. Big thanks to **wplll** (cache-aware prompt + `/cache inspect`), +**Liu-Vince** (MCP pagination diagnosis), **@Giggitycountless** (snapshot cap +proposal), and to issue reporters **@SamhandsomeLee**, +**@barjatiyasaurabh**, **@tyculw**, **@hongyuatcufe**, and **@ljlbit** for +the bugs fixed below. ### Fixed @@ -23,7 +27,7 @@ release. `/.claude/commands/`, and `/.cursor/commands/` are now discovered alongside the existing global `~/.deepseek/commands/`. Workspace-local commands shadow global by name, matching the precedence - model already used for skills. + model already used for skills. Reported by **@SamhandsomeLee**. - **`@`-mention completion finds AI-tool dot-directories** — files inside `.deepseek/`, `.cursor/`, `.claude/`, and `.agents/` are now discoverable in `@`-mention Tab-completion even when those directories are excluded by @@ -32,25 +36,54 @@ release. - **MCP paginated discovery (#1250, #1256)** — tools, resources, resource templates, and prompts from MCP servers that paginate their responses (e.g., gbrain at 5 items per page) are now fully discovered by following - the MCP spec's `nextCursor` across all pages. Thanks **Liu-Vince** for - the diagnosis and fix. + the MCP spec's `nextCursor` across all pages. Reported by + **@hongyuatcufe**; thanks to **Liu-Vince** for the diagnosis and PR + #1256 with the same fix shape. - **Snapshot storage has a disk-space cap (#1112)** — the snapshot side repo now enforces a 500 MB hard limit. When the limit is exceeded at snapshot time, the oldest snapshots are pruned aggressively to stay under a 400 MB target. Guards against the reported 1.2 TB snapshot blowup during - high-churn sessions. + high-churn sessions. Reported by **@tyculw**; thanks to + **@Giggitycountless** for the PR #1131 proposal that informed the + hard-cap approach. - **`/clear` now resets the Todos sidebar (#1258)** — previously `/clear` only reset the Plan panel; the Todos checklist persisted across clears until app restart. The fix ensures `clear_todos()` clears the - `SharedTodoList` inner state. + `SharedTodoList` inner state. Reported by **@barjatiyasaurabh**. + +### Added + +- **Cache-aware prompt diagnostics + payload optimization (#1196)** — adds + a `PromptBuilder` that classifies the system prompt into `static` / + `history` / `dynamic` layers for cache-prefix stability, plus: + - `/cache inspect` — shows SHA-256 hashes per layer, base static prefix + hash vs full request prefix hash, static-prefix stability across + turns, and first-divergence tracking. Does not print prompt text. + - `/cache warmup` — prefetches the stable prefix to seed the DeepSeek + context cache. + - **Project Context Pack injected into the stable prefix by default** + — a structured workspace summary (directory listing up to 4 levels / + 400 entries, README excerpt up to 4 KB, config + key source file + lists). Adds **~1–10 KB to every prompt depending on repo size**, in + exchange for a much more cacheable prefix. **Default ON**; disable + with `[context] project_pack = false` in `~/.deepseek/config.toml` + if you'd rather keep prompts minimal. + - Wire-payload optimization: large tool outputs are budgeted, repeated + identical tool outputs and `` blocks are deduplicated + with stable refs (wire-only — local session messages stay intact). + - Footer cache-hit % chip from `prompt_cache_hit_tokens` / + `prompt_cache_miss_tokens` in the API response. + + Thanks **wplll** for the design and implementation. ### Changed -- **Language directive strengthened against project-context bias (#1118, - #1129)** — the system prompt now explicitly instructs the model that - project context (AGENTS.md, auto-generated instructions, file trees) is - NOT a language signal. Chinese filenames in a repo no longer bias the - model toward Chinese replies when the user writes in English. +- **Language directive strengthened against project-context bias (#1118)** + — the system prompt now explicitly instructs the model that project + context (AGENTS.md, auto-generated instructions, file trees) is NOT a + language signal. Chinese filenames in a repo no longer bias the model + toward Chinese replies when the user writes in English. Reported by + **@ljlbit**. ### Known issues diff --git a/README.md b/README.md index 7dd5335f..e72eee91 100644 --- a/README.md +++ b/README.md @@ -225,31 +225,44 @@ deepseek --provider ollama --model deepseek-coder:1.3b --- -## What's New In v0.8.23 +## What's New In v0.8.24 -A security-focused follow-up to v0.8.22: sanitized child-process environments, -tighter tool-safety classifications, and fixes for MCP, secrets, and the -runtime API. [Full changelog](CHANGELOG.md). +A community-focused bugfix release picking up the backlog after the v0.8.23 +security release. [Full changelog](CHANGELOG.md). -- **Child-process environment scrubbed** — shells, MCP servers, hooks, and other - spawned subprocesses now start from an explicit env allowlist instead of - inheriting every parent variable. No more accidental `*_API_KEY` or - `GITHUB_TOKEN` leakage through subprocesses. -- **macOS Keychain prompts gone** — the file-backed secret store is now the - default; the OS keyring is opt-in via `DEEPSEEK_SECRET_BACKEND=system|keyring`. -- **MCP servers stay working** — MCP stdio launches now inherit the env vars - that `npx`, `uvx`, `python -m`, and proxy-bound corporate setups need, while - still scrubbing secrets. -- **MCP spawn errors are visible** — instead of an opaque wrapper message, you - now see the real OS error ("No such file or directory") when an MCP server - can't start. -- **Live thinking is compact by default** — the streaming thinking panel - collapses by default; expand via the details toggle. -- **Runtime API requires auth by default** — `deepseek serve --http` no longer - accepts unauthenticated requests. -- **Plus**: hardened `run_tests` approval, symlink-traversal guards, Plan-mode - tool-surface tightening, path-sanitization fixes, and a new - `docs/RELEASE_CHECKLIST.md`. +- **Cache-aware prompt diagnostics + payload optimization** (#1196, thanks + **wplll**) — new `/cache inspect` and `/cache warmup` commands, layered + prompt classification (static / history / dynamic) with per-layer SHA-256 + hashes, wire-payload dedup for repeated tool outputs, and a footer cache-hit + % chip from the DeepSeek API response. A new **Project Context Pack** is + injected into the stable prefix by default to improve cache hit rates; + disable with `[context] project_pack = false` if you'd rather keep prompts + minimal. +- **Workspace-local slash commands** (#1259) — drop a `.deepseek/commands/foo.md` + in any project and `/foo` works there. Also scans `.cursor/commands/` and + `.claude/commands/`. Project-local shadows global by name. +- **`@`-mention completion finds AI-tool dot-directories** — files inside + `.deepseek/`, `.cursor/`, `.claude/`, and `.agents/` are now discoverable + via `@` completion even when those dirs are in `.gitignore`. +- **MCP paginated discovery** (#1250, thanks **Liu-Vince**) — MCP servers that + paginate `tools/list` (e.g., gbrain at 5 per page) now have all their tools + discovered via `nextCursor`. +- **Snapshot disk cap** (#1112) — the snapshot side repo enforces a 500 MB + hard limit, pruning oldest first when it's hit. Guards against the reported + 1.2 TB blowup. Thanks **@Giggitycountless** for the PR #1131 proposal. +- **`/clear` resets the Todos sidebar** (#1258) — was only clearing the Plan + panel before. +- **Mouse-wheel survives focus toggles** — re-arms `EnableMouseCapture` on + `FocusGained` so wheel scroll keeps working after Cmd+Tab or screenshot + workflows. +- **i18n: prompts in English get English replies** (#1118) — Chinese + filenames in a project tree no longer bias the model toward Chinese + responses. +- **Plus**: language-directive strengthening, MCP error-message clarity + improvements (PR #1196), and assorted polish. + +⚠️ **Known issue:** v0.8.22+ have a Windows 10 conhost flicker regression +(#1260) tracked for v0.8.25. v0.8.20 works correctly if you're affected. --- diff --git a/README.zh-CN.md b/README.zh-CN.md index c9eb6f26..0e3c1778 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -192,24 +192,36 @@ deepseek --provider ollama --model deepseek-coder:1.3b --- -## v0.8.23 新功能 +## v0.8.24 新功能 -面向安全的 v0.8.22 跟进版本:子进程环境清理、工具安全分类收紧,以及 MCP、 -密钥存储和运行时 API 的修复。[完整更新日志](CHANGELOG.md)。 +承接 v0.8.23 安全发布之后的社区 bug 修复版本。[完整更新日志](CHANGELOG.md)。 -- **子进程环境已清理** — shell、MCP 服务器、hooks 等子进程现在从显式环境变量 - 白名单启动,不再继承所有父进程变量。`*_API_KEY`、`GITHUB_TOKEN` 等敏感信息 - 不会通过子进程泄露。 -- **macOS 钥匙串弹窗已消除** — 文件存储现在是默认的密钥后端;系统钥匙串需通过 - `DEEPSEEK_SECRET_BACKEND=system|keyring` 主动选择加入。 -- **MCP 服务器保持正常运行** — MCP stdio 启动时保留 `npx`、`uvx`、`python -m` - 和企业代理等所需的环境变量,同时继续清理密钥。 -- **MCP 启动错误现在可见** — 不再显示模糊的包装错误信息,而是直接展示真实的 - 操作系统错误(如 "No such file or directory")。 -- **实时思考默认折叠** — 流式思考面板默认折叠,可通过详情切换展开。 -- **运行时 API 默认要求认证** — `deepseek serve --http` 不再接受未认证请求。 -- **此外**:加固 `run_tests` 审批策略、符号链接遍历防护、Plan 模式工具集收紧、 - 路径清理修复,以及新增 `docs/RELEASE_CHECKLIST.md`。 +- **缓存感知的 prompt 诊断和载荷优化** (#1196,感谢 **wplll**) — 新增 + `/cache inspect` 和 `/cache warmup` 命令,对系统 prompt 进行分层(static / + history / dynamic)并展示每层的 SHA-256 哈希;线材有效载荷去重重复工具输出; + 页脚展示来自 DeepSeek API 响应的缓存命中率。新增**项目上下文包**默认注入到 + 稳定前缀以提高缓存命中率;如需保持 prompt 简洁,可在配置中设置 + `[context] project_pack = false` 关闭。 +- **工作区本地的斜杠命令** (#1259) — 在任意项目中放置 + `.deepseek/commands/foo.md`,`/foo` 即可在该项目中可用。同时扫描 + `.cursor/commands/` 和 `.claude/commands/`。项目本地按名称覆盖全局。 +- **`@` 提示补全可发现 AI 工具点目录** — 即使 + `.deepseek/`、`.cursor/`、`.claude/`、`.agents/` 在 `.gitignore` 中, + 这些目录下的文件也能通过 `@` 补全发现。 +- **MCP 分页发现** (#1250,感谢 **Liu-Vince**) — 对 `tools/list` 进行分页的 + MCP 服务器(如 gbrain 每页 5 个)现在通过 `nextCursor` 完整发现所有工具。 +- **快照磁盘容量上限** (#1112) — 快照副本仓库现在强制 500 MB 上限, + 超出时按时间从旧到新清理。可防止报告中 1.2 TB 快照失控。感谢 + **@Giggitycountless** 的 PR #1131 提案。 +- **`/clear` 现在重置 Todos 侧边栏** (#1258) — 以前只清空 Plan 面板。 +- **鼠标滚轮在焦点切换后仍可用** — 在 `FocusGained` 时重新启用 + `EnableMouseCapture`,使 Cmd+Tab 或截屏后滚轮滚动仍正常工作。 +- **i18n:英文提问得到英文回复** (#1118) — 项目中的中文文件名不再使模型偏向 + 中文回复。 +- **此外**:语言指令加强、MCP 错误信息更清晰(来自 PR #1196),及若干打磨。 + +⚠️ **已知问题**:v0.8.22+ 在 Windows 10 conhost 上存在闪烁回归(#1260), +跟踪到 v0.8.25 修复。如受影响,v0.8.20 工作正常。 --- From 2c49b7e84b7b3f96ae8e29cfdfcf2eabb0fa4679 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 9 May 2026 02:31:21 -0500 Subject: [PATCH 11/11] fix(release): bump per-crate Cargo.toml path-dep pins + npm package version to 0.8.24 --- 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/Cargo.toml | 4 ++-- npm/deepseek-tui/package.json | 4 ++-- 10 files changed, 33 insertions(+), 33 deletions(-) diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 96e54662..76206ae2 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.23" } +deepseek-config = { path = "../config", version = "0.8.24" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index a9eaf2a5..e3b66b2a 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.23" } -deepseek-config = { path = "../config", version = "0.8.23" } -deepseek-core = { path = "../core", version = "0.8.23" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.23" } -deepseek-hooks = { path = "../hooks", version = "0.8.23" } -deepseek-mcp = { path = "../mcp", version = "0.8.23" } -deepseek-protocol = { path = "../protocol", version = "0.8.23" } -deepseek-state = { path = "../state", version = "0.8.23" } -deepseek-tools = { path = "../tools", version = "0.8.23" } +deepseek-agent = { path = "../agent", version = "0.8.24" } +deepseek-config = { path = "../config", version = "0.8.24" } +deepseek-core = { path = "../core", version = "0.8.24" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.24" } +deepseek-hooks = { path = "../hooks", version = "0.8.24" } +deepseek-mcp = { path = "../mcp", version = "0.8.24" } +deepseek-protocol = { path = "../protocol", version = "0.8.24" } +deepseek-state = { path = "../state", version = "0.8.24" } +deepseek-tools = { path = "../tools", version = "0.8.24" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 69f841a3..76710970 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.23" } -deepseek-app-server = { path = "../app-server", version = "0.8.23" } -deepseek-config = { path = "../config", version = "0.8.23" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.23" } -deepseek-mcp = { path = "../mcp", version = "0.8.23" } -deepseek-secrets = { path = "../secrets", version = "0.8.23" } -deepseek-state = { path = "../state", version = "0.8.23" } +deepseek-agent = { path = "../agent", version = "0.8.24" } +deepseek-app-server = { path = "../app-server", version = "0.8.24" } +deepseek-config = { path = "../config", version = "0.8.24" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.24" } +deepseek-mcp = { path = "../mcp", version = "0.8.24" } +deepseek-secrets = { path = "../secrets", version = "0.8.24" } +deepseek-state = { path = "../state", version = "0.8.24" } chrono.workspace = true dirs.workspace = true serde.workspace = true diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 93ece828..6f769a60 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.23" } +deepseek-secrets = { path = "../secrets", version = "0.8.24" } dirs.workspace = true serde.workspace = true toml.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 25c69a43..dc419b66 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.23" } -deepseek-config = { path = "../config", version = "0.8.23" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.23" } -deepseek-hooks = { path = "../hooks", version = "0.8.23" } -deepseek-mcp = { path = "../mcp", version = "0.8.23" } -deepseek-protocol = { path = "../protocol", version = "0.8.23" } -deepseek-state = { path = "../state", version = "0.8.23" } -deepseek-tools = { path = "../tools", version = "0.8.23" } +deepseek-agent = { path = "../agent", version = "0.8.24" } +deepseek-config = { path = "../config", version = "0.8.24" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.24" } +deepseek-hooks = { path = "../hooks", version = "0.8.24" } +deepseek-mcp = { path = "../mcp", version = "0.8.24" } +deepseek-protocol = { path = "../protocol", version = "0.8.24" } +deepseek-state = { path = "../state", version = "0.8.24" } +deepseek-tools = { path = "../tools", version = "0.8.24" } serde_json.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index fd5bbc5b..72e011cb 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.23" } +deepseek-protocol = { path = "../protocol", version = "0.8.24" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index b7a1915d..e47e6481 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.23" } +deepseek-protocol = { path = "../protocol", version = "0.8.24" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 626411d3..91697aeb 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.23" } +deepseek-protocol = { path = "../protocol", version = "0.8.24" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 39bcc186..7201be46 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.23" } -deepseek-tools = { path = "../tools", version = "0.8.23" } +deepseek-secrets = { path = "../secrets", version = "0.8.24" } +deepseek-tools = { path = "../tools", version = "0.8.24" } schemaui = { version = "0.12.0", default-features = false, optional = true } async-stream = "0.3.6" async-trait = "0.1" diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index badf9ed2..7711708d 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,7 +1,7 @@ { "name": "deepseek-tui", - "version": "0.8.23", - "deepseekBinaryVersion": "0.8.23", + "version": "0.8.24", + "deepseekBinaryVersion": "0.8.24", "description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.", "author": "Hmbown", "license": "MIT",