chore(release): merge v0.8.24 (#1283)

Bugfix + community PR release. See CHANGELOG.md for full notes.
This commit is contained in:
Hunter Bown
2026-05-09 02:41:48 -05:00
committed by GitHub
37 changed files with 3040 additions and 241 deletions
+89
View File
@@ -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 `<workspace>/.deepseek/commands/`,
`<workspace>/.claude/commands/`, and `<workspace>/.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 **~110 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 `<turn_meta>` 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
Generated
+14 -14
View File
@@ -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"
+1 -1
View File
@@ -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
+36 -23
View File
@@ -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.
---
+28 -16
View File
@@ -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 工作正常。
---
+1 -1
View File
@@ -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
+9 -9
View File
@@ -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
+7 -7
View File
@@ -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
+1 -1
View File
@@ -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
+8 -8
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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"
+293
View File
@@ -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: "<turn_meta>\nCurrent local date: 2026-05-08\n</turn_meta>"
.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<project_instructions source=\"AGENTS.md\">\nRules\n</project_instructions>\n\n## Project Context Pack\n\n<project_context_pack>\n{}\n</project_context_pack>\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<project_instructions source=\"AGENTS.md\">\nStable project rules\n</project_instructions>\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!({
File diff suppressed because it is too large Load Diff
+320 -4
View File
@@ -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::<usize>().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::<usize>().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<String> {
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<project_instructions source=\"AGENTS.md\">\nSECRET_PROJECT_RULE\n</project_instructions>"
.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!(
"<turn_meta>\nCurrent local date: 2026-05-09\n{}\n</turn_meta>",
"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();
+28 -3
View File
@@ -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<String> {
///
/// `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<String> {
let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase();
let mut result: Vec<String> = COMMANDS
.iter()
@@ -730,7 +736,7 @@ pub fn all_command_names_matching(prefix: &str) -> Vec<String> {
.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();
+223 -30
View File
@@ -1,36 +1,54 @@
//! User-defined slash commands from `~/.deepseek/commands/<name>.md`.
//! User-defined slash commands from `~/.deepseek/commands/<name>.md` and
//! workspace-local `<workspace>/.deepseek/commands/<name>.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. `<workspace>/.deepseek/commands/` (project-local, highest)
//! 2. `<workspace>/.claude/commands/` (Claude Code interop)
//! 3. `<workspace>/.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<PathBuf> {
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<String> = 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<CommandResult> {
pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option<CommandResult> {
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<CommandR
///
/// The prefix should be the command name portion only (after `/`).
/// Returns entries formatted as `/name`.
pub fn user_commands_matching(prefix: &str) -> Vec<String> {
///
/// `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<String> {
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<String> {
#[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:?}"
);
}
}
+22
View File
@@ -690,6 +690,10 @@ pub struct ContextConfig {
/// v0.7.5 audits V4 prefix-cache behavior.
#[serde(default)]
pub enabled: Option<bool>,
/// 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<bool>,
/// Verbatim window: last N turns never summarized. Default: 16.
#[serde(default)]
pub verbatim_window_turns: Option<usize>,
@@ -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 {
+4
View File
@@ -95,6 +95,7 @@ pub struct EngineConfig {
/// `instructions = [...]` config (or the per-project override).
/// Resolved via `expand_path` so `~` works.
pub instructions: Vec<PathBuf>,
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,
+27
View File
@@ -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");
+1
View File
@@ -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(),
+120 -54
View File
@@ -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<String> = 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<McpTool> = 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<String> = 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<McpResource> =
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<String> = 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<McpResourceTemplate> =
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<String> = 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<McpPrompt> =
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(())
}
+250
View File
@@ -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<String>,
readme: Option<ReadmePack>,
config_files: Vec<String>,
key_source_files: Vec<String>,
counts: BTreeMap<String, usize>,
}
#[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<String> {
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::<Vec<_>>();
config_files.sort();
let mut key_source_files = entries
.iter()
.filter(|path| is_source_file(path))
.take(PACK_MAX_SOURCE_FILES)
.cloned()
.collect::<Vec<_>>();
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<project_context_pack>\n{json}\n</project_context_pack>"
))
}
fn collect_pack_entries(root: &Path, dir: &Path, depth: usize, out: &mut Vec<String>) {
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::<Vec<_>>();
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<String> {
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<ReadmePack> {
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::<String>()
}
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")
);
}
}
+64
View File
@@ -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("<project_context_pack>"));
}
#[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("<project_context_pack>"));
assert!(
prompt.find("<project_context_pack>").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",
},
) {
+2
View File
@@ -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.
+1
View File
@@ -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(),
+115
View File
@@ -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<SnapshotId> {
// 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<u64> {
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<usize> {
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);
}
}
+15 -2
View File
@@ -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<TurnCacheRecord>,
pub last_cache_inspection: Option<PromptInspection>,
}
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
+38
View File
@@ -434,6 +434,44 @@ fn collect_candidates(root: &Path) -> Vec<String> {
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
}
+17 -5
View File
@@ -20,7 +20,13 @@ pub fn visible_slash_menu_entries(app: &App, limit: usize) -> Vec<SlashMenuEntry
if app.slash_menu_hidden {
return Vec::new();
}
slash_completion_hints(&app.input, limit, &app.cached_skills, app.ui_locale)
slash_completion_hints(
&app.input,
limit,
&app.cached_skills,
app.ui_locale,
Some(&app.workspace),
)
}
/// Apply the currently-selected slash menu entry to the composer input.
@@ -63,10 +69,16 @@ pub fn try_autocomplete_slash_command(app: &mut App) -> bool {
return false;
}
let candidates = slash_completion_hints(&app.input, 128, &app.cached_skills, app.ui_locale)
.into_iter()
.map(|entry| entry.name)
.collect::<Vec<_>>();
let candidates = slash_completion_hints(
&app.input,
128,
&app.cached_skills,
app.ui_locale,
Some(&app.workspace),
)
.into_iter()
.map(|entry| entry.name)
.collect::<Vec<_>>();
if candidates.is_empty() {
return false;
+91 -9
View File
@@ -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<Vec<String>> {
Ok(ids)
}
async fn run_cache_warmup(app: &App, config: &Config) -> Result<Usage> {
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<Span<'static>> {
}
fn footer_cache_spans(app: &App) -> Vec<Span<'static>> {
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<Span<'static>> {
.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<Span<'static>> {
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),
)]
}
+15 -4
View File
@@ -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"),
+7 -6
View File
@@ -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<SlashMenuEntry> {
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);
+261 -11
View File
@@ -93,11 +93,7 @@ impl Workspace {
fn build_file_index(&self) -> HashMap<String, Vec<PathBuf>> {
let mut index: HashMap<String, Vec<PathBuf>> = 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<usize>) -> 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<String>,
substring_hits: &mut Vec<String>,
seen: &mut HashSet<PathBuf>,
max_depth: Option<usize>,
) {
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<String>,
seen: &mut HashSet<PathBuf>,
) {
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"
);
}
}
+2 -2
View File
@@ -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",