diff --git a/CHANGELOG.md b/CHANGELOG.md index 05e3846f..5eb828a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,95 @@ 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-09 + +A bugfix + refactor release picking up the backlog after the v0.8.23 security +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 + +- **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 + now discovered alongside the existing global `~/.deepseek/commands/`. + Workspace-local commands shadow global by name, matching the precedence + 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 + `.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. 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. 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. 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)** + — 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 + +- **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 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 工作正常。 --- 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/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 67d118c7..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, }, ]; @@ -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(); @@ -921,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/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/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/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(()) } 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/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. 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/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); + } } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index f753eefb..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, } } } @@ -3692,11 +3695,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) { @@ -3809,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/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/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/ui.rs b/crates/tui/src/tui/ui.rs index f15b3f6d..9993864c 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, @@ -1592,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; } @@ -2981,6 +2991,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 +3793,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 +4611,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 +7075,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 +7094,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 +7111,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"), 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); 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" + ); + } } 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",