diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f722a9fb..52cd68db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,6 +128,53 @@ jobs: with: files: artifacts/*/* prerelease: false + body: | + ## Install + + ### Recommended — npm (one command, both binaries) + + ```bash + npm install -g deepseek-tui + ``` + + The wrapper downloads both binaries from this Release and places them in the same directory. + + ### Cargo (Linux / macOS) + + ```bash + cargo install deepseek-tui-cli deepseek-tui --locked + ``` + + Both crates are required — `deepseek-tui-cli` produces the `deepseek` dispatcher and `deepseek-tui` produces the interactive runtime that the dispatcher delegates to. Installing only one binary will fail at runtime with a `MISSING_COMPANION_BINARY` error. + + ### Manual download + + **Both** binaries below must be downloaded for your platform and dropped into the same directory (e.g. `~/.local/bin/`): + + | Platform | Dispatcher | TUI runtime | + |---|---|---| + | Linux x64 | `deepseek-linux-x64` | `deepseek-tui-linux-x64` | + | macOS x64 | `deepseek-macos-x64` | `deepseek-tui-macos-x64` | + | macOS ARM | `deepseek-macos-arm64` | `deepseek-tui-macos-arm64` | + | Windows x64 | `deepseek-windows-x64.exe` | `deepseek-tui-windows-x64.exe` | + + Then `chmod +x` both (Unix) and run `./deepseek`. + + ### Verify (recommended) + + Download `deepseek-artifacts-sha256.txt` from this Release and verify: + + ```bash + # Linux + sha256sum -c deepseek-artifacts-sha256.txt + + # macOS + shasum -a 256 -c deepseek-artifacts-sha256.txt + ``` + + ## Changelog + + See [CHANGELOG.md](https://github.com/Hmbown/DeepSeek-TUI/blob/main/CHANGELOG.md) for the full notes for this release. # npm publish is intentionally not automated. The npm account requires 2FA OTP # on every publish, and a granular automation token that bypasses 2FA has not diff --git a/CHANGELOG.md b/CHANGELOG.md index b3576469..e5ee9962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.4] - 2026-05-02 + +### Added +- **Localization expansion (Phase 1, #285)** — every slash command's help + description, the full `/tokens` / `/cost` / `/cache` debug output, the + footer state and chip text, and the help-overlay section headings are + now translated for all four shipped locales (`en`, `ja`, `zh-Hans`, + `pt-BR`). Set the language with `/config locale zh-Hans` (or + `LANG=zh_CN.UTF-8` / `LC_ALL=zh_CN.UTF-8` from the shell). Non-Latin + scripts render via the same `unicode_width` plumbing the existing 27 + chrome strings already use; the `shipped_first_pack_has_no_missing_core_messages` + test enforces full coverage across all four locales for every new + `MessageId`. Tool descriptions sent to the model and the base system + prompt intentionally remain English (training-data alignment, prefix + cache stability). + - Phase 1a (#294): 44 new IDs covering slash commands. + - Phase 1b (#295): 13 new IDs covering `/tokens` / `/cost` / `/cache` + debug output. Templates use `{placeholder}` substitution so a + translator can re-order args freely. + - Phase 1c (#296): 11 new IDs covering footer state, sub-agent chip, + quit-confirmation toast, and help-overlay section labels. +- **Stable cache prefix** (#263) — five companion fixes to keep the + DeepSeek prefix cache stable across turns: drop volatile fields from + the working-set summary block (#280, #287), place handoff and + working-set after the static prompt blocks (#288 → #292), memoise the + tool catalog so descriptions stay byte-stable (#289), sort + `project_tree` and `summarize_project` output (#290), and use a unique + fallback id for parallel streaming tool calls so downstream tool-result + routing doesn't match the first call twice (#291). The combined effect + is a meaningful jump in cache hit rate after the third turn. + +### Fixed +- **Agent-mode shell exec could not reach the network** (#272) — the seatbelt + default policy denies all outbound network including DNS, so any + `exec_shell` command needing the network (`curl`, `yt-dlp`, package + managers, …) failed in Agent mode unless the user dropped to Yolo. The + engine now elevates the sandbox policy to `WorkspaceWrite { network_access: + true, … }` for both Agent and Yolo. Plan mode is unchanged (read-only + investigation never registers the shell tool). The application-level + `NetworkPolicy` (`crates/tui/src/network_policy.rs`) remains the only + outbound-traffic boundary. +- **`/skill install ` failed with `invalid gzip header`** (#269) + — `https://github.com//` parsed as a raw direct URL, so the + installer downloaded the HTML repo page and tried to gzip-decode HTML. + Bare GitHub repo URLs (with or without `.git`, with or without `www.`, + with or without a trailing slash) now route to the `GitHubRepo` source the + same as `github:/`. URLs that already point at a specific + archive / blob / tree path still go through `DirectUrl`. +- **V4 Pro discount expiry extended** (#267) — DeepSeek extended the V4 Pro 75% + promotional discount from 2026-05-05 15:59 UTC to 2026-05-31 15:59 UTC. Without + this update the TUI would have started showing 4× the actual billed cost on + May 6 onwards. Verified at https://api-docs.deepseek.com/quick_start/pricing. + ## [0.8.3] - 2026-05-01 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 25b8cc66..d6ea0410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,7 +1011,7 @@ dependencies = [ [[package]] name = "deepseek-agent" -version = "0.8.3" +version = "0.8.4" dependencies = [ "deepseek-config", "serde", @@ -1019,7 +1019,7 @@ dependencies = [ [[package]] name = "deepseek-app-server" -version = "0.8.3" +version = "0.8.4" dependencies = [ "anyhow", "axum", @@ -1042,7 +1042,7 @@ dependencies = [ [[package]] name = "deepseek-config" -version = "0.8.3" +version = "0.8.4" dependencies = [ "anyhow", "deepseek-secrets", @@ -1055,7 +1055,7 @@ dependencies = [ [[package]] name = "deepseek-core" -version = "0.8.3" +version = "0.8.4" dependencies = [ "anyhow", "chrono", @@ -1074,7 +1074,7 @@ dependencies = [ [[package]] name = "deepseek-execpolicy" -version = "0.8.3" +version = "0.8.4" dependencies = [ "anyhow", "deepseek-protocol", @@ -1083,7 +1083,7 @@ dependencies = [ [[package]] name = "deepseek-hooks" -version = "0.8.3" +version = "0.8.4" dependencies = [ "anyhow", "async-trait", @@ -1097,7 +1097,7 @@ dependencies = [ [[package]] name = "deepseek-mcp" -version = "0.8.3" +version = "0.8.4" dependencies = [ "anyhow", "deepseek-protocol", @@ -1107,7 +1107,7 @@ dependencies = [ [[package]] name = "deepseek-protocol" -version = "0.8.3" +version = "0.8.4" dependencies = [ "serde", "serde_json", @@ -1115,7 +1115,7 @@ dependencies = [ [[package]] name = "deepseek-secrets" -version = "0.8.3" +version = "0.8.4" dependencies = [ "dirs", "keyring", @@ -1128,7 +1128,7 @@ dependencies = [ [[package]] name = "deepseek-state" -version = "0.8.3" +version = "0.8.4" dependencies = [ "anyhow", "chrono", @@ -1140,7 +1140,7 @@ dependencies = [ [[package]] name = "deepseek-tools" -version = "0.8.3" +version = "0.8.4" dependencies = [ "anyhow", "async-trait", @@ -1153,7 +1153,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.8.3" +version = "0.8.4" dependencies = [ "anyhow", "arboard", @@ -1214,7 +1214,7 @@ dependencies = [ [[package]] name = "deepseek-tui-cli" -version = "0.8.3" +version = "0.8.4" dependencies = [ "anyhow", "chrono", @@ -1237,7 +1237,7 @@ dependencies = [ [[package]] name = "deepseek-tui-core" -version = "0.8.3" +version = "0.8.4" [[package]] name = "deranged" diff --git a/Cargo.toml b/Cargo.toml index b22cfdfa..adc7444a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.8.3" +version = "0.8.4" edition = "2024" license = "MIT" repository = "https://github.com/Hmbown/DeepSeek-TUI" diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index ce061459..5d87cd4e 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,5 +7,5 @@ repository.workspace = true description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture" [dependencies] -deepseek-config = { path = "../config", version = "0.8.3" } +deepseek-config = { path = "../config", version = "0.8.4" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index 60b1ab8e..46c473fa 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect anyhow.workspace = true axum.workspace = true clap.workspace = true -deepseek-agent = { path = "../agent", version = "0.8.3" } -deepseek-config = { path = "../config", version = "0.8.3" } -deepseek-core = { path = "../core", version = "0.8.3" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.3" } -deepseek-hooks = { path = "../hooks", version = "0.8.3" } -deepseek-mcp = { path = "../mcp", version = "0.8.3" } -deepseek-protocol = { path = "../protocol", version = "0.8.3" } -deepseek-state = { path = "../state", version = "0.8.3" } -deepseek-tools = { path = "../tools", version = "0.8.3" } +deepseek-agent = { path = "../agent", version = "0.8.4" } +deepseek-config = { path = "../config", version = "0.8.4" } +deepseek-core = { path = "../core", version = "0.8.4" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.4" } +deepseek-hooks = { path = "../hooks", version = "0.8.4" } +deepseek-mcp = { path = "../mcp", version = "0.8.4" } +deepseek-protocol = { path = "../protocol", version = "0.8.4" } +deepseek-state = { path = "../state", version = "0.8.4" } +deepseek-tools = { path = "../tools", version = "0.8.4" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index bed047cc..f0a34069 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -14,13 +14,13 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -deepseek-agent = { path = "../agent", version = "0.8.3" } -deepseek-app-server = { path = "../app-server", version = "0.8.3" } -deepseek-config = { path = "../config", version = "0.8.3" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.3" } -deepseek-mcp = { path = "../mcp", version = "0.8.3" } -deepseek-secrets = { path = "../secrets", version = "0.8.3" } -deepseek-state = { path = "../state", version = "0.8.3" } +deepseek-agent = { path = "../agent", version = "0.8.4" } +deepseek-app-server = { path = "../app-server", version = "0.8.4" } +deepseek-config = { path = "../config", version = "0.8.4" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.4" } +deepseek-mcp = { path = "../mcp", version = "0.8.4" } +deepseek-secrets = { path = "../secrets", version = "0.8.4" } +deepseek-state = { path = "../state", version = "0.8.4" } chrono.workspace = true dirs.workspace = true serde.workspace = true diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index fea07d63..9f40c0f6 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,7 +8,7 @@ description = "Config schema and precedence model for DeepSeek workspace archite [dependencies] anyhow.workspace = true -deepseek-secrets = { path = "../secrets", version = "0.8.3" } +deepseek-secrets = { path = "../secrets", version = "0.8.4" } dirs.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index cda119ab..9890117a 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,14 +9,14 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -deepseek-agent = { path = "../agent", version = "0.8.3" } -deepseek-config = { path = "../config", version = "0.8.3" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.3" } -deepseek-hooks = { path = "../hooks", version = "0.8.3" } -deepseek-mcp = { path = "../mcp", version = "0.8.3" } -deepseek-protocol = { path = "../protocol", version = "0.8.3" } -deepseek-state = { path = "../state", version = "0.8.3" } -deepseek-tools = { path = "../tools", version = "0.8.3" } +deepseek-agent = { path = "../agent", version = "0.8.4" } +deepseek-config = { path = "../config", version = "0.8.4" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.4" } +deepseek-hooks = { path = "../hooks", version = "0.8.4" } +deepseek-mcp = { path = "../mcp", version = "0.8.4" } +deepseek-protocol = { path = "../protocol", version = "0.8.4" } +deepseek-state = { path = "../state", version = "0.8.4" } +deepseek-tools = { path = "../tools", version = "0.8.4" } serde_json.workspace = true tokio.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 7d15b484..4f1d24c5 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace [dependencies] anyhow.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.8.3" } +deepseek-protocol = { path = "../protocol", version = "0.8.4" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index c570f50b..fd19e178 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.8.3" } +deepseek-protocol = { path = "../protocol", version = "0.8.4" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 532dbbd4..64d709c5 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -8,6 +8,6 @@ description = "MCP server lifecycle and tool proxy compatibility for DeepSeek wo [dependencies] anyhow.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.8.3" } +deepseek-protocol = { path = "../protocol", version = "0.8.4" } serde.workspace = true serde_json.workspace = true diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index 3856f0d0..f1a2483e 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -273,13 +273,20 @@ impl KeyringStore for FileKeyringStore { } fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> { - let mut blob = self.load_unlocked().unwrap_or_default(); + // load_unlocked already returns Ok(default) for a missing file, so the + // first-write-creates-the-file path is preserved. Any other Err + // (insecure permissions, corrupt JSON, transient I/O) MUST surface to + // the caller — propagating it via `unwrap_or_default()` silently + // wipes every previously stored secret on the next `store_unlocked`. + let mut blob = self.load_unlocked()?; blob.entries.insert(key.to_string(), value.to_string()); self.store_unlocked(&blob) } fn delete(&self, key: &str) -> Result<(), SecretsError> { - let mut blob = self.load_unlocked().unwrap_or_default(); + // Same invariant as `set`: never fall back to an empty blob on read + // error, or `delete ` becomes `delete `. + let mut blob = self.load_unlocked()?; blob.entries.remove(key); self.store_unlocked(&blob) } @@ -564,6 +571,98 @@ mod tests { ); } + // Regression for #281: `set` and `delete` used to call + // `load_unlocked().unwrap_or_default()`, which silently wiped every + // existing secret whenever the read failed (insecure permissions, + // corrupt JSON, or any other I/O error). + + #[cfg(unix)] + #[test] + fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("secrets.json"); + let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}"; + fs::write(&path, original).unwrap(); + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o644); + fs::set_permissions(&path, perms).unwrap(); + + let store = FileKeyringStore::new(path.clone()); + let err = store.set("openrouter", "or-new").unwrap_err(); + assert!( + matches!(err, SecretsError::InsecurePermissions { .. }), + "set must surface the read error rather than overwriting; got: {err}" + ); + + let on_disk = fs::read_to_string(&path).unwrap(); + assert_eq!( + on_disk, original, + "set must not modify the file when load_unlocked errored" + ); + } + + #[cfg(unix)] + #[test] + fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("secrets.json"); + let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}"; + fs::write(&path, original).unwrap(); + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o644); + fs::set_permissions(&path, perms).unwrap(); + + let store = FileKeyringStore::new(path.clone()); + let err = store.delete("nvidia").unwrap_err(); + assert!( + matches!(err, SecretsError::InsecurePermissions { .. }), + "delete must surface the read error rather than wiping the file; got: {err}" + ); + let on_disk = fs::read_to_string(&path).unwrap(); + assert_eq!(on_disk, original); + } + + #[test] + fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("secrets.json"); + // Corrupt JSON. Permissions ok where unix; on Windows the perm-check + // doesn't run so we exercise the json-error path directly. + fs::write(&path, "{ this is not valid json").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o600); + fs::set_permissions(&path, perms).unwrap(); + } + + let store = FileKeyringStore::new(path.clone()); + let err = store.set("deepseek", "sk-new").unwrap_err(); + assert!( + matches!(err, SecretsError::Json(_)), + "set must surface the parse error rather than wiping the file; got: {err}" + ); + let on_disk = fs::read_to_string(&path).unwrap(); + assert_eq!(on_disk, "{ this is not valid json"); + } + + #[test] + fn file_store_set_still_creates_file_when_missing() { + // Regression guard: the #281 fix removed `unwrap_or_default()` from + // the load call. Make sure the original first-write-creates-the-file + // ergonomic still works — `load_unlocked` returns `Ok(default)` for + // a missing file, so the `?` should pass through cleanly. + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("nested").join("secrets.json"); + let store = FileKeyringStore::new(path.clone()); + + store.set("deepseek", "sk-fresh").unwrap(); + assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string())); + } + #[test] fn file_store_default_path_uses_home() { // We don't override HOME here (other tests do); we just check the diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 6accfd76..d150b694 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral [dependencies] anyhow.workspace = true async-trait.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.8.3" } +deepseek-protocol = { path = "../protocol", version = "0.8.4" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 91c0c3fe..d76e79d8 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -14,9 +14,9 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.100" arboard = "3.4" -deepseek-tui-cli = { path = "../cli", version = "0.8.3" } -deepseek-secrets = { path = "../secrets", version = "0.8.3" } -deepseek-tools = { path = "../tools", version = "0.8.3" } +deepseek-tui-cli = { path = "../cli", version = "0.8.4" } +deepseek-secrets = { path = "../secrets", version = "0.8.4" } +deepseek-tools = { path = "../tools", version = "0.8.4" } async-stream = "0.3.6" async-trait = "0.1" bytes = "1.11.0" diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index df0216a1..97a35345 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -1178,11 +1178,21 @@ pub(super) fn parse_sse_chunk( *thinking_started = false; } + let block_index = *content_index; let id = tc .get("id") .and_then(Value::as_str) - .unwrap_or("tool_call") - .to_string(); + .map(str::to_string) + // Some upstream gateways (and the responses-API + // bridge) elide the `id` on the first chunk of a + // tool call. Falling back to a constant string + // collides when the model emits parallel tool + // calls in the same delta — every call ended up + // with the same id and downstream tool-result + // routing matched the first one twice. Index by + // the content-block position to keep the + // fallback unique within the response. + .unwrap_or_else(|| format!("call_{block_index}")); let name = tc .get("function") .and_then(|f| f.get("name")) @@ -1201,7 +1211,6 @@ pub(super) fn parse_sse_chunk( }) }); - let block_index = *content_index; events.push(StreamEvent::ContentBlockStart { index: block_index, content_block: ContentBlockStart::ToolUse { @@ -1471,4 +1480,64 @@ mod stream_decoder_tests { "should yield InputJsonDelta carrying the tool args; got {events:?}" ); } + + /// Regression for the parallel-tool-calls-without-id collision (audit + /// Finding 8): when the upstream chunk omits the `id` field, the + /// fallback used to be the literal string `"tool_call"` for every + /// parallel call, so two tool calls in one delta ended up sharing an + /// id. Downstream routing then matched the first call's tool_result + /// twice and the second call hung. The fallback is now indexed by the + /// content-block position, keeping each call unique within the + /// response. + #[test] + fn decoder_assigns_unique_fallback_ids_to_parallel_tool_calls_missing_id() { + let events = decode_chunk( + r#"{"choices":[{"delta":{"tool_calls":[ + {"index":0,"function":{"name":"grep_files","arguments":"{\"pattern\":\"a\"}"}}, + {"index":1,"function":{"name":"read_file","arguments":"{\"path\":\"x\"}"}} + ]}}]}"#, + ); + + let ids: Vec<&str> = events + .iter() + .filter_map(|e| match e { + StreamEvent::ContentBlockStart { + content_block: ContentBlockStart::ToolUse { id, .. }, + .. + } => Some(id.as_str()), + _ => None, + }) + .collect(); + + assert_eq!( + ids.len(), + 2, + "expected two tool-use blocks for parallel tool calls; got {events:?}" + ); + assert_ne!( + ids[0], ids[1], + "parallel tool calls without upstream `id` must get distinct fallback ids; got {ids:?}" + ); + } + + #[test] + fn decoder_preserves_upstream_tool_call_id_when_present() { + // Counter-test to the fallback regression: when the upstream chunk + // does include `id`, we forward it verbatim — we shouldn't quietly + // rewrite ids the API gave us just because we have a fallback path. + let events = decode_chunk( + r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_xyz","function":{"name":"grep_files","arguments":"{}"}}]}}]}"#, + ); + let id = events + .iter() + .find_map(|e| match e { + StreamEvent::ContentBlockStart { + content_block: ContentBlockStart::ToolUse { id, .. }, + .. + } => Some(id.as_str()), + _ => None, + }) + .expect("tool-use block present"); + assert_eq!(id, "call_xyz"); + } } diff --git a/crates/tui/src/command_safety.rs b/crates/tui/src/command_safety.rs index bc33a43b..cafcbfab 100644 --- a/crates/tui/src/command_safety.rs +++ b/crates/tui/src/command_safety.rs @@ -1,4 +1,3 @@ -// TODO(integrate): Wire command safety analysis into shell tool approval flow #![allow(dead_code)] //! Command safety analysis for shell execution diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 2066ede9..63f92a60 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -15,7 +15,9 @@ pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult { if let Some(cmd) = super::get_command_info(topic) { let mut help = format!( "{}\n\n {}\n\n Usage: {}", - cmd.name, cmd.description, cmd.usage + cmd.name, + cmd.description_for(app.ui_locale), + cmd.usage ); if !cmd.aliases.is_empty() { let _ = write!(help, "\n Aliases: {}", cmd.aliases.join(", ")); diff --git a/crates/tui/src/commands/debug.rs b/crates/tui/src/commands/debug.rs index 593958d9..8d4b9d0f 100644 --- a/crates/tui/src/commands/debug.rs +++ b/crates/tui/src/commands/debug.rs @@ -2,82 +2,81 @@ //! Debug commands: tokens, cost, system, context, undo, retry +use std::time::Instant; + use super::CommandResult; use crate::compaction::estimate_input_tokens_conservative; +use crate::localization::{Locale, MessageId, tr}; use crate::models::{SystemPrompt, context_window_for_model}; -use crate::tui::app::{App, AppAction}; +use crate::tui::app::{App, AppAction, TurnCacheRecord}; use crate::tui::history::HistoryCell; -fn token_count(value: Option) -> String { - value.map_or_else(|| "not reported".to_string(), |tokens| tokens.to_string()) +fn token_count(value: Option, locale: Locale) -> String { + value.map_or_else( + || tr(locale, MessageId::CmdTokensNotReported).to_string(), + |tokens| tokens.to_string(), + ) } -fn active_context_summary(app: &App) -> String { +fn active_context_summary(app: &App, locale: Locale) -> String { let estimated = estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref()); match context_window_for_model(&app.model) { Some(window) => { let used = estimated.min(window as usize); let percent = (used as f64 / f64::from(window) * 100.0).clamp(0.0, 100.0); - format!("~{used} / {window} ({percent:.1}%)") + tr(locale, MessageId::CmdTokensContextWithWindow) + .replace("{used}", &used.to_string()) + .replace("{window}", &window.to_string()) + .replace("{percent}", &format!("{percent:.1}")) } - None => format!("~{estimated} / unknown window"), + None => tr(locale, MessageId::CmdTokensContextUnknownWindow) + .replace("{estimated}", &estimated.to_string()), } } -fn cache_summary(app: &App) -> String { +fn cache_summary(app: &App, locale: Locale) -> String { match ( app.last_prompt_cache_hit_tokens, app.last_prompt_cache_miss_tokens, ) { - (Some(hit), Some(miss)) => format!("{hit} hit / {miss} miss"), - (Some(hit), None) => format!("{hit} hit / miss not reported"), - (None, Some(miss)) => format!("hit not reported / {miss} miss"), - (None, None) => "not reported".to_string(), + (Some(hit), Some(miss)) => tr(locale, MessageId::CmdTokensCacheBoth) + .replace("{hit}", &hit.to_string()) + .replace("{miss}", &miss.to_string()), + (Some(hit), None) => { + tr(locale, MessageId::CmdTokensCacheHitOnly).replace("{hit}", &hit.to_string()) + } + (None, Some(miss)) => { + tr(locale, MessageId::CmdTokensCacheMissOnly).replace("{miss}", &miss.to_string()) + } + (None, None) => tr(locale, MessageId::CmdTokensNotReported).to_string(), } } /// Show token usage for session pub fn tokens(app: &mut App) -> CommandResult { + let locale = app.ui_locale; let message_count = app.api_messages.len(); let chat_count = app.history.len(); - CommandResult::message(format!( - "Token Usage:\n\ - ─────────────────────────────\n\ - Active context: {}\n\ - Last API input: {} (turn telemetry; may count repeated prefix across tool rounds)\n\ - Last API output: {}\n\ - Cache hit/miss: {} (telemetry/cost only)\n\ - Cumulative tokens: {} (session usage telemetry)\n\ - Approx session cost: ${:.4}\n\ - API messages: {}\n\ - Chat messages: {}\n\ - Model: {}", - active_context_summary(app), - token_count(app.last_prompt_tokens), - token_count(app.last_completion_tokens), - cache_summary(app), - app.total_tokens, - app.session_cost, - message_count, - chat_count, - app.model, - )) + let report = tr(locale, MessageId::CmdTokensReport) + .replace("{active}", &active_context_summary(app, locale)) + .replace("{input}", &token_count(app.last_prompt_tokens, locale)) + .replace("{output}", &token_count(app.last_completion_tokens, locale)) + .replace("{cache}", &cache_summary(app, locale)) + .replace("{total}", &app.total_tokens.to_string()) + .replace("{cost}", &format!("{:.4}", app.session_cost)) + .replace("{api_messages}", &message_count.to_string()) + .replace("{chat_messages}", &chat_count.to_string()) + .replace("{model}", &app.model); + CommandResult::message(report) } /// Show session cost breakdown pub fn cost(app: &mut App) -> CommandResult { - CommandResult::message(format!( - "Session Cost:\n\ - ─────────────────────────────\n\ - Approx total spent: ${:.4}\n\n\ - Cost estimates are approximate and use provider usage telemetry when available.\n\n\ - DeepSeek API Pricing:\n\ - ─────────────────────────────\n\ - Pricing details are not configured in this CLI.", - app.session_cost, - )) + let report = tr(app.ui_locale, MessageId::CmdCostReport) + .replace("{cost}", &format!("{:.4}", app.session_cost)); + CommandResult::message(report) } /// Show current system prompt @@ -121,6 +120,141 @@ pub fn context(_app: &mut App) -> CommandResult { CommandResult::action(AppAction::OpenContextInspector) } +/// Show per-turn DeepSeek prefix-cache telemetry for the last N turns (#263). +/// +/// `arg` is parsed as a count override (default 10, capped at the ring size). +/// Renders a fixed-width table the user can paste into a bug report. +pub fn cache(app: &mut App, arg: Option<&str>) -> CommandResult { + let want = arg + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(10); + let cap = app.turn_cache_history.len(); + let count = want + .min(cap) + .min(crate::tui::app::App::TURN_CACHE_HISTORY_CAP); + + if cap == 0 { + return CommandResult::message(tr(app.ui_locale, MessageId::CmdCacheNoData)); + } + + CommandResult::message(format_cache_history(app, count, app.ui_locale)) +} + +fn format_cache_history(app: &App, count: usize, locale: Locale) -> String { + let total = app.turn_cache_history.len(); + let start = total.saturating_sub(count); + let rows: Vec<&TurnCacheRecord> = app.turn_cache_history.iter().skip(start).collect(); + + let mut totals_input: u64 = 0; + let mut totals_hit: u64 = 0; + let mut totals_miss: u64 = 0; + let mut header = tr(locale, MessageId::CmdCacheHeader) + .replace("{count}", &rows.len().to_string()) + .replace("{total}", &total.to_string()) + .replace("{model}", &app.model); + header.push_str(&"─".repeat(76)); + header.push('\n'); + header.push_str("turn in out hit miss replay ratio age\n"); + header.push_str(&"─".repeat(76)); + header.push('\n'); + + let now = Instant::now(); + let mut body = String::new(); + let absolute_start = total.saturating_sub(rows.len()); + for (i, rec) in rows.iter().enumerate() { + let turn_index = absolute_start + i + 1; + totals_input += u64::from(rec.input_tokens); + + let replay_cell = rec + .reasoning_replay_tokens + .map_or_else(|| "—".to_string(), |t| t.to_string()); + let age = humanize_age(now.saturating_duration_since(rec.recorded_at)); + + // No cache telemetry → render `—` everywhere and don't pollute totals + // with inferred zeros. Some providers (and some routes inside DeepSeek) + // skip the cache fields; including a synthesized 0/N for those turns + // would make every aggregate ratio look broken. + let Some(hit) = rec.cache_hit_tokens else { + body.push_str(&format!( + "{turn:>4} {input:>5} {output:>5} {hit:>5} {miss:>5} {replay:>6} {ratio:>6} {age}\n", + turn = turn_index, + input = rec.input_tokens, + output = rec.output_tokens, + hit = "—", + miss = "—", + replay = replay_cell, + ratio = "—", + age = age, + )); + continue; + }; + + let miss_reported = rec.cache_miss_tokens; + let miss = miss_reported.unwrap_or_else(|| rec.input_tokens.saturating_sub(hit)); + let accounted = u64::from(hit) + u64::from(miss); + let ratio = if accounted == 0 { + " —".to_string() + } else { + format!("{:>5.1}%", 100.0 * f64::from(hit) / accounted as f64) + }; + totals_hit += u64::from(hit); + totals_miss += u64::from(miss); + + let miss_cell = match miss_reported { + Some(_) => format!("{miss}"), + None => format!("{miss}*"), + }; + + body.push_str(&format!( + "{turn:>4} {input:>5} {output:>5} {hit:>5} {miss:>5} {replay:>6} {ratio} {age}\n", + turn = turn_index, + input = rec.input_tokens, + output = rec.output_tokens, + hit = hit, + miss = miss_cell, + replay = replay_cell, + ratio = ratio, + age = age, + )); + } + + let totals_accounted = totals_hit + totals_miss; + let avg_ratio = if totals_accounted == 0 { + "—".to_string() + } else { + format!( + "{:.1}%", + 100.0 * totals_hit as f64 / totals_accounted as f64 + ) + }; + + let mut footer = String::new(); + footer.push_str(&"─".repeat(76)); + footer.push('\n'); + footer.push_str( + &tr(locale, MessageId::CmdCacheTotals) + .replace("{sum_in}", &totals_input.to_string()) + .replace("{sum_hit}", &totals_hit.to_string()) + .replace("{sum_miss}", &totals_miss.to_string()) + .replace("{avg}", &avg_ratio), + ); + footer.push_str(tr(locale, MessageId::CmdCacheFootnote)); + footer.push_str(tr(locale, MessageId::CmdCacheAdvice)); + + format!("{header}{body}{footer}") +} + +fn humanize_age(d: std::time::Duration) -> String { + let secs = d.as_secs(); + if secs < 60 { + format!("{secs}s") + } else if secs < 3600 { + format!("{}m{:02}s", secs / 60, secs % 60) + } else { + format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60) + } +} + #[cfg(test)] mod tests { use super::*; @@ -256,6 +390,115 @@ mod tests { assert!(msg.contains("chars total")); } + #[test] + fn cache_command_reports_no_data_before_first_turn() { + let mut app = create_test_app(); + let result = cache(&mut app, None); + let msg = result.message.expect("cache produces a message"); + assert!(msg.contains("no turns recorded yet"), "got: {msg}"); + } + + #[test] + fn cache_command_renders_recorded_turns_with_ratio() { + let mut app = create_test_app(); + let now = Instant::now(); + // Three turns: 75% hit, 50% hit, miss-only (provider didn't report hit). + app.push_turn_cache_record(TurnCacheRecord { + input_tokens: 4_000, + output_tokens: 200, + cache_hit_tokens: Some(3_000), + cache_miss_tokens: Some(1_000), + reasoning_replay_tokens: None, + recorded_at: now, + }); + app.push_turn_cache_record(TurnCacheRecord { + input_tokens: 6_000, + output_tokens: 250, + cache_hit_tokens: Some(3_000), + cache_miss_tokens: Some(3_000), + reasoning_replay_tokens: Some(150), + recorded_at: now, + }); + // Turn 3: hit reported but provider didn't report miss separately — + // infer miss = input − hit and mark with `*`. + app.push_turn_cache_record(TurnCacheRecord { + input_tokens: 5_000, + output_tokens: 100, + cache_hit_tokens: Some(2_500), + cache_miss_tokens: None, + reasoning_replay_tokens: None, + recorded_at: now, + }); + // Turn 4: no telemetry at all — must not pollute aggregate ratios. + app.push_turn_cache_record(TurnCacheRecord { + input_tokens: 1_000, + output_tokens: 50, + cache_hit_tokens: None, + cache_miss_tokens: None, + reasoning_replay_tokens: None, + recorded_at: now, + }); + + let result = cache(&mut app, None); + let msg = result.message.expect("cache produces a message"); + + // Header reflects total rows and model. + assert!(msg.contains("last 4 of 4 turn(s)"), "got: {msg}"); + // Per-turn ratios are rendered. + assert!(msg.contains("75.0%"), "got: {msg}"); + assert!(msg.contains("50.0%"), "got: {msg}"); + // Turn 3: hit=2500, inferred miss=2500 → 50.0% with `*`-marked miss. + assert!(msg.contains("2500*"), "got: {msg}"); + // Turn 4 (no telemetry) shows em-dashes and is excluded from totals. + // Aggregate over turns 1-3: hit=8500, miss=6500 → 56.7%. + assert!(msg.contains("avg hit ratio: 56.7%"), "got: {msg}"); + // Footer guidance is present. + assert!(msg.contains("70%"), "got: {msg}"); + } + + #[test] + fn cache_command_count_argument_clamps_to_history() { + let mut app = create_test_app(); + for _ in 0..3 { + app.push_turn_cache_record(TurnCacheRecord { + input_tokens: 1_000, + output_tokens: 100, + cache_hit_tokens: Some(500), + cache_miss_tokens: Some(500), + reasoning_replay_tokens: None, + recorded_at: Instant::now(), + }); + } + let result = cache(&mut app, Some("100")); + let msg = result.message.expect("cache produces a message"); + // Asked for 100 turns, only 3 exist — should report "last 3 of 3". + assert!(msg.contains("last 3 of 3 turn(s)"), "got: {msg}"); + } + + #[test] + fn turn_cache_history_is_capped_at_50() { + let mut app = create_test_app(); + for i in 0..(crate::tui::app::App::TURN_CACHE_HISTORY_CAP + 12) { + app.push_turn_cache_record(TurnCacheRecord { + input_tokens: i as u32, + output_tokens: 1, + cache_hit_tokens: Some(i as u32), + cache_miss_tokens: Some(0), + reasoning_replay_tokens: None, + recorded_at: Instant::now(), + }); + } + assert_eq!( + app.turn_cache_history.len(), + crate::tui::app::App::TURN_CACHE_HISTORY_CAP + ); + // Oldest record was evicted; newest record is still at the back. + assert_eq!( + app.turn_cache_history.back().unwrap().input_tokens, + (crate::tui::app::App::TURN_CACHE_HISTORY_CAP + 11) as u32 + ); + } + #[test] fn test_context_shows_usage_stats() { let mut app = create_test_app(); diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index bfd66bb2..75a16058 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -20,6 +20,7 @@ mod session; mod skills; mod task; +use crate::localization::{Locale, MessageId, tr}; use crate::tui::app::{App, AppAction}; /// Result of executing a command @@ -74,13 +75,18 @@ impl CommandResult { } } -/// Command metadata for help and autocomplete +/// Command metadata for help and autocomplete. +/// +/// The English description lives in `localization::english` (private), keyed +/// by `description_id`. Callers resolve a localized description through +/// [`CommandInfo::description_for`] which delegates to +/// [`crate::localization::tr`]. #[derive(Debug, Clone, Copy)] pub struct CommandInfo { pub name: &'static str, pub aliases: &'static [&'static str], - pub description: &'static str, pub usage: &'static str, + pub description_id: MessageId, } impl CommandInfo { @@ -96,11 +102,16 @@ impl CommandInfo { } } - pub fn palette_description(&self) -> String { + pub fn description_for(&self, locale: Locale) -> &'static str { + tr(locale, self.description_id) + } + + pub fn palette_description_for(&self, locale: Locale) -> String { + let desc = self.description_for(locale); if self.aliases.is_empty() { - self.description.to_string() + desc.to_string() } else { - format!("{} aliases: {}", self.description, self.aliases.join(", ")) + format!("{} aliases: {}", desc, self.aliases.join(", ")) } } } @@ -111,266 +122,273 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "help", aliases: &["?"], - description: "Show help information", usage: "/help [command]", + description_id: MessageId::CmdHelpDescription, }, CommandInfo { name: "clear", aliases: &[], - description: "Clear conversation history", usage: "/clear", + description_id: MessageId::CmdClearDescription, }, CommandInfo { name: "exit", aliases: &["quit", "q"], - description: "Exit the application", usage: "/exit", + description_id: MessageId::CmdExitDescription, }, CommandInfo { name: "model", aliases: &[], - description: "Switch or view current model", usage: "/model [name]", + description_id: MessageId::CmdModelDescription, }, CommandInfo { name: "models", aliases: &[], - description: "List available models from API", usage: "/models", + description_id: MessageId::CmdModelsDescription, }, CommandInfo { name: "provider", aliases: &[], - description: "Switch or view the active LLM backend (deepseek | nvidia-nim)", usage: "/provider [name]", + description_id: MessageId::CmdProviderDescription, }, CommandInfo { name: "queue", aliases: &["queued"], - description: "View or edit queued messages", usage: "/queue [list|edit |drop |clear]", + description_id: MessageId::CmdQueueDescription, }, CommandInfo { name: "subagents", aliases: &["agents"], - description: "List sub-agent status", usage: "/subagents", + description_id: MessageId::CmdSubagentsDescription, }, CommandInfo { name: "links", aliases: &["dashboard", "api"], - description: "Show DeepSeek dashboard and docs links", usage: "/links", + description_id: MessageId::CmdLinksDescription, }, CommandInfo { name: "home", aliases: &["stats", "overview"], - description: "Show home dashboard with stats and quick actions", usage: "/home", + description_id: MessageId::CmdHomeDescription, }, CommandInfo { name: "note", aliases: &[], - description: "Append note to persistent notes file (.deepseek/notes.md)", usage: "/note ", + description_id: MessageId::CmdNoteDescription, }, CommandInfo { name: "attach", aliases: &["image", "media"], - description: "Attach image/video media; use @path for text files or directories", usage: "/attach ", + description_id: MessageId::CmdAttachDescription, }, CommandInfo { name: "task", aliases: &["tasks"], - description: "Manage background tasks", usage: "/task [add |list|show |cancel ]", + description_id: MessageId::CmdTaskDescription, }, CommandInfo { name: "jobs", aliases: &["job"], - description: "Inspect and control background shell jobs", usage: "/jobs [list|show |poll |wait |stdin |cancel ]", + description_id: MessageId::CmdJobsDescription, }, CommandInfo { name: "mcp", aliases: &[], - description: "Open or manage MCP servers", usage: "/mcp [init|add stdio [args...]|add http |enable |disable |remove |validate|reload]", + description_id: MessageId::CmdMcpDescription, }, // Session commands CommandInfo { name: "save", aliases: &[], - description: "Save session to file", usage: "/save [path]", + description_id: MessageId::CmdSaveDescription, }, CommandInfo { name: "sessions", aliases: &["resume"], - description: "Open session picker", usage: "/sessions", + description_id: MessageId::CmdSessionsDescription, }, CommandInfo { name: "load", aliases: &[], - description: "Load session from file", usage: "/load [path]", + description_id: MessageId::CmdLoadDescription, }, CommandInfo { name: "compact", aliases: &[], - description: "Trigger context compaction to free up space (legacy; v0.6.6 prefers cycle restart)", usage: "/compact", + description_id: MessageId::CmdCompactDescription, }, CommandInfo { name: "context", aliases: &["ctx"], - description: "Open compact session context inspector", usage: "/context", + description_id: MessageId::CmdContextDescription, }, CommandInfo { name: "cycles", aliases: &[], - description: "List checkpoint-restart cycle handoffs in this session", usage: "/cycles", + description_id: MessageId::CmdCyclesDescription, }, CommandInfo { name: "cycle", aliases: &[], - description: "Show the carry-forward briefing for a specific cycle", usage: "/cycle ", + description_id: MessageId::CmdCycleDescription, }, CommandInfo { name: "recall", aliases: &[], - description: "Search prior cycle archives (BM25 over message text)", usage: "/recall ", + description_id: MessageId::CmdRecallDescription, }, CommandInfo { name: "export", aliases: &[], - description: "Export conversation to markdown", usage: "/export [path]", + description_id: MessageId::CmdExportDescription, }, // Config commands CommandInfo { name: "config", aliases: &[], - description: "Open interactive configuration editor", usage: "/config", + description_id: MessageId::CmdConfigDescription, }, CommandInfo { name: "yolo", aliases: &[], - description: "Enable YOLO mode (shell + trust + auto-approve)", usage: "/yolo", + description_id: MessageId::CmdYoloDescription, }, CommandInfo { name: "agent", aliases: &[], - description: "Switch to agent mode", usage: "/agent", + description_id: MessageId::CmdAgentDescription, }, CommandInfo { name: "plan", aliases: &[], - description: "Switch to plan mode and review suggested implementation steps", usage: "/plan", + description_id: MessageId::CmdPlanDescription, }, CommandInfo { name: "trust", aliases: &[], - description: "Manage workspace trust and per-path allowlist (`/trust add `, `/trust list`, `/trust on|off`)", usage: "/trust [on|off|add |remove |list]", + description_id: MessageId::CmdTrustDescription, }, CommandInfo { name: "logout", aliases: &[], - description: "Clear API key and return to setup", usage: "/logout", + description_id: MessageId::CmdLogoutDescription, }, // Debug commands CommandInfo { name: "tokens", aliases: &[], - description: "Show token usage for session", usage: "/tokens", + description_id: MessageId::CmdTokensDescription, }, CommandInfo { name: "system", aliases: &[], - description: "Show current system prompt", usage: "/system", + description_id: MessageId::CmdSystemDescription, }, CommandInfo { name: "undo", aliases: &[], - description: "Remove last message pair", usage: "/undo", + description_id: MessageId::CmdUndoDescription, }, CommandInfo { name: "retry", aliases: &[], - description: "Retry the last request", usage: "/retry", + description_id: MessageId::CmdRetryDescription, }, CommandInfo { name: "init", aliases: &[], - description: "Generate AGENTS.md for project", usage: "/init", + description_id: MessageId::CmdInitDescription, }, CommandInfo { name: "settings", aliases: &[], - description: "Show persistent settings", usage: "/settings", + description_id: MessageId::CmdSettingsDescription, }, CommandInfo { name: "statusline", aliases: &["status"], - description: "Configure which items appear in the footer", usage: "/statusline", + description_id: MessageId::CmdStatuslineDescription, }, // Skills commands CommandInfo { name: "skills", aliases: &[], - description: "List local skills (or --remote to browse the curated registry)", usage: "/skills [--remote]", + description_id: MessageId::CmdSkillsDescription, }, CommandInfo { name: "skill", aliases: &[], - description: "Activate a skill, or install/update/uninstall/trust a community skill", usage: "/skill |update |uninstall |trust >", + description_id: MessageId::CmdSkillDescription, }, CommandInfo { name: "review", aliases: &[], - description: "Run a structured code review on a file, diff, or PR", usage: "/review ", + description_id: MessageId::CmdReviewDescription, }, CommandInfo { name: "restore", aliases: &[], - description: "Roll back the workspace to a prior pre/post-turn snapshot. With no arg, lists recent snapshots.", usage: "/restore [N]", + description_id: MessageId::CmdRestoreDescription, }, // RLM command CommandInfo { name: "rlm", aliases: &["recursive"], - description: "Recursive Language Model (RLM) turn — store the prompt in a Python REPL and let the model write code to process it, with `llm_query()` / `sub_rlm()` for sub-LLM calls.", usage: "/rlm ", + description_id: MessageId::CmdRlmDescription, }, // Debug/cost command CommandInfo { name: "cost", aliases: &[], - description: "Show session cost breakdown", usage: "/cost", + description_id: MessageId::CmdCostDescription, + }, + // Cache telemetry (#263) + CommandInfo { + name: "cache", + aliases: &[], + usage: "/cache [count]", + description_id: MessageId::CmdCacheDescription, }, ]; @@ -423,6 +441,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { // Debug commands "tokens" => debug::tokens(app), "cost" => debug::cost(app), + "cache" => debug::cache(app, arg), "system" => debug::system_prompt(app), "context" | "ctx" => debug::context(app), "undo" => debug::undo(app), @@ -712,7 +731,7 @@ mod tests { .find(|cmd| cmd.name == "context") .expect("context command should exist"); assert_eq!(context.aliases, &["ctx"]); - assert!(context.description.contains("inspector")); + assert!(context.description_for(Locale::En).contains("inspector")); let mut app = create_test_app(); let result = execute("/ctx", &mut app); @@ -764,6 +783,113 @@ mod tests { assert!(deepseek_result.action.is_none()); } + /// Build an App scoped to an isolated tempdir so dispatch-side-effects + /// (e.g. `/init` writing AGENTS.md, `/export` writing chat transcripts) + /// don't pollute the repo working tree when the smoke tests run. + fn create_isolated_test_app() -> (App, tempfile::TempDir) { + let tmpdir = tempfile::TempDir::new().expect("tempdir for smoke test"); + let workspace = tmpdir.path().to_path_buf(); + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: workspace.clone(), + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: workspace.join("skills"), + memory_path: workspace.join("memory.md"), + notes_path: workspace.join("notes.txt"), + mcp_config_path: workspace.join("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + }; + let app = App::new(options, &Config::default()); + (app, tmpdir) + } + + /// Smoke test: every entry in `COMMANDS` must dispatch to a real handler. + /// A dispatch miss surfaces as the fall-through `Unknown command:` error + /// message in `execute`. This catches the case where a new command is + /// added to `COMMANDS` (so it shows up in `/help` and the palette) but + /// the matching arm in `execute` is forgotten — the user would type the + /// command, see it autocomplete, and then get an unhelpful "did you + /// mean" suggestion. Also catches panics in handlers because the test + /// runner unwinds the panic and reports the offending command. + /// `/save` and `/export` default their output paths to `cwd`-relative + /// filenames when no arg is supplied, which would scribble files into + /// `crates/tui/` when CI runs from there. Pass an explicit tempdir- + /// relative path for those two so the dispatch test stays sandboxed. + fn invocation_for(command_name: &str, alias_or_name: &str, tmpdir: &std::path::Path) -> String { + match command_name { + "save" => format!("/{alias_or_name} {}", tmpdir.join("session.json").display()), + "export" => format!("/{alias_or_name} {}", tmpdir.join("chat.md").display()), + _ => format!("/{alias_or_name}"), + } + } + + /// `/restore` is covered by its own dedicated tests in + /// `commands/restore.rs` that serialize on the global env mutex via + /// `scoped_home` (snapshot repo init shells out to git, which races + /// against parallel-running tests). Skip it here so this smoke test + /// stays parallel-safe. + fn skip_in_dispatch_smoke(name: &str) -> bool { + name == "restore" + } + + /// Smoke test: every entry in `COMMANDS` must dispatch to a real handler. + /// A dispatch miss surfaces as the fall-through `Unknown command:` error + /// message in `execute`. This catches the case where a new command is + /// added to `COMMANDS` (so it shows up in `/help` and the palette) but + /// the matching arm in `execute` is forgotten — the user would type the + /// command, see it autocomplete, and then get an unhelpful "did you + /// mean" suggestion. Also catches panics in handlers because the test + /// runner unwinds the panic and reports the offending command. + #[test] + fn every_registered_command_dispatches_to_a_handler() { + for command in COMMANDS { + if skip_in_dispatch_smoke(command.name) { + continue; + } + let (mut app, tmpdir) = create_isolated_test_app(); + let invocation = invocation_for(command.name, command.name, tmpdir.path()); + let result = execute(&invocation, &mut app); + if let Some(msg) = &result.message { + assert!( + !msg.contains("Unknown command"), + "/{} fell through to the unknown-command branch: {msg}", + command.name, + ); + } + } + } + + /// Same check, but for declared aliases — `/q` should not fall through + /// just because the registry lists it as an alias of `/exit`. + #[test] + fn every_command_alias_dispatches_to_a_handler() { + for command in COMMANDS { + if skip_in_dispatch_smoke(command.name) { + continue; + } + for alias in command.aliases { + let (mut app, tmpdir) = create_isolated_test_app(); + let invocation = invocation_for(command.name, alias, tmpdir.path()); + let result = execute(&invocation, &mut app); + if let Some(msg) = &result.message { + assert!( + !msg.contains("Unknown command"), + "/{alias} (alias of /{}) fell through to unknown: {msg}", + command.name, + ); + } + } + } + } + #[test] fn unknown_command_suggests_nearest_match() { let mut app = create_test_app(); diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index d69a2b2a..71024d72 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1195,15 +1195,25 @@ impl Engine { ctx = ctx.with_network_policy(decider.clone()); } - if mode == AppMode::Yolo { - ctx.with_elevated_sandbox_policy(crate::sandbox::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![self.session.workspace.clone()], - network_access: true, - exclude_tmpdir: false, - exclude_slash_tmp: false, - }) - } else { - ctx + match mode { + // Plan mode is read-only investigation; the shell tool is not + // registered, so leaving the sandbox policy at the seatbelt-strict + // default is fine. + AppMode::Plan => ctx, + // Agent and Yolo both register the shell tool. The sandbox-default + // policy denies all outbound network — including DNS — which + // breaks ordinary developer commands (cargo fetch, npm install, + // curl, yt-dlp, …) without buying the user any safety the + // application-level NetworkPolicy / approval flow doesn't already + // provide. Elevate to workspace-write + network. (#273) + AppMode::Agent | AppMode::Yolo => { + ctx.with_elevated_sandbox_policy(crate::sandbox::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![self.session.workspace.clone()], + network_access: true, + exclude_tmpdir: false, + exclude_slash_tmp: false, + }) + } } } diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index f4a7f7ed..df7467a1 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -3,6 +3,7 @@ use super::*; use super::context::WORKING_SET_SUMMARY_MARKER; use crate::models::SystemBlock; use serde_json::json; +use std::collections::HashSet; use std::fs; use std::path::PathBuf; use std::time::Instant; @@ -258,6 +259,63 @@ fn model_tool_catalog_keeps_everything_loaded_in_yolo_mode() { assert!(catalog.iter().all(|tool| tool.defer_loading == Some(false))); } +#[test] +fn model_tool_catalog_sorts_each_partition_for_prefix_cache_stability() { + // Regression for #263: deterministic byte order of the tools array is a + // hard requirement for DeepSeek's KV prefix cache. Built-ins stay as a + // contiguous prefix; MCP tools follow. Within each partition: alphabetical. + let catalog = build_model_tool_catalog( + vec![ + api_tool("read_file"), + api_tool("apply_patch"), + api_tool("exec_shell"), + ], + vec![api_tool("mcp_zoo_b"), api_tool("mcp_aardvark_a")], + AppMode::Yolo, + ); + + let names: Vec<&str> = catalog.iter().map(|t| t.name.as_str()).collect(); + assert_eq!( + names, + vec![ + "apply_patch", + "exec_shell", + "read_file", + "mcp_aardvark_a", + "mcp_zoo_b", + ], + "built-ins must be alphabetical and contiguous; MCP tools follow, alphabetical", + ); +} + +#[test] +fn active_tool_list_pushes_deferred_activations_to_the_tail() { + // Regression for #263: when ToolSearch activates a deferred tool mid- + // session, it must NOT be inserted at its catalog index — that would + // shift every later tool's byte offset and bust the cached prefix. + // Deferred-but-now-active tools belong at the tail. + let mut a = api_tool("a_load_now"); + a.defer_loading = Some(false); + let mut search = api_tool("search_via_toolsearch"); + search.defer_loading = Some(true); + let mut b = api_tool("b_load_now"); + b.defer_loading = Some(false); + + let catalog = vec![a, search, b]; + let active: HashSet = ["a_load_now", "search_via_toolsearch", "b_load_now"] + .into_iter() + .map(String::from) + .collect(); + + let listed = active_tools_for_step(&catalog, &active, false); + let names: Vec<&str> = listed.iter().map(|t| t.name.as_str()).collect(); + assert_eq!( + names, + vec!["a_load_now", "b_load_now", "search_via_toolsearch"], + "deferred-but-active tools must come after always-loaded tools", + ); +} + #[test] fn turn_tool_registry_builder_keeps_plan_mode_read_only_for_files() { let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); @@ -290,6 +348,44 @@ fn agent_mode_can_build_auto_approved_tool_context() { assert!(engine.build_tool_context(AppMode::Yolo, false).auto_approve); } +#[test] +fn agent_and_yolo_modes_elevate_shell_sandbox_to_allow_network() { + // Regression for #273: the seatbelt-default policy denies all outbound + // network (including DNS), which broke `curl`, `yt-dlp`, package managers, + // and similar shell commands in Agent mode. Elevation must include + // network access so the application-level NetworkPolicy stays the only + // outbound boundary. + let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); + + let agent_ctx = engine.build_tool_context(AppMode::Agent, false); + let agent_policy = agent_ctx + .elevated_sandbox_policy + .as_ref() + .expect("Agent mode should elevate the sandbox policy"); + assert!( + agent_policy.has_network_access(), + "Agent mode must allow shell network access; got {agent_policy:?}", + ); + + let yolo_ctx = engine.build_tool_context(AppMode::Yolo, false); + assert!( + yolo_ctx + .elevated_sandbox_policy + .as_ref() + .expect("Yolo mode should elevate the sandbox policy") + .has_network_access(), + ); + + // Plan mode is read-only investigation and does not register the shell + // tool, so it intentionally leaves the policy at the strict default. + assert!( + engine + .build_tool_context(AppMode::Plan, false) + .elevated_sandbox_policy + .is_none(), + ); +} + #[tokio::test] async fn session_update_preserves_reasoning_tool_only_turn() { let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default()); diff --git a/crates/tui/src/core/engine/tool_catalog.rs b/crates/tui/src/core/engine/tool_catalog.rs index 2581fd4f..c731a794 100644 --- a/crates/tui/src/core/engine/tool_catalog.rs +++ b/crates/tui/src/core/engine/tool_catalog.rs @@ -105,6 +105,14 @@ pub(super) fn build_model_tool_catalog( ) -> Vec { apply_native_tool_deferral(&mut native_tools, mode); apply_mcp_tool_deferral(&mut mcp_tools, mode); + // Sort each partition by name for prefix-cache stability (#263). The + // upstream `to_api_tools()` already sorts the registry's HashMap output; + // this catalog is built from caller-supplied Vecs which the test harness + // and (future) caller refactors may not pre-sort. Built-ins stay as a + // contiguous prefix ahead of MCP tools so adding/removing an MCP tool + // never shifts a built-in's position. + native_tools.sort_by(|a, b| a.name.cmp(&b.name)); + mcp_tools.sort_by(|a, b| a.name.cmp(&b.name)); native_tools.extend(mcp_tools); native_tools } @@ -188,11 +196,25 @@ pub(super) fn initial_active_tools(catalog: &[Tool]) -> HashSet { } fn active_tool_list_from_catalog(catalog: &[Tool], active: &HashSet) -> Vec { - catalog - .iter() - .filter(|tool| active.contains(&tool.name)) - .cloned() - .collect() + // Two-pass for prefix-cache stability (#263). Always-loaded tools come + // first in their stable catalog order; tools that started life deferred + // and were activated mid-conversation by ToolSearch get appended at the + // tail. Otherwise activating a deferred tool shifts every later tool's + // byte offset and busts the cached prefix from that point onwards. + let mut head: Vec = Vec::new(); + let mut tail: Vec = Vec::new(); + for tool in catalog { + if !active.contains(&tool.name) { + continue; + } + if tool.defer_loading.unwrap_or(false) { + tail.push(tool.clone()); + } else { + head.push(tool.clone()); + } + } + head.extend(tail); + head } pub(super) fn active_tools_for_step( diff --git a/crates/tui/src/execpolicy/mod.rs b/crates/tui/src/execpolicy/mod.rs index 42505a20..00ced1aa 100644 --- a/crates/tui/src/execpolicy/mod.rs +++ b/crates/tui/src/execpolicy/mod.rs @@ -1,4 +1,3 @@ -// TODO(integrate): Wire exec-policy into shell tool — tracked as future work #![allow(dead_code)] #![allow(unused_imports)] diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 9b0e92bd..049daf20 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -214,6 +214,74 @@ pub enum MessageId { HelpFooterMove, HelpFooterJump, HelpFooterClose, + CmdAgentDescription, + CmdAttachDescription, + CmdCacheDescription, + CmdClearDescription, + CmdCompactDescription, + CmdConfigDescription, + CmdContextDescription, + CmdCostDescription, + CmdCycleDescription, + CmdCyclesDescription, + CmdExitDescription, + CmdExportDescription, + CmdHelpDescription, + CmdHomeDescription, + CmdInitDescription, + CmdJobsDescription, + CmdLinksDescription, + CmdLoadDescription, + CmdLogoutDescription, + CmdMcpDescription, + CmdModelDescription, + CmdModelsDescription, + CmdNoteDescription, + CmdPlanDescription, + CmdProviderDescription, + CmdQueueDescription, + CmdRecallDescription, + CmdRestoreDescription, + CmdRetryDescription, + CmdReviewDescription, + CmdRlmDescription, + CmdSaveDescription, + CmdSessionsDescription, + CmdSettingsDescription, + CmdSkillDescription, + CmdSkillsDescription, + CmdStatuslineDescription, + CmdSubagentsDescription, + CmdSystemDescription, + CmdTaskDescription, + CmdTokensDescription, + CmdTrustDescription, + CmdUndoDescription, + CmdYoloDescription, + CmdCacheAdvice, + CmdCacheFootnote, + CmdCacheHeader, + CmdCacheNoData, + CmdCacheTotals, + CmdCostReport, + CmdTokensCacheBoth, + CmdTokensCacheHitOnly, + CmdTokensCacheMissOnly, + CmdTokensContextUnknownWindow, + CmdTokensContextWithWindow, + CmdTokensNotReported, + CmdTokensReport, + FooterAgentSingular, + FooterAgentsPlural, + FooterPressCtrlCAgain, + FooterWorking, + HelpSectionActions, + HelpSectionClipboard, + HelpSectionEditing, + HelpSectionHelp, + HelpSectionModes, + HelpSectionNavigation, + HelpSectionSessions, } #[allow(dead_code)] @@ -245,6 +313,74 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::HelpFooterMove, MessageId::HelpFooterJump, MessageId::HelpFooterClose, + MessageId::CmdAgentDescription, + MessageId::CmdAttachDescription, + MessageId::CmdCacheDescription, + MessageId::CmdClearDescription, + MessageId::CmdCompactDescription, + MessageId::CmdConfigDescription, + MessageId::CmdContextDescription, + MessageId::CmdCostDescription, + MessageId::CmdCycleDescription, + MessageId::CmdCyclesDescription, + MessageId::CmdExitDescription, + MessageId::CmdExportDescription, + MessageId::CmdHelpDescription, + MessageId::CmdHomeDescription, + MessageId::CmdInitDescription, + MessageId::CmdJobsDescription, + MessageId::CmdLinksDescription, + MessageId::CmdLoadDescription, + MessageId::CmdLogoutDescription, + MessageId::CmdMcpDescription, + MessageId::CmdModelDescription, + MessageId::CmdModelsDescription, + MessageId::CmdNoteDescription, + MessageId::CmdPlanDescription, + MessageId::CmdProviderDescription, + MessageId::CmdQueueDescription, + MessageId::CmdRecallDescription, + MessageId::CmdRestoreDescription, + MessageId::CmdRetryDescription, + MessageId::CmdReviewDescription, + MessageId::CmdRlmDescription, + MessageId::CmdSaveDescription, + MessageId::CmdSessionsDescription, + MessageId::CmdSettingsDescription, + MessageId::CmdSkillDescription, + MessageId::CmdSkillsDescription, + MessageId::CmdStatuslineDescription, + MessageId::CmdSubagentsDescription, + MessageId::CmdSystemDescription, + MessageId::CmdTaskDescription, + MessageId::CmdTokensDescription, + MessageId::CmdTrustDescription, + MessageId::CmdUndoDescription, + MessageId::CmdYoloDescription, + MessageId::CmdCacheAdvice, + MessageId::CmdCacheFootnote, + MessageId::CmdCacheHeader, + MessageId::CmdCacheNoData, + MessageId::CmdCacheTotals, + MessageId::CmdCostReport, + MessageId::CmdTokensCacheBoth, + MessageId::CmdTokensCacheHitOnly, + MessageId::CmdTokensCacheMissOnly, + MessageId::CmdTokensContextUnknownWindow, + MessageId::CmdTokensContextWithWindow, + MessageId::CmdTokensNotReported, + MessageId::CmdTokensReport, + MessageId::FooterAgentSingular, + MessageId::FooterAgentsPlural, + MessageId::FooterPressCtrlCAgain, + MessageId::FooterWorking, + MessageId::HelpSectionActions, + MessageId::HelpSectionClipboard, + MessageId::HelpSectionEditing, + MessageId::HelpSectionHelp, + MessageId::HelpSectionModes, + MessageId::HelpSectionNavigation, + MessageId::HelpSectionSessions, ]; pub fn tr(locale: Locale, id: MessageId) -> &'static str { @@ -396,6 +532,130 @@ fn english(id: MessageId) -> &'static str { MessageId::HelpFooterMove => " Up/Down move ", MessageId::HelpFooterJump => " PgUp/PgDn jump ", MessageId::HelpFooterClose => " Esc close ", + MessageId::CmdAgentDescription => "Switch to agent mode", + MessageId::CmdAttachDescription => { + "Attach image/video media; use @path for text files or directories" + } + MessageId::CmdCacheDescription => { + "Show DeepSeek prefix-cache hit/miss stats for the last N turns" + } + MessageId::CmdClearDescription => "Clear conversation history", + MessageId::CmdCompactDescription => { + "Trigger context compaction to free up space (legacy; v0.6.6 prefers cycle restart)" + } + MessageId::CmdConfigDescription => "Open interactive configuration editor", + MessageId::CmdContextDescription => "Open compact session context inspector", + MessageId::CmdCostDescription => "Show session cost breakdown", + MessageId::CmdCycleDescription => "Show the carry-forward briefing for a specific cycle", + MessageId::CmdCyclesDescription => "List checkpoint-restart cycle handoffs in this session", + MessageId::CmdExitDescription => "Exit the application", + MessageId::CmdExportDescription => "Export conversation to markdown", + MessageId::CmdHelpDescription => "Show help information", + MessageId::CmdHomeDescription => "Show home dashboard with stats and quick actions", + MessageId::CmdInitDescription => "Generate AGENTS.md for project", + MessageId::CmdJobsDescription => "Inspect and control background shell jobs", + MessageId::CmdLinksDescription => "Show DeepSeek dashboard and docs links", + MessageId::CmdLoadDescription => "Load session from file", + MessageId::CmdLogoutDescription => "Clear API key and return to setup", + MessageId::CmdMcpDescription => "Open or manage MCP servers", + MessageId::CmdModelDescription => "Switch or view current model", + MessageId::CmdModelsDescription => "List available models from API", + MessageId::CmdNoteDescription => { + "Append note to persistent notes file (.deepseek/notes.md)" + } + MessageId::CmdPlanDescription => { + "Switch to plan mode and review suggested implementation steps" + } + MessageId::CmdProviderDescription => { + "Switch or view the active LLM backend (deepseek | nvidia-nim)" + } + MessageId::CmdQueueDescription => "View or edit queued messages", + MessageId::CmdRecallDescription => "Search prior cycle archives (BM25 over message text)", + MessageId::CmdRestoreDescription => { + "Roll back the workspace to a prior pre/post-turn snapshot. With no arg, lists recent snapshots." + } + MessageId::CmdRetryDescription => "Retry the last request", + MessageId::CmdReviewDescription => "Run a structured code review on a file, diff, or PR", + MessageId::CmdRlmDescription => { + "Recursive Language Model (RLM) turn — store the prompt in a Python REPL and let the model write code to process it, with `llm_query()` / `sub_rlm()` for sub-LLM calls." + } + MessageId::CmdSaveDescription => "Save session to file", + MessageId::CmdSessionsDescription => "Open session picker", + MessageId::CmdSettingsDescription => "Show persistent settings", + MessageId::CmdSkillDescription => { + "Activate a skill, or install/update/uninstall/trust a community skill" + } + MessageId::CmdSkillsDescription => { + "List local skills (or --remote to browse the curated registry)" + } + MessageId::CmdStatuslineDescription => "Configure which items appear in the footer", + MessageId::CmdSubagentsDescription => "List sub-agent status", + MessageId::CmdSystemDescription => "Show current system prompt", + MessageId::CmdTaskDescription => "Manage background tasks", + MessageId::CmdTokensDescription => "Show token usage for session", + MessageId::CmdTrustDescription => { + "Manage workspace trust and per-path allowlist (`/trust add `, `/trust list`, `/trust on|off`)" + } + MessageId::CmdUndoDescription => "Remove last message pair", + MessageId::CmdYoloDescription => "Enable YOLO mode (shell + trust + auto-approve)", + MessageId::CmdCacheAdvice => { + "Hit/miss ratios over ~70% after the third turn indicate a stable cache prefix; \n\ + lower than that on long sessions suggests prefix churn worth investigating (#263)." + } + MessageId::CmdCacheFootnote => { + "* miss inferred from input − hit when the provider did not report it explicitly.\n" + } + MessageId::CmdCacheHeader => { + "Cache telemetry — last {count} of {total} turn(s) (model: {model})\n" + } + MessageId::CmdCacheNoData => { + "Cache history: no turns recorded yet.\n\n\ + DeepSeek surfaces `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` \ + on every API turn that the model supports it (V4 family). Run a turn \ + and try /cache again." + } + MessageId::CmdCacheTotals => { + "Σ in: {sum_in} Σ hit: {sum_hit} Σ miss: {sum_miss} avg hit ratio: {avg}\n" + } + MessageId::CmdCostReport => { + "Session Cost:\n\ + ─────────────────────────────\n\ + Approx total spent: ${cost}\n\n\ + Cost estimates are approximate and use provider usage telemetry when available.\n\n\ + DeepSeek API Pricing:\n\ + ─────────────────────────────\n\ + Pricing details are not configured in this CLI." + } + MessageId::CmdTokensCacheBoth => "{hit} hit / {miss} miss", + MessageId::CmdTokensCacheHitOnly => "{hit} hit / miss not reported", + MessageId::CmdTokensCacheMissOnly => "hit not reported / {miss} miss", + MessageId::CmdTokensContextUnknownWindow => "~{estimated} / unknown window", + MessageId::CmdTokensContextWithWindow => "~{used} / {window} ({percent}%)", + MessageId::FooterAgentSingular => "1 agent", + MessageId::FooterAgentsPlural => "{count} agents", + MessageId::FooterPressCtrlCAgain => "Press Ctrl+C again to quit", + MessageId::FooterWorking => "working", + MessageId::HelpSectionActions => "Actions", + MessageId::HelpSectionClipboard => "Clipboard", + MessageId::HelpSectionEditing => "Input editing", + MessageId::HelpSectionHelp => "Help", + MessageId::HelpSectionModes => "Modes", + MessageId::HelpSectionNavigation => "Navigation", + MessageId::HelpSectionSessions => "Sessions", + MessageId::CmdTokensNotReported => "not reported", + MessageId::CmdTokensReport => { + "Token Usage:\n\ + ─────────────────────────────\n\ + Active context: {active}\n\ + Last API input: {input} (turn telemetry; may count repeated prefix across tool rounds)\n\ + Last API output: {output}\n\ + Cache hit/miss: {cache} (telemetry/cost only)\n\ + Cumulative tokens: {total} (session usage telemetry)\n\ + Approx session cost: ${cost}\n\ + API messages: {api_messages}\n\ + Chat messages: {chat_messages}\n\ + Model: {model}" + } } } @@ -443,6 +703,129 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::HelpFooterMove => " Up/Down 移動 ", MessageId::HelpFooterJump => " PgUp/PgDn ジャンプ ", MessageId::HelpFooterClose => " Esc 閉じる ", + MessageId::CmdAgentDescription => "Agent モードに切り替え", + MessageId::CmdAttachDescription => { + "画像・動画メディアを添付(テキストファイルやディレクトリは @path)" + } + MessageId::CmdCacheDescription => { + "直近 N ターンの DeepSeek プレフィックスキャッシュのヒット/ミス統計を表示" + } + MessageId::CmdClearDescription => "会話履歴をクリア", + MessageId::CmdCompactDescription => { + "コンテキスト圧縮で容量を確保(旧式:v0.6.6 以降はサイクル再起動を推奨)" + } + MessageId::CmdConfigDescription => "インタラクティブな設定エディタを開く", + MessageId::CmdContextDescription => "コンパクトなセッションコンテキスト検査ツールを開く", + MessageId::CmdCostDescription => "セッションのコスト内訳を表示", + MessageId::CmdCycleDescription => "指定したサイクルの引き継ぎブリーフィングを表示", + MessageId::CmdCyclesDescription => { + "セッション内のチェックポイント再起動サイクルの引き継ぎを一覧表示" + } + MessageId::CmdExitDescription => "アプリを終了", + MessageId::CmdExportDescription => "会話を Markdown にエクスポート", + MessageId::CmdHelpDescription => "ヘルプを表示", + MessageId::CmdHomeDescription => "統計とクイックアクション付きのホームダッシュボードを表示", + MessageId::CmdInitDescription => "プロジェクト用に AGENTS.md を生成", + MessageId::CmdJobsDescription => "バックグラウンドのシェルジョブを確認・制御", + MessageId::CmdLinksDescription => "DeepSeek ダッシュボードとドキュメントへのリンクを表示", + MessageId::CmdLoadDescription => "ファイルからセッションを読み込み", + MessageId::CmdLogoutDescription => "API キーを消去してセットアップに戻る", + MessageId::CmdMcpDescription => "MCP サーバを開く・管理する", + MessageId::CmdModelDescription => "現在のモデルを切り替え・確認", + MessageId::CmdModelsDescription => "API から利用可能なモデルを一覧表示", + MessageId::CmdNoteDescription => "永続ノートファイル(.deepseek/notes.md)に追記", + MessageId::CmdPlanDescription => "Plan モードに切り替え、推奨される実装手順を確認", + MessageId::CmdProviderDescription => { + "現在の LLM バックエンドを切り替え・確認(deepseek | nvidia-nim)" + } + MessageId::CmdQueueDescription => "キューされたメッセージを確認・編集", + MessageId::CmdRecallDescription => { + "過去のサイクルアーカイブを検索(メッセージ本文への BM25 検索)" + } + MessageId::CmdRestoreDescription => { + "ワークスペースを以前のターン前/後スナップショットへロールバック。引数なしで最近のスナップショットを一覧表示。" + } + MessageId::CmdRetryDescription => "直前のリクエストを再試行", + MessageId::CmdReviewDescription => "ファイル・diff・PR に対して構造化コードレビューを実行", + MessageId::CmdRlmDescription => { + "再帰言語モデル(RLM)ターン — プロンプトを Python REPL に格納し、モデルが処理コードを記述。サブ LLM 呼び出しは `llm_query()` / `sub_rlm()`。" + } + MessageId::CmdSaveDescription => "セッションをファイルに保存", + MessageId::CmdSessionsDescription => "セッションピッカーを開く", + MessageId::CmdSettingsDescription => "永続化された設定を表示", + MessageId::CmdSkillDescription => { + "スキルを有効化、またはコミュニティスキルをインストール/更新/アンインストール/信頼" + } + MessageId::CmdSkillsDescription => { + "ローカルスキルを一覧表示(--remote で精選レジストリを参照)" + } + MessageId::CmdStatuslineDescription => "フッターに表示する項目を設定", + MessageId::CmdSubagentsDescription => "サブエージェントの状態を一覧表示", + MessageId::CmdSystemDescription => "現在のシステムプロンプトを表示", + MessageId::CmdTaskDescription => "バックグラウンドタスクを管理", + MessageId::CmdTokensDescription => "セッションのトークン使用量を表示", + MessageId::CmdTrustDescription => { + "ワークスペースの信頼設定とパス別許可リストを管理(`/trust add `、`/trust list`、`/trust on|off`)" + } + MessageId::CmdUndoDescription => "最後のメッセージ対を削除", + MessageId::CmdYoloDescription => "YOLO モードを有効化(shell + 信頼 + 自動承認)", + MessageId::CmdCacheAdvice => { + "3 ターン目以降にヒット率が ~70% 以上で安定していれば、プレフィックスキャッシュは健全。\n\ + 長いセッションでこれを下回る場合はプレフィックスのドリフトの可能性あり (#263)。" + } + MessageId::CmdCacheFootnote => { + "* プロバイダがミスを単独で報告しない場合は「入力 − ヒット」から推定。\n" + } + MessageId::CmdCacheHeader => { + "キャッシュテレメトリ — 直近 {count} / {total} ターン(モデル: {model})\n" + } + MessageId::CmdCacheNoData => { + "キャッシュ履歴: まだターンを記録していません。\n\n\ + DeepSeek は対応モデル (V4 系) の各 API ターンで `prompt_cache_hit_tokens` / \ + `prompt_cache_miss_tokens` を返します。1 ターン実行してから /cache を再度試してください。" + } + MessageId::CmdCacheTotals => { + "Σ 入力: {sum_in} Σ ヒット: {sum_hit} Σ ミス: {sum_miss} 平均ヒット率: {avg}\n" + } + MessageId::CmdCostReport => { + "セッション費用:\n\ + ─────────────────────────────\n\ + 累計概算: ${cost}\n\n\ + 費用は概算値。プロバイダの使用量テレメトリがあれば優先して使用します。\n\n\ + DeepSeek API 料金:\n\ + ─────────────────────────────\n\ + 本 CLI には詳細な料金表は組み込まれていません。" + } + MessageId::CmdTokensCacheBoth => "ヒット {hit} / ミス {miss}", + MessageId::CmdTokensCacheHitOnly => "ヒット {hit} / ミスは未報告", + MessageId::CmdTokensCacheMissOnly => "ヒットは未報告 / ミス {miss}", + MessageId::CmdTokensContextUnknownWindow => "~{estimated} / コンテキスト窓不明", + MessageId::CmdTokensContextWithWindow => "~{used} / {window} ({percent}%)", + MessageId::FooterAgentSingular => "1 エージェント", + MessageId::FooterAgentsPlural => "{count} エージェント", + MessageId::FooterPressCtrlCAgain => "もう一度 Ctrl+C で終了", + MessageId::FooterWorking => "処理中", + MessageId::HelpSectionActions => "操作", + MessageId::HelpSectionClipboard => "クリップボード", + MessageId::HelpSectionEditing => "入力編集", + MessageId::HelpSectionHelp => "ヘルプ", + MessageId::HelpSectionModes => "モード", + MessageId::HelpSectionNavigation => "ナビゲーション", + MessageId::HelpSectionSessions => "セッション", + MessageId::CmdTokensNotReported => "未報告", + MessageId::CmdTokensReport => { + "トークン使用量:\n\ + ─────────────────────────────\n\ + アクティブコンテキスト: {active}\n\ + 直近の API 入力: {input}(ターン単位のテレメトリ。複数回のツール往復で同じプレフィックスが重複してカウントされる場合あり)\n\ + 直近の API 出力: {output}\n\ + キャッシュヒット/ミス: {cache}(テレメトリ/コスト用のみ)\n\ + 累計トークン: {total}(セッション使用量テレメトリ)\n\ + セッション費用概算: ${cost}\n\ + API メッセージ: {api_messages}\n\ + チャットメッセージ: {chat_messages}\n\ + モデル: {model}" + } }) } @@ -479,6 +862,111 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::HelpFooterMove => " Up/Down 移动 ", MessageId::HelpFooterJump => " PgUp/PgDn 跳转 ", MessageId::HelpFooterClose => " Esc 关闭 ", + MessageId::CmdAgentDescription => "切换到 Agent 模式", + MessageId::CmdAttachDescription => "附加图片或视频媒体;文本文件或目录请使用 @path", + MessageId::CmdCacheDescription => "显示最近 N 轮的 DeepSeek 前缀缓存命中/未命中统计", + MessageId::CmdClearDescription => "清除对话历史", + MessageId::CmdCompactDescription => { + "触发上下文压缩以释放空间(旧版命令;v0.6.6 起建议改用循环重启)" + } + MessageId::CmdConfigDescription => "打开交互式配置编辑器", + MessageId::CmdContextDescription => "打开紧凑会话上下文检查器", + MessageId::CmdCostDescription => "显示本次会话的费用明细", + MessageId::CmdCycleDescription => "显示指定循环的延续简报", + MessageId::CmdCyclesDescription => "列出本次会话中的检查点重启循环交接", + MessageId::CmdExitDescription => "退出应用", + MessageId::CmdExportDescription => "将对话导出为 Markdown", + MessageId::CmdHelpDescription => "显示帮助信息", + MessageId::CmdHomeDescription => "显示主页面板,含统计与快捷操作", + MessageId::CmdInitDescription => "为项目生成 AGENTS.md", + MessageId::CmdJobsDescription => "查看并管理后台 shell 作业", + MessageId::CmdLinksDescription => "显示 DeepSeek 控制台与文档链接", + MessageId::CmdLoadDescription => "从文件加载会话", + MessageId::CmdLogoutDescription => "清除 API 密钥并返回设置", + MessageId::CmdMcpDescription => "打开或管理 MCP 服务器", + MessageId::CmdModelDescription => "切换或查看当前模型", + MessageId::CmdModelsDescription => "列出 API 中可用的模型", + MessageId::CmdNoteDescription => "将笔记追加到持久笔记文件(.deepseek/notes.md)", + MessageId::CmdPlanDescription => "切换到 Plan 模式并查看建议的实现步骤", + MessageId::CmdProviderDescription => "切换或查看当前 LLM 后端(deepseek | nvidia-nim)", + MessageId::CmdQueueDescription => "查看或编辑已排队的消息", + MessageId::CmdRecallDescription => "搜索此前的循环归档(基于消息文本的 BM25 检索)", + MessageId::CmdRestoreDescription => { + "将工作区回滚到此前的轮次前/后快照。不带参数时列出最近的快照。" + } + MessageId::CmdRetryDescription => "重试上一次请求", + MessageId::CmdReviewDescription => "对文件、diff 或 PR 进行结构化代码审查", + MessageId::CmdRlmDescription => { + "递归语言模型(RLM)轮次 —— 将提示词存入 Python REPL,让模型编写代码进行处理;可用 `llm_query()` / `sub_rlm()` 调用子 LLM。" + } + MessageId::CmdSaveDescription => "将会话保存到文件", + MessageId::CmdSessionsDescription => "打开会话选择器", + MessageId::CmdSettingsDescription => "显示持久化设置", + MessageId::CmdSkillDescription => "激活技能,或安装/更新/卸载/信任社区技能", + MessageId::CmdSkillsDescription => "列出本地技能(或使用 --remote 浏览精选注册表)", + MessageId::CmdStatuslineDescription => "配置底栏要显示哪些条目", + MessageId::CmdSubagentsDescription => "列出子代理状态", + MessageId::CmdSystemDescription => "显示当前系统提示词", + MessageId::CmdTaskDescription => "管理后台任务", + MessageId::CmdTokensDescription => "显示本次会话的 token 用量", + MessageId::CmdTrustDescription => { + "管理工作区信任与按路径的白名单(`/trust add `、`/trust list`、`/trust on|off`)" + } + MessageId::CmdUndoDescription => "移除最后一组消息对", + MessageId::CmdYoloDescription => "启用 YOLO 模式(shell + 信任 + 自动批准)", + MessageId::CmdCacheAdvice => { + "第 3 轮起命中率稳定在 ~70% 以上即表示前缀缓存稳定;\n\ + 长会话中明显偏低则意味着前缀有抖动,值得排查(#263)。" + } + MessageId::CmdCacheFootnote => "* 当提供方未单独上报未命中时,由「输入 − 命中」推算。\n", + MessageId::CmdCacheHeader => "缓存遥测 —— 最近 {count} / {total} 轮(模型:{model})\n", + MessageId::CmdCacheNoData => { + "缓存历史:尚未记录任何轮次。\n\n\ + DeepSeek 在受支持的模型(V4 系列)每个 API 轮次都会返回 `prompt_cache_hit_tokens` / \ + `prompt_cache_miss_tokens`。请先运行一个轮次再试 /cache。" + } + MessageId::CmdCacheTotals => { + "Σ 输入:{sum_in} Σ 命中:{sum_hit} Σ 未命中:{sum_miss} 平均命中率:{avg}\n" + } + MessageId::CmdCostReport => { + "会话费用:\n\ + ─────────────────────────────\n\ + 预估累计消耗:${cost}\n\n\ + 费用为估算值;如有提供方用量遥测会优先使用。\n\n\ + DeepSeek API 计费:\n\ + ─────────────────────────────\n\ + 此 CLI 中未配置详细计费规则。" + } + MessageId::CmdTokensCacheBoth => "命中 {hit} / 未命中 {miss}", + MessageId::CmdTokensCacheHitOnly => "命中 {hit} / 未命中未上报", + MessageId::CmdTokensCacheMissOnly => "命中未上报 / 未命中 {miss}", + MessageId::CmdTokensContextUnknownWindow => "~{estimated} / 窗口未知", + MessageId::CmdTokensContextWithWindow => "~{used} / {window}({percent}%)", + MessageId::FooterAgentSingular => "1 个子代理", + MessageId::FooterAgentsPlural => "{count} 个子代理", + MessageId::FooterPressCtrlCAgain => "再次按 Ctrl+C 退出", + MessageId::FooterWorking => "工作中", + MessageId::HelpSectionActions => "操作", + MessageId::HelpSectionClipboard => "剪贴板", + MessageId::HelpSectionEditing => "输入编辑", + MessageId::HelpSectionHelp => "帮助", + MessageId::HelpSectionModes => "模式", + MessageId::HelpSectionNavigation => "导航", + MessageId::HelpSectionSessions => "会话", + MessageId::CmdTokensNotReported => "未上报", + MessageId::CmdTokensReport => { + "令牌用量:\n\ + ─────────────────────────────\n\ + 活动上下文: {active}\n\ + 上次 API 输入: {input}(来自轮次遥测;多轮工具调用中相同前缀可能被重复计入)\n\ + 上次 API 输出: {output}\n\ + 缓存命中/未命中: {cache}(仅用于遥测/计费)\n\ + 累计令牌: {total}(会话用量遥测)\n\ + 预估会话费用: ${cost}\n\ + API 消息数: {api_messages}\n\ + 聊天消息数: {chat_messages}\n\ + 模型: {model}" + } }) } @@ -517,6 +1005,139 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::HelpFooterMove => " Up/Down move ", MessageId::HelpFooterJump => " PgUp/PgDn salta ", MessageId::HelpFooterClose => " Esc fecha ", + MessageId::CmdAgentDescription => "Mudar para o modo agent", + MessageId::CmdAttachDescription => { + "Anexar imagem ou vídeo; use @path para arquivos de texto ou diretórios" + } + MessageId::CmdCacheDescription => { + "Exibir estatísticas de hit/miss do cache de prefixo DeepSeek nas últimas N rodadas" + } + MessageId::CmdClearDescription => "Limpar o histórico da conversa", + MessageId::CmdCompactDescription => { + "Compactar o contexto para liberar espaço (legado; a v0.6.6 prefere o reinício de ciclo)" + } + MessageId::CmdConfigDescription => "Abrir o editor interativo de configuração", + MessageId::CmdContextDescription => "Abrir o inspetor compacto de contexto da sessão", + MessageId::CmdCostDescription => "Exibir o detalhamento de custo da sessão", + MessageId::CmdCycleDescription => { + "Exibir o briefing de continuidade de um ciclo específico" + } + MessageId::CmdCyclesDescription => { + "Listar as transferências dos ciclos checkpoint-restart desta sessão" + } + MessageId::CmdExitDescription => "Sair do aplicativo", + MessageId::CmdExportDescription => "Exportar a conversa para markdown", + MessageId::CmdHelpDescription => "Exibir informações de ajuda", + MessageId::CmdHomeDescription => "Exibir o painel inicial com estatísticas e ações rápidas", + MessageId::CmdInitDescription => "Gerar AGENTS.md para o projeto", + MessageId::CmdJobsDescription => "Inspecionar e controlar jobs de shell em segundo plano", + MessageId::CmdLinksDescription => "Exibir links do painel e da documentação do DeepSeek", + MessageId::CmdLoadDescription => "Carregar a sessão de um arquivo", + MessageId::CmdLogoutDescription => "Limpar a chave de API e voltar à configuração", + MessageId::CmdMcpDescription => "Abrir ou gerenciar servidores MCP", + MessageId::CmdModelDescription => "Trocar ou exibir o modelo atual", + MessageId::CmdModelsDescription => "Listar os modelos disponíveis pela API", + MessageId::CmdNoteDescription => { + "Adicionar nota ao arquivo persistente (.deepseek/notes.md)" + } + MessageId::CmdPlanDescription => { + "Mudar para o modo plan e revisar os passos de implementação sugeridos" + } + MessageId::CmdProviderDescription => { + "Trocar ou exibir o backend LLM ativo (deepseek | nvidia-nim)" + } + MessageId::CmdQueueDescription => "Ver ou editar mensagens enfileiradas", + MessageId::CmdRecallDescription => { + "Buscar arquivos de ciclos anteriores (BM25 sobre o texto das mensagens)" + } + MessageId::CmdRestoreDescription => { + "Reverter o workspace a um snapshot pré/pós-turno anterior. Sem argumento, lista os snapshots recentes." + } + MessageId::CmdRetryDescription => "Repetir a última requisição", + MessageId::CmdReviewDescription => { + "Executar uma revisão de código estruturada em um arquivo, diff ou PR" + } + MessageId::CmdRlmDescription => { + "Turno do Recursive Language Model (RLM) — guarda o prompt em um REPL Python e deixa o modelo escrever o código que o processa; use `llm_query()` / `sub_rlm()` para chamadas a sub-LLMs." + } + MessageId::CmdSaveDescription => "Salvar a sessão em arquivo", + MessageId::CmdSessionsDescription => "Abrir o seletor de sessões", + MessageId::CmdSettingsDescription => "Exibir as configurações persistidas", + MessageId::CmdSkillDescription => { + "Ativar uma skill, ou instalar/atualizar/desinstalar/confiar em uma skill da comunidade" + } + MessageId::CmdSkillsDescription => { + "Listar skills locais (ou --remote para navegar pelo registro curado)" + } + MessageId::CmdStatuslineDescription => "Configurar quais itens aparecem no rodapé", + MessageId::CmdSubagentsDescription => "Listar o status dos sub-agentes", + MessageId::CmdSystemDescription => "Exibir o prompt de sistema atual", + MessageId::CmdTaskDescription => "Gerenciar tarefas em segundo plano", + MessageId::CmdTokensDescription => "Exibir o uso de tokens da sessão", + MessageId::CmdTrustDescription => { + "Gerenciar a confiança do workspace e a allowlist por caminho (`/trust add `, `/trust list`, `/trust on|off`)" + } + MessageId::CmdUndoDescription => "Remover o último par de mensagens", + MessageId::CmdYoloDescription => { + "Ativar o modo YOLO (shell + confiança + aprovação automática)" + } + MessageId::CmdCacheAdvice => { + "Taxas de hit/miss acima de ~70% a partir do terceiro turno indicam um prefixo de cache estável;\n\ + valores menores em sessões longas sugerem instabilidade no prefixo, vale investigar (#263)." + } + MessageId::CmdCacheFootnote => { + "* miss inferido a partir de entrada − hit quando o provedor não o reporta separadamente.\n" + } + MessageId::CmdCacheHeader => { + "Telemetria do cache — últimos {count} de {total} turno(s) (modelo: {model})\n" + } + MessageId::CmdCacheNoData => { + "Histórico do cache: nenhum turno registrado ainda.\n\n\ + O DeepSeek expõe `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` em cada turno \ + da API onde o modelo suporta (família V4). Execute um turno e tente /cache de novo." + } + MessageId::CmdCacheTotals => { + "Σ entrada: {sum_in} Σ hit: {sum_hit} Σ miss: {sum_miss} taxa média de hit: {avg}\n" + } + MessageId::CmdCostReport => { + "Custo da sessão:\n\ + ─────────────────────────────\n\ + Total aproximado: ${cost}\n\n\ + Estimativas de custo são aproximadas e usam a telemetria de uso do provedor quando disponível.\n\n\ + Preços da API DeepSeek:\n\ + ─────────────────────────────\n\ + Os detalhes de preço não estão configurados nesta CLI." + } + MessageId::CmdTokensCacheBoth => "{hit} hit / {miss} miss", + MessageId::CmdTokensCacheHitOnly => "{hit} hit / miss não reportado", + MessageId::CmdTokensCacheMissOnly => "hit não reportado / {miss} miss", + MessageId::CmdTokensContextUnknownWindow => "~{estimated} / janela desconhecida", + MessageId::CmdTokensContextWithWindow => "~{used} / {window} ({percent}%)", + MessageId::FooterAgentSingular => "1 sub-agente", + MessageId::FooterAgentsPlural => "{count} sub-agentes", + MessageId::FooterPressCtrlCAgain => "Pressione Ctrl+C novamente para sair", + MessageId::FooterWorking => "trabalhando", + MessageId::HelpSectionActions => "Ações", + MessageId::HelpSectionClipboard => "Área de transferência", + MessageId::HelpSectionEditing => "Edição de entrada", + MessageId::HelpSectionHelp => "Ajuda", + MessageId::HelpSectionModes => "Modos", + MessageId::HelpSectionNavigation => "Navegação", + MessageId::HelpSectionSessions => "Sessões", + MessageId::CmdTokensNotReported => "não reportado", + MessageId::CmdTokensReport => { + "Uso de tokens:\n\ + ─────────────────────────────\n\ + Contexto ativo: {active}\n\ + Última entrada da API: {input} (telemetria por turno; pode contar o mesmo prefixo várias vezes em rodadas com ferramentas)\n\ + Última saída da API: {output}\n\ + Hit/miss do cache: {cache} (apenas para telemetria/custo)\n\ + Tokens acumulados: {total} (telemetria de uso da sessão)\n\ + Custo aproximado: ${cost}\n\ + Mensagens da API: {api_messages}\n\ + Mensagens do chat: {chat_messages}\n\ + Modelo: {model}" + } }) } diff --git a/crates/tui/src/pricing.rs b/crates/tui/src/pricing.rs index 4aefeeaa..b7cc9187 100644 --- a/crates/tui/src/pricing.rs +++ b/crates/tui/src/pricing.rs @@ -14,7 +14,7 @@ struct ModelPricing { } fn v4_pro_discount_ends_at() -> DateTime { - Utc.with_ymd_and_hms(2026, 5, 5, 15, 59, 0) + Utc.with_ymd_and_hms(2026, 5, 31, 15, 59, 0) .single() .expect("valid DeepSeek V4 Pro discount end timestamp") } @@ -37,7 +37,7 @@ fn pricing_for_model_at(model: &str, now: DateTime) -> Option if lower.contains("v4-pro") || lower.contains("v4pro") { if now <= v4_pro_discount_ends_at() { // DeepSeek lists these as a limited-time 75% discount through - // 2026-05-05 15:59 UTC. + // 2026-05-31 15:59 UTC. return Some(ModelPricing { input_cache_hit_per_million: 0.003625, input_cache_miss_per_million: 0.435, @@ -131,7 +131,7 @@ mod tests { #[test] fn v4_pro_uses_limited_time_discount_before_expiry() { let before_expiry = Utc - .with_ymd_and_hms(2026, 5, 5, 15, 58, 59) + .with_ymd_and_hms(2026, 5, 31, 15, 58, 59) .single() .unwrap(); let pricing = pricing_for_model_at("deepseek-v4-pro", before_expiry).unwrap(); @@ -143,7 +143,10 @@ mod tests { #[test] fn v4_pro_returns_to_base_rates_after_discount_expiry() { - let after_expiry = Utc.with_ymd_and_hms(2026, 5, 5, 16, 0, 0).single().unwrap(); + let after_expiry = Utc + .with_ymd_and_hms(2026, 5, 31, 16, 0, 0) + .single() + .unwrap(); let pricing = pricing_for_model_at("deepseek-v4-pro", after_expiry).unwrap(); assert_eq!(pricing.input_cache_hit_per_million, 0.0145); @@ -151,6 +154,17 @@ mod tests { assert_eq!(pricing.output_per_million, 3.48); } + #[test] + fn v4_pro_discount_still_applies_just_before_old_may5_expiry() { + // Regression for #267: extension to 2026-05-31 15:59 UTC. + let after_old_expiry = Utc.with_ymd_and_hms(2026, 5, 6, 0, 0, 0).single().unwrap(); + let pricing = pricing_for_model_at("deepseek-v4-pro", after_old_expiry).unwrap(); + + assert_eq!(pricing.input_cache_hit_per_million, 0.003625); + assert_eq!(pricing.input_cache_miss_per_million, 0.435); + assert_eq!(pricing.output_per_million, 0.87); + } + #[test] fn v4_flash_keeps_current_published_rates() { let now = Utc.with_ymd_and_hms(2026, 4, 25, 0, 0, 0).single().unwrap(); diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 0ca2f2cc..d6d73a73 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -177,6 +177,22 @@ pub fn system_prompt_for_mode_with_context( } /// Get the system prompt for a specific mode with project and skills context. +/// +/// **Volatile-content-last invariant.** Blocks are appended in order from +/// most-static to most-volatile so DeepSeek's KV prefix cache hits the +/// longest possible byte prefix turn-over-turn: +/// +/// 1. mode prompt (compile-time constant) +/// 2. project context / fallback (workspace-static) +/// 3. skills block (skills-dir-static) +/// 4. `## Context Management` (compile-time constant, Agent/Yolo only) +/// 5. compaction handoff template (compile-time constant) +/// 6. handoff block — file-backed; rewritten by `/compact` and on exit +/// 7. working-set summary — drifts when a new path is observed +/// +/// Anything appended after a volatile block forfeits the cache for the rest +/// of the request. New blocks belong above the handoff/working-set boundary +/// unless they themselves are turn-volatile. pub fn system_prompt_for_mode_with_context_and_skills( mode: AppMode, workspace: &Path, @@ -188,7 +204,7 @@ pub fn system_prompt_for_mode_with_context_and_skills( // Load project context from workspace let project_context = load_project_context_with_parents(workspace); - // Combine base prompt with project context + // 1–2. Mode prompt + project context (or fallback automap). let mut full_prompt = if let Some(project_block) = project_context.as_system_block() { format!("{}\n\n{}", mode_prompt, project_block) } else { @@ -201,22 +217,13 @@ pub fn system_prompt_for_mode_with_context_and_skills( ) }; - if let Some(summary) = working_set_summary - && !summary.trim().is_empty() - { - full_prompt = format!("{full_prompt}\n\n{summary}"); - } - + // 3. Skills block. if let Some(skills_block) = skills_dir.and_then(crate::skills::render_available_skills_context) { full_prompt = format!("{full_prompt}\n\n{skills_block}"); } - if let Some(handoff_block) = load_handoff_block(workspace) { - full_prompt = format!("{full_prompt}\n\n{handoff_block}"); - } - - // Add compaction instruction for agent modes + // 4. Context Management (Agent / Yolo only). if matches!(mode, AppMode::Agent | AppMode::Yolo) { full_prompt.push_str( "\n\n## Context Management\n\n\ @@ -228,11 +235,27 @@ pub fn system_prompt_for_mode_with_context_and_skills( ); } - // Append the compaction handoff template so the model knows the format - // to use when writing `.deepseek/handoff.md` on exit / `/compact`. + // 5. Compaction handoff template — so the model knows the format to use + // when writing `.deepseek/handoff.md` on exit / `/compact`. full_prompt.push_str("\n\n"); full_prompt.push_str(COMPACT_TEMPLATE); + // ── Volatile-content boundary ───────────────────────────────────────── + // Everything below drifts mid-session and busts the prefix cache for + // bytes that follow. Keep new static blocks above this comment. + + // 6. Previous-session handoff (file-backed, rewritten by `/compact`). + if let Some(handoff_block) = load_handoff_block(workspace) { + full_prompt = format!("{full_prompt}\n\n{handoff_block}"); + } + + // 7. Working-set summary (drifts when a new path is observed). + if let Some(summary) = working_set_summary + && !summary.trim().is_empty() + { + full_prompt = format!("{full_prompt}\n\n{summary}"); + } + SystemPrompt::Text(full_prompt) } @@ -425,4 +448,169 @@ mod tests { assert!(!YOLO_PROMPT.is_empty()); assert!(!PLAN_PROMPT.is_empty()); } + + // ── Cache-prefix stability harness (#263 step 2) ─────────────────────── + // + // These tests pin the byte-stability invariant required for DeepSeek's + // KV prefix cache to hit: any prompt-construction surface that ends up + // in the cached prefix must produce identical bytes given identical + // inputs across calls. + + use crate::test_support::assert_byte_identical; + + #[test] + fn compose_prompt_is_byte_stable_across_calls() { + // Suspect #4 from #263: mode prompt churn within a single mode. + // Two calls with identical (mode, personality) inputs must produce + // identical bytes — anything else is a cache buster. + for mode in [AppMode::Agent, AppMode::Yolo, AppMode::Plan] { + for personality in [Personality::Calm, Personality::Playful] { + let a = compose_prompt(mode, personality); + let b = compose_prompt(mode, personality); + assert_byte_identical( + &format!("compose_prompt(mode={mode:?}, personality={personality:?})"), + &a, + &b, + ); + } + } + } + + #[test] + fn system_prompt_for_mode_with_context_is_byte_stable_for_unchanged_workspace() { + // Same workspace, no working_set / skills churn between calls → + // identical bytes. This pins the most representative production + // surface (engine.rs builds the system prompt via this fn or + // its sibling _and_skills variant on every turn). + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path(); + + for mode in [AppMode::Agent, AppMode::Yolo, AppMode::Plan] { + let a = match system_prompt_for_mode_with_context(mode, workspace, None) { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(_) => panic!("expected text system prompt"), + }; + let b = match system_prompt_for_mode_with_context(mode, workspace, None) { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(_) => panic!("expected text system prompt"), + }; + assert_byte_identical( + &format!("system_prompt_for_mode_with_context(mode={mode:?}) on empty workspace"), + &a, + &b, + ); + } + } + + #[test] + fn system_prompt_with_working_set_summary_is_byte_stable_for_constant_summary() { + // The `working_set_summary` argument is the volatile surface (suspect + // #1 in #263). Independently verifying THIS surface needs a separate + // test in working_set.rs; here we just pin that the surrounding + // prompt construction faithfully embeds whatever summary it's given + // without injecting any non-determinism on its own. + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path(); + let summary = "## Repo Working Set\nWorkspace: /tmp/x\n"; + + let a = match system_prompt_for_mode_with_context(AppMode::Agent, workspace, Some(summary)) + { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(_) => panic!("expected text system prompt"), + }; + let b = match system_prompt_for_mode_with_context(AppMode::Agent, workspace, Some(summary)) + { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(_) => panic!("expected text system prompt"), + }; + assert_byte_identical( + "system_prompt_for_mode_with_context with constant working_set summary", + &a, + &b, + ); + assert!(a.contains(summary), "summary must be embedded as-is"); + } + + #[test] + fn system_prompt_with_handoff_file_is_byte_stable_when_file_is_unchanged() { + // Companion to the working-set stability test: if `.deepseek/handoff.md` + // hasn't moved between two builds, the rendered prompt must produce + // identical bytes. The handoff block is the second volatile surface + // (the first is the working-set summary) — both land below the static + // boundary in `system_prompt_for_mode_with_context_and_skills`. + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path(); + let handoff_dir = workspace.join(".deepseek"); + std::fs::create_dir_all(&handoff_dir).unwrap(); + std::fs::write( + handoff_dir.join("handoff.md"), + "# Session handoff\n\n## Active task\nFinish #280.\n\n## Open blockers\n- [ ] none\n", + ) + .unwrap(); + + let a = match system_prompt_for_mode_with_context(AppMode::Agent, workspace, None) { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(_) => panic!("expected text system prompt"), + }; + let b = match system_prompt_for_mode_with_context(AppMode::Agent, workspace, None) { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(_) => panic!("expected text system prompt"), + }; + assert_byte_identical( + "system_prompt_for_mode_with_context with constant handoff file", + &a, + &b, + ); + assert!(a.contains(HANDOFF_BLOCK_MARKER), "handoff must be embedded"); + assert!(a.contains("Finish #280."), "handoff body must be present"); + } + + #[test] + fn handoff_and_working_set_appear_after_static_blocks() { + // Cache-prefix invariant: the volatile blocks (handoff, working_set) + // must come *after* the static `## Context Management` and the + // compaction handoff template (`## Compaction Handoff`) so a churn + // in either volatile section doesn't drag the static blocks out of + // the cached prefix. Pre-fix ordering placed handoff between the + // skills block and `## Context Management`, which busted the cache + // every time `/compact` rewrote the file. + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path(); + let handoff_dir = workspace.join(".deepseek"); + std::fs::create_dir_all(&handoff_dir).unwrap(); + std::fs::write(handoff_dir.join("handoff.md"), "# handoff body\n").unwrap(); + + let summary = "## Repo Working Set\nWorkspace: /tmp/x\n"; + let prompt = + match system_prompt_for_mode_with_context(AppMode::Agent, workspace, Some(summary)) { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(_) => panic!("expected text system prompt"), + }; + + let context_pos = prompt + .find("## Context Management") + .expect("Context Management section present in Agent mode"); + let compact_pos = prompt + .find("## Compaction Handoff") + .expect("compaction handoff template present"); + let handoff_pos = prompt + .find(HANDOFF_BLOCK_MARKER) + .expect("handoff block present when fixture file exists"); + let working_set_pos = prompt + .find("## Repo Working Set") + .expect("working-set summary present when supplied"); + + assert!( + context_pos < handoff_pos, + "## Context Management must precede the handoff block" + ); + assert!( + compact_pos < handoff_pos, + "## Compaction Handoff must precede the handoff block" + ); + assert!( + handoff_pos < working_set_pos, + "handoff block must precede the working-set summary (most-volatile last)" + ); + } } diff --git a/crates/tui/src/sandbox/mod.rs b/crates/tui/src/sandbox/mod.rs index a1bf7f87..b6678783 100644 --- a/crates/tui/src/sandbox/mod.rs +++ b/crates/tui/src/sandbox/mod.rs @@ -1,9 +1,6 @@ -// TODO(integrate): Wire sandbox into shell tool — tracked as future security feature #![allow(dead_code)] //! Sandbox module for secure command execution. -//! NOTE: Not yet integrated into shell tool - planned security feature. - //! //! This module provides sandboxing capabilities for shell commands executed by //! DeepSeek TUI. Sandboxing restricts what system resources a command can access, diff --git a/crates/tui/src/sandbox/policy.rs b/crates/tui/src/sandbox/policy.rs index 46511935..51ae760e 100644 --- a/crates/tui/src/sandbox/policy.rs +++ b/crates/tui/src/sandbox/policy.rs @@ -1,4 +1,3 @@ -// TODO(integrate): Wire sandbox policy into shell tool — tracked as future work #![allow(dead_code)] //! Sandbox policy definitions for command execution restrictions. diff --git a/crates/tui/src/skills/install.rs b/crates/tui/src/skills/install.rs index fe55823c..c67fa856 100644 --- a/crates/tui/src/skills/install.rs +++ b/crates/tui/src/skills/install.rs @@ -82,7 +82,9 @@ impl InstallSource { /// Parse a user-supplied spec. Empty / whitespace-only input is rejected. /// /// * `github:owner/repo` → [`InstallSource::GitHubRepo`] - /// * `http://` or `https://` prefix → [`InstallSource::DirectUrl`] + /// * `https://github.com/owner/repo[.git]` (no path past the repo) → + /// [`InstallSource::GitHubRepo`] + /// * any other `http://` or `https://` prefix → [`InstallSource::DirectUrl`] /// * anything else → [`InstallSource::Registry`] pub fn parse(spec: &str) -> Result { let trimmed = spec.trim(); @@ -107,12 +109,43 @@ impl InstallSource { return Ok(Self::GitHubRepo(format!("{owner}/{repo}"))); } if trimmed.starts_with("https://") || trimmed.starts_with("http://") { + if let Some(repo) = parse_github_browser_url(trimmed) { + return Ok(Self::GitHubRepo(repo)); + } return Ok(Self::DirectUrl(trimmed.to_string())); } Ok(Self::Registry(trimmed.to_string())) } } +/// Detect bare `https://github.com//` URLs (with or without a +/// trailing `.git`) and return `owner/repo`. Returns `None` for any URL that +/// already points at a specific archive / blob / tree path — those are real +/// direct URLs and the caller fetches them as-is. +fn parse_github_browser_url(url: &str) -> Option { + let after_scheme = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://"))?; + let (host, rest) = after_scheme.split_once('/')?; + if !host.eq_ignore_ascii_case("github.com") && !host.eq_ignore_ascii_case("www.github.com") { + return None; + } + let trimmed = rest.trim_end_matches('/'); + let mut parts = trimmed.splitn(3, '/'); + let owner = parts.next()?.trim(); + let repo = parts.next()?.trim().trim_end_matches(".git"); + if owner.is_empty() || repo.is_empty() { + return None; + } + // If there is a third segment, the URL points at a sub-resource + // (`/archive/...`, `/blob/...`, `/tree/...`). Treat that as a real direct + // URL — the user explicitly wants whatever lives at that path. + if parts.next().is_some() { + return None; + } + Some(format!("{owner}/{repo}")) +} + // ───────────────────────────────────────────────────────────────────────────── // Outcome / result types // ───────────────────────────────────────────────────────────────────────────── @@ -1034,6 +1067,47 @@ mod tests { ); } + #[test] + fn parse_github_browser_url_routes_to_github_repo() { + // Regression for #269: `https://github.com//` was being + // parsed as a DirectUrl, so the installer downloaded the HTML repo + // page and tried to gzip-decode HTML ("invalid gzip header"). + for spec in [ + "https://github.com/obra/superpowers", + "https://github.com/obra/superpowers/", + "https://github.com/obra/superpowers.git", + "https://github.com/obra/superpowers.git/", + "https://www.github.com/obra/superpowers", + "http://github.com/obra/superpowers", + " https://github.com/obra/superpowers ", + ] { + let parsed = InstallSource::parse(spec) + .unwrap_or_else(|err| panic!("parse({spec}) failed: {err}")); + assert_eq!( + parsed, + InstallSource::GitHubRepo("obra/superpowers".to_string()), + "spec {spec} must route to GitHubRepo", + ); + } + } + + #[test] + fn parse_github_archive_url_stays_direct() { + // URLs that point at a specific subresource (archive tarball, blob, + // tree) are real direct URLs — the user picked that exact path. + for spec in [ + "https://github.com/obra/superpowers/archive/refs/heads/main.tar.gz", + "https://github.com/obra/superpowers/blob/main/README.md", + "https://github.com/obra/superpowers/tree/main", + ] { + let parsed = InstallSource::parse(spec).unwrap(); + assert!( + matches!(parsed, InstallSource::DirectUrl(_)), + "spec {spec} must stay DirectUrl, got {parsed:?}", + ); + } + } + #[test] fn parse_registry_source() { let s = InstallSource::parse("my-skill").unwrap(); diff --git a/crates/tui/src/test_support.rs b/crates/tui/src/test_support.rs index 5efe4713..8212a66f 100644 --- a/crates/tui/src/test_support.rs +++ b/crates/tui/src/test_support.rs @@ -17,3 +17,45 @@ pub(crate) fn lock_test_env() -> MutexGuard<'static, ()> { Err(poisoned) => poisoned.into_inner(), } } + +/// Find the byte position of the first divergence between two strings, +/// returning a windowed view (`±32 bytes` around the divergence) so failures +/// in cache-prefix-stability tests show *which* bytes drifted, not just that +/// they did. Returns `None` when the strings are byte-identical. +pub(crate) fn first_divergence(a: &str, b: &str) -> Option<(usize, String, String)> { + let a_bytes = a.as_bytes(); + let b_bytes = b.as_bytes(); + let max = a_bytes.len().min(b_bytes.len()); + for i in 0..max { + if a_bytes[i] != b_bytes[i] { + let lo = i.saturating_sub(32); + let a_hi = (i + 32).min(a_bytes.len()); + let b_hi = (i + 32).min(b_bytes.len()); + let a_ctx = String::from_utf8_lossy(&a_bytes[lo..a_hi]).into_owned(); + let b_ctx = String::from_utf8_lossy(&b_bytes[lo..b_hi]).into_owned(); + return Some((i, a_ctx, b_ctx)); + } + } + if a_bytes.len() != b_bytes.len() { + return Some(( + max, + format!("(len={})", a_bytes.len()), + format!("(len={})", b_bytes.len()), + )); + } + None +} + +/// Assert two strings are byte-identical, panicking with a windowed diff +/// around the first divergence when they aren't. Used by the prefix-cache +/// stability harness (#263, #280) to pin construction surfaces that land in +/// DeepSeek's KV cache prefix. +#[track_caller] +pub(crate) fn assert_byte_identical(label: &str, a: &str, b: &str) { + if let Some((pos, a_ctx, b_ctx)) = first_divergence(a, b) { + panic!( + "{label}: prompt construction is non-deterministic — first diff at byte {pos}\n\ + ── side A (±32B) ──\n{a_ctx:?}\n── side B (±32B) ──\n{b_ctx:?}", + ); + } +} diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index 4a0b0d00..1d6c5014 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -7,7 +7,7 @@ //! - Filtering by capability use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use serde_json::Value; @@ -24,6 +24,11 @@ use super::spec::{ pub struct ToolRegistry { tools: HashMap>, context: ToolContext, + /// Memoised serialised tool catalog. Rebuilt lazily on first + /// `to_api_tools` call after a mutation; pinned across reads so the + /// description and schema bytes stay byte-stable for DeepSeek's KV + /// prefix cache. Invalidated on `register` / `remove` / `clear`. + api_cache: OnceLock>, } impl ToolRegistry { @@ -33,6 +38,7 @@ impl ToolRegistry { Self { tools: HashMap::new(), context, + api_cache: OnceLock::new(), } } @@ -42,6 +48,7 @@ impl ToolRegistry { if self.tools.insert(name.clone(), tool).is_some() { tracing::warn!("Overwriting existing tool: {}", name); } + self.invalidate_api_cache(); } /// Register multiple tools at once. @@ -133,10 +140,32 @@ impl ToolRegistry { } /// Convert all tools to API Tool format for sending to the model. + /// + /// Output is sorted by tool name for **prefix-cache stability** (#263). + /// Rust's `HashMap` uses a randomly-seeded hasher per process, so a raw + /// `self.tools.values()` iteration emits tools in a different order on + /// every `deepseek` launch, invalidating DeepSeek's KV prefix cache for + /// every cross-session resume. Sorting here matches the way Claude Code + /// stabilises its tool array (`assembleToolPool` in their reference). + /// + /// The serialised catalog is memoised on first call and pinned across + /// reads so each tool's `description()` and `input_schema()` are sampled + /// exactly once per registration. MCP adapters whose upstream description + /// drifts on reconnect would otherwise rewrite the catalog mid-session + /// and bust the prefix cache. The cache is invalidated on `register`, + /// `remove`, and `clear`. #[must_use] pub fn to_api_tools(&self) -> Vec { - self.tools - .values() + self.api_cache + .get_or_init(|| self.build_api_tools()) + .clone() + } + + fn build_api_tools(&self) -> Vec { + let mut tools: Vec<&Arc> = self.tools.values().collect(); + tools.sort_by(|a, b| a.name().cmp(b.name())); + tools + .into_iter() .map(|tool| Tool { tool_type: None, name: tool.name().to_string(), @@ -151,6 +180,10 @@ impl ToolRegistry { .collect() } + fn invalidate_api_cache(&mut self) { + self.api_cache = OnceLock::new(); + } + /// Convert tools to API Tool format with optional cache control on the last tool. #[must_use] #[allow(dead_code)] @@ -230,13 +263,18 @@ impl ToolRegistry { #[must_use] #[allow(dead_code)] pub fn remove(&mut self, name: &str) -> Option> { - self.tools.remove(name) + let removed = self.tools.remove(name); + if removed.is_some() { + self.invalidate_api_cache(); + } + removed } /// Clear all tools from the registry. #[allow(dead_code)] pub fn clear(&mut self) { self.tools.clear(); + self.invalidate_api_cache(); } } @@ -832,6 +870,175 @@ mod tests { assert_eq!(api_tools[0].description, "A test tool"); } + /// Tool whose `description()` advances through a script of pre-built + /// strings, one per call. Used to demonstrate that the api-tools cache + /// pins the description bytes on first read instead of re-sampling them + /// each turn (#263 follow-up; mirrors reference-cc's `getToolSchemaCache`). + struct VaryingDescriptionTool { + name: String, + descriptions: Vec, + next: std::sync::atomic::AtomicUsize, + } + + impl VaryingDescriptionTool { + fn new(name: &str, descriptions: &[&str]) -> Self { + Self { + name: name.to_string(), + descriptions: descriptions.iter().map(|s| (*s).to_string()).collect(), + next: std::sync::atomic::AtomicUsize::new(0), + } + } + } + + #[async_trait::async_trait] + impl ToolSpec for VaryingDescriptionTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + let idx = self + .next + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + .min(self.descriptions.len() - 1); + &self.descriptions[idx] + } + + fn input_schema(&self) -> Value { + json!({"type": "object", "properties": {}, "required": []}) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly] + } + + async fn execute( + &self, + _input: Value, + _context: &ToolContext, + ) -> Result { + Ok(ToolResult::success("ok".to_string())) + } + } + + #[test] + fn to_api_tools_pins_description_bytes_across_calls() { + // Regression for the cache-stability follow-up: an MCP adapter that + // returns a different `description()` on reconnect (or any other + // tool whose description isn't a `&'static str`) would otherwise + // rewrite the catalog bytes mid-session and miss the prefix cache. + // The registry pins the first call's value until it's mutated. + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let mut registry = ToolRegistry::new(ctx); + registry.register(Arc::new(VaryingDescriptionTool::new( + "varying", + &["first description", "second description"], + ))); + + let first = registry.to_api_tools(); + let second = registry.to_api_tools(); + + assert_eq!(first.len(), 1); + assert_eq!(first[0].description, "first description"); + assert_eq!( + first, second, + "api-tools catalog must be byte-identical across reads with no mutation in between" + ); + } + + #[test] + fn register_invalidates_api_tools_cache() { + // Counter-test: when a real change happens (a new tool registers, + // an existing one is removed, or `clear` is called), the cache must + // be discarded so the next read reflects the live registry. + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let mut registry = ToolRegistry::new(ctx); + registry.register(Arc::new(VaryingDescriptionTool::new( + "varying", + &["first description", "second description"], + ))); + + let before = registry.to_api_tools(); + assert_eq!(before.len(), 1); + + registry.register(make_test_tool("late_arrival")); + + let after = registry.to_api_tools(); + assert_eq!(after.len(), 2, "cache must rebuild after register"); + assert!(after.iter().any(|t| t.name == "varying")); + assert!(after.iter().any(|t| t.name == "late_arrival")); + // The varying tool's description advances on cache rebuild — the + // first read above sampled `first description`; this rebuild samples + // `second description`. The point is just that the bytes *can* + // change after a real mutation, not that they always do. + let varying_after = after + .iter() + .find(|t| t.name == "varying") + .expect("varying tool present"); + assert_eq!(varying_after.description, "second description"); + } + + #[test] + fn remove_and_clear_invalidate_api_tools_cache() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let mut registry = ToolRegistry::new(ctx); + registry.register(make_test_tool("alpha")); + registry.register(make_test_tool("beta")); + + let before = registry.to_api_tools(); + assert_eq!(before.len(), 2); + + let _ = registry.remove("alpha"); + let after_remove = registry.to_api_tools(); + assert_eq!(after_remove.len(), 1); + assert_eq!(after_remove[0].name, "beta"); + + registry.clear(); + let after_clear = registry.to_api_tools(); + assert!(after_clear.is_empty(), "cache must clear with the registry"); + } + + #[test] + fn to_api_tools_emits_alphabetical_order_regardless_of_registration_order() { + // Regression for #263: HashMap iteration is non-deterministic across + // process launches, which busts DeepSeek's KV prefix cache for every + // cross-session resume. `to_api_tools` must emit by name regardless + // of registration order so two consecutive calls (and two distinct + // launches) produce byte-identical output. + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + + let order_a = { + let mut registry = ToolRegistry::new(ctx.clone()); + registry.register(make_test_tool("zebra")); + registry.register(make_test_tool("alpha")); + registry.register(make_test_tool("mango")); + registry + .to_api_tools() + .iter() + .map(|t| t.name.clone()) + .collect::>() + }; + + let order_b = { + let mut registry = ToolRegistry::new(ctx.clone()); + registry.register(make_test_tool("alpha")); + registry.register(make_test_tool("mango")); + registry.register(make_test_tool("zebra")); + registry + .to_api_tools() + .iter() + .map(|t| t.name.clone()) + .collect::>() + }; + + assert_eq!(order_a, vec!["alpha", "mango", "zebra"]); + assert_eq!(order_a, order_b); + } + #[test] fn test_registry_remove() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index b9ddb961..a50dc3ee 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -59,6 +59,32 @@ pub enum AppMode { Plan, } +/// One row in the per-turn cache-telemetry ring (`/cache` debug surface, #263). +#[derive(Debug, Clone)] +pub struct TurnCacheRecord { + /// Provider-reported total input tokens for the turn (cache-hit + + /// cache-miss + uncategorized). Useful for sanity-checking that hits + + /// misses sum back to roughly the prompt size. + pub input_tokens: u32, + /// Provider-reported output tokens. + pub output_tokens: u32, + /// `prompt_cache_hit_tokens` from DeepSeek's usage payload. `None` when + /// the model in use does not report cache telemetry (see + /// `Capabilities::cache_telemetry_supported`). + pub cache_hit_tokens: Option, + /// `prompt_cache_miss_tokens`. `None` when the provider did not report it + /// — in that case the `/cache` formatter infers the miss as + /// `input_tokens − cache_hit_tokens`. + pub cache_miss_tokens: Option, + /// Approximate tokens spent re-sending prior `reasoning_content` on + /// V4-thinking tool-calling turns (chars/3 heuristic). Helps separate + /// cache misses caused by reasoning-replay churn from misses caused by + /// real prefix instability. + pub reasoning_replay_tokens: Option, + /// Local timestamp the turn telemetry was recorded. + pub recorded_at: Instant, +} + /// DeepSeek reasoning-effort tier, mirrored on ChatGPT/Claude effort pickers. /// /// The config file accepts all five string values for forward-compat with @@ -666,6 +692,9 @@ pub struct App { pub last_prompt_cache_hit_tokens: Option, /// DeepSeek context-cache miss tokens from the last API call. Telemetry only. pub last_prompt_cache_miss_tokens: Option, + /// Per-turn cache telemetry ring (`/cache` debug surface, #263). Newest + /// turn at the back. Capped at [`Self::TURN_CACHE_HISTORY_CAP`]. + pub turn_cache_history: VecDeque, /// Approximate input tokens spent re-sending prior `reasoning_content` on /// the last thinking-mode tool-calling turn (V4 §5.1.1 "Interleaved /// Thinking"). Computed client-side at ~4 chars/token. @@ -790,6 +819,19 @@ pub enum ApiKeyError { // === App State === impl App { + /// Cap on [`Self::turn_cache_history`]. Holds enough turns to debug a long + /// session without being so large the on-screen `/cache` table wraps. + pub const TURN_CACHE_HISTORY_CAP: usize = 50; + + /// Append a per-turn cache-telemetry record, trimming the oldest entry once + /// the ring exceeds [`Self::TURN_CACHE_HISTORY_CAP`]. + pub fn push_turn_cache_record(&mut self, record: TurnCacheRecord) { + self.turn_cache_history.push_back(record); + while self.turn_cache_history.len() > Self::TURN_CACHE_HISTORY_CAP { + self.turn_cache_history.pop_front(); + } + } + pub fn tr(&self, id: MessageId) -> &'static str { tr(self.ui_locale, id) } @@ -1039,6 +1081,7 @@ impl App { last_completion_tokens: None, last_prompt_cache_hit_tokens: None, last_prompt_cache_miss_tokens: None, + turn_cache_history: VecDeque::new(), last_reasoning_replay_tokens: None, workspace_context: None, workspace_context_refreshed_at: None, diff --git a/crates/tui/src/tui/command_palette.rs b/crates/tui/src/tui/command_palette.rs index 3dedae7f..7ae6e0ae 100644 --- a/crates/tui/src/tui/command_palette.rs +++ b/crates/tui/src/tui/command_palette.rs @@ -14,6 +14,7 @@ use ratatui::{ use unicode_width::UnicodeWidthStr; use crate::commands; +use crate::localization::Locale; use crate::palette; use crate::skills::SkillRegistry; use crate::tools::spec::ApprovalRequirement; @@ -46,6 +47,7 @@ pub struct CommandPaletteView { } pub fn build_entries( + locale: Locale, skills_dir: &Path, workspace: &Path, mcp_config_path: &Path, @@ -54,7 +56,7 @@ pub fn build_entries( let mut entries = Vec::new(); for command in commands::COMMANDS { - let mut description = command.palette_description(); + let mut description = command.palette_description_for(locale); if command.requires_argument() { description.push_str(" "); description.push_str(command.usage); @@ -919,7 +921,13 @@ mod tests { #[test] fn command_palette_command_entries_include_links_and_config_but_not_removed_commands() { - let entries = build_entries(Path::new("."), Path::new("."), Path::new("mcp.json"), None); + let entries = build_entries( + Locale::En, + Path::new("."), + Path::new("."), + Path::new("mcp.json"), + None, + ); let command_labels = entries .iter() .filter(|entry| entry.section == PaletteSection::Command) @@ -934,7 +942,13 @@ mod tests { #[test] fn command_palette_inserts_model_command_for_argument_entry() { - let entries = build_entries(Path::new("."), Path::new("."), Path::new("mcp.json"), None); + let entries = build_entries( + Locale::En, + Path::new("."), + Path::new("."), + Path::new("mcp.json"), + None, + ); let model = entries .iter() .find(|entry| entry.section == PaletteSection::Command && entry.label == "/model") @@ -991,6 +1005,7 @@ mod tests { ], }; let entries = build_entries( + Locale::En, Path::new("."), Path::new("."), Path::new("mcp.json"), @@ -1044,6 +1059,7 @@ mod tests { }], }; let entries = build_entries( + Locale::En, Path::new("."), Path::new("."), Path::new("mcp.json"), diff --git a/crates/tui/src/tui/keybindings.rs b/crates/tui/src/tui/keybindings.rs index c0cf9661..5b6ec311 100644 --- a/crates/tui/src/tui/keybindings.rs +++ b/crates/tui/src/tui/keybindings.rs @@ -33,16 +33,18 @@ pub enum KeybindingSection { } impl KeybindingSection { - pub fn label(self) -> &'static str { - match self { - Self::Navigation => "Navigation", - Self::Editing => "Input editing", - Self::Submission => "Actions", - Self::Modes => "Modes", - Self::Sessions => "Sessions", - Self::Clipboard => "Clipboard", - Self::Help => "Help", - } + pub fn label(self, locale: crate::localization::Locale) -> &'static str { + use crate::localization::{MessageId, tr}; + let id = match self { + Self::Navigation => MessageId::HelpSectionNavigation, + Self::Editing => MessageId::HelpSectionEditing, + Self::Submission => MessageId::HelpSectionActions, + Self::Modes => MessageId::HelpSectionModes, + Self::Sessions => MessageId::HelpSectionSessions, + Self::Clipboard => MessageId::HelpSectionClipboard, + Self::Help => MessageId::HelpSectionHelp, + }; + tr(locale, id) } /// Stable ordering for help rendering — matches the variant declaration diff --git a/crates/tui/src/tui/streaming/mod.rs b/crates/tui/src/tui/streaming/mod.rs index 0cbe53cd..74e24373 100644 --- a/crates/tui/src/tui/streaming/mod.rs +++ b/crates/tui/src/tui/streaming/mod.rs @@ -1,4 +1,3 @@ -// TODO(integrate): Wire streaming collector into TUI rendering pipeline #![allow(dead_code)] //! Markdown stream collector for newline-gated rendering. diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 162ffe50..62e1e3bb 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -716,6 +716,14 @@ async fn run_event_loop( app.last_prompt_cache_hit_tokens = usage.prompt_cache_hit_tokens; app.last_prompt_cache_miss_tokens = usage.prompt_cache_miss_tokens; app.last_reasoning_replay_tokens = usage.reasoning_replay_tokens; + app.push_turn_cache_record(crate::tui::app::TurnCacheRecord { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + cache_hit_tokens: usage.prompt_cache_hit_tokens, + cache_miss_tokens: usage.prompt_cache_miss_tokens, + reasoning_replay_tokens: usage.reasoning_replay_tokens, + recorded_at: Instant::now(), + }); if let Some(error) = error { app.status_message = Some(format!("Turn failed: {error}")); } @@ -1422,6 +1430,7 @@ async fn run_event_loop( } app.view_stack .push(CommandPaletteView::new(build_command_palette_entries( + app.ui_locale, &app.skills_dir, &app.workspace, &app.mcp_config_path, @@ -4575,7 +4584,11 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { // within ~2s. Mirrors codex-rs's `FooterMode::QuitShortcutReminder`. let quit_prompt = if app.quit_is_armed() { Some(FooterToast { - text: "Press Ctrl+C again to quit".to_string(), + text: crate::localization::tr( + app.ui_locale, + crate::localization::MessageId::FooterPressCtrlCAgain, + ) + .to_string(), color: palette::STATUS_WARNING, }) } else { @@ -4614,7 +4627,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { // non-tool work falls back to the existing dot-pulse label. props.state_label = active_subagent_status_label(app) .or_else(|| active_tool_status_label(app)) - .unwrap_or_else(|| crate::tui::widgets::footer_working_label(dot_frame)); + .unwrap_or_else(|| crate::tui::widgets::footer_working_label(dot_frame, app.ui_locale)); props.state_color = palette::DEEPSEEK_SKY; // Spout drift: only animate when low_motion is off. The textual @@ -4942,7 +4955,7 @@ fn render_footer_from( Vec::new() }; let agents = if has(S::Agents) { - crate::tui::widgets::footer_agents_chip(running_agent_count(app)) + crate::tui::widgets::footer_agents_chip(running_agent_count(app), app.ui_locale) } else { Vec::new() }; @@ -5048,7 +5061,8 @@ fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec> { // coherence, in-flight sub-agents, reasoning replay tokens, cache hit // rate, and session cost. let coherence_spans = footer_coherence_spans(app); - let agents_spans = crate::tui::widgets::footer_agents_chip(running_agent_count(app)); + let agents_spans = + crate::tui::widgets::footer_agents_chip(running_agent_count(app), app.ui_locale); let replay_spans = footer_reasoning_replay_spans(app); let cache_spans = footer_cache_spans(app); let displayed_cost = app.displayed_session_cost(); @@ -5713,6 +5727,7 @@ fn handle_context_menu_action(app: &mut App, action: ContextMenuAction) { ContextMenuAction::OpenCommandPalette => { app.view_stack .push(CommandPaletteView::new(build_command_palette_entries( + app.ui_locale, &app.skills_dir, &app.workspace, &app.mcp_config_path, diff --git a/crates/tui/src/tui/views/help.rs b/crates/tui/src/tui/views/help.rs index e06ad5db..7b5076ec 100644 --- a/crates/tui/src/tui/views/help.rs +++ b/crates/tui/src/tui/views/help.rs @@ -88,7 +88,7 @@ impl HelpView { } pub fn new_for_locale(locale: Locale) -> Self { - let entries = build_entries(); + let entries = build_entries(locale); let mut view = Self { locale, entries, @@ -144,17 +144,18 @@ impl HelpView { } } -fn build_entries() -> Vec { +fn build_entries(locale: Locale) -> Vec { let mut entries = Vec::new(); for command in commands::COMMANDS { let label = format!("/{}", command.name); + let localized = command.description_for(locale); let description = if command.aliases.is_empty() { - command.description.to_string() + localized.to_string() } else { format!( "{} (aliases: {})", - command.description, + localized, command .aliases .iter() @@ -182,7 +183,11 @@ fn build_entries() -> Vec { for binding in KEYBINDINGS { let label = binding.chord.to_string(); - let description = format!("[{}] {}", binding.section.label(), binding.description); + let description = format!( + "[{}] {}", + binding.section.label(locale), + binding.description + ); let haystack = format!( "{} {}", label.to_ascii_lowercase(), diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index 43930ef7..a38754d7 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -15,6 +15,7 @@ use ratatui::{ }; use unicode_width::UnicodeWidthStr; +use crate::localization::{Locale, MessageId, tr}; use crate::palette; use crate::tui::app::{App, AppMode}; @@ -123,16 +124,18 @@ pub fn footer_working_strip_string(width: usize, frame: u64) -> String { out } -/// Pulse `working` through `working`, `working.`, `working..`, `working...` +/// Pulse the localized "working" label through 0–3 trailing ASCII dots /// keyed off `frame`. The cycle period is 4 frames (matching the four -/// states), so adjacent ticks visibly differ. Returns a static-friendly +/// states), so adjacent ticks visibly differ. Dots stay ASCII regardless +/// of locale so the animation reads identically across scripts. Returns a /// `String` so callers can drop it into a `Span::styled` without lifetime /// gymnastics. #[must_use] -pub fn footer_working_label(frame: u64) -> String { +pub fn footer_working_label(frame: u64, locale: Locale) -> String { let dots = (frame % 4) as usize; - let mut out = String::with_capacity(7 + dots); - out.push_str("working"); + let base = tr(locale, MessageId::FooterWorking); + let mut out = String::with_capacity(base.len() + dots); + out.push_str(base); for _ in 0..dots { out.push('.'); } @@ -141,16 +144,18 @@ pub fn footer_working_label(frame: u64) -> String { /// Build a "N agents" chip span list when there are sub-agents in flight. /// Empty list when N == 0 hides the chip entirely. Singular for N == 1 -/// reads naturally; plural otherwise. +/// reads naturally; plural otherwise. The pluralization template lives in +/// the locale registry so CJK locales can render the count without the +/// English plural-`s` artefact. #[must_use] -pub fn footer_agents_chip(running: usize) -> Vec> { +pub fn footer_agents_chip(running: usize, locale: Locale) -> Vec> { if running == 0 { return Vec::new(); } let text = if running == 1 { - "1 agent".to_string() + tr(locale, MessageId::FooterAgentSingular).to_string() } else { - format!("{running} agents") + tr(locale, MessageId::FooterAgentsPlural).replace("{count}", &running.to_string()) }; vec![Span::styled( text, @@ -508,6 +513,7 @@ fn truncate_to_width(text: &str, max_width: usize) -> String { mod tests { use super::{FooterProps, FooterWidget, Renderable}; use crate::config::Config; + use crate::localization::Locale; use crate::palette; use crate::tui::app::{App, AppMode, TuiOptions}; use ratatui::{ @@ -597,20 +603,20 @@ mod tests { // ---- agents chip wording ---- #[test] fn footer_agents_chip_is_empty_when_no_agents_running() { - let chip = super::footer_agents_chip(0); + let chip = super::footer_agents_chip(0, Locale::En); assert!(chip.is_empty(), "0 agents in flight → no chip"); } #[test] fn footer_agents_chip_uses_singular_for_one() { - let chip = super::footer_agents_chip(1); + let chip = super::footer_agents_chip(1, Locale::En); assert_eq!(chip.len(), 1); assert_eq!(chip[0].content.as_ref(), "1 agent"); } #[test] fn footer_agents_chip_uses_plural_for_many() { - let chip = super::footer_agents_chip(3); + let chip = super::footer_agents_chip(3, Locale::En); assert_eq!(chip.len(), 1); assert_eq!(chip[0].content.as_ref(), "3 agents"); } @@ -618,7 +624,7 @@ mod tests { #[test] fn footer_agents_chip_renders_into_widget() { let app = make_app(); - let agents = super::footer_agents_chip(2); + let agents = super::footer_agents_chip(2, Locale::En); let props = FooterProps::from_app( &app, None, @@ -779,16 +785,16 @@ mod tests { // The label sequence `working` → `working.` → `working..` → // `working...` then wraps back. Each frame is a discrete tick; // the cycle is exactly 4 frames so adjacent ticks visibly differ. - assert_eq!(super::footer_working_label(0), "working"); - assert_eq!(super::footer_working_label(1), "working."); - assert_eq!(super::footer_working_label(2), "working.."); - assert_eq!(super::footer_working_label(3), "working..."); + assert_eq!(super::footer_working_label(0, Locale::En), "working"); + assert_eq!(super::footer_working_label(1, Locale::En), "working."); + assert_eq!(super::footer_working_label(2, Locale::En), "working.."); + assert_eq!(super::footer_working_label(3, Locale::En), "working..."); assert_eq!( - super::footer_working_label(4), + super::footer_working_label(4, Locale::En), "working", "wraps back at frame 4", ); - assert_eq!(super::footer_working_label(7), "working..."); + assert_eq!(super::footer_working_label(7, Locale::En), "working..."); } /// Render the footer at `width` and return the visible single-line text. diff --git a/crates/tui/src/utils.rs b/crates/tui/src/utils.rs index a1b8bded..ebd45ea3 100644 --- a/crates/tui/src/utils.rs +++ b/crates/tui/src/utils.rs @@ -1,7 +1,7 @@ //! Utility helpers shared across the `DeepSeek` CLI. use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::models::{ContentBlock, Message}; use anyhow::{Context, Result}; @@ -38,6 +38,13 @@ pub fn is_key_file(path: &Path) -> bool { } /// Generate a high-level summary of the project based on key files. +/// +/// Output is byte-stable across calls: `WalkBuilder` doesn't sort siblings +/// (the OS readdir order leaks through), so the joined `key_files` list +/// would otherwise reorder run-to-run on filesystems that don't pre-sort. +/// Only matters when the workspace has no `AGENTS.md` / `CLAUDE.md`, since +/// the system prompt routes through `ProjectContext::as_system_block` first +/// and only falls back here when no project-context document exists. #[must_use] pub fn summarize_project(root: &Path) -> String { let mut key_files = Vec::new(); @@ -58,6 +65,8 @@ pub fn summarize_project(root: &Path) -> String { } } + key_files.sort(); + if key_files.is_empty() { return "Unknown project type".to_string(); } @@ -90,38 +99,43 @@ pub fn summarize_project(root: &Path) -> String { } /// Generate a tree-like view of the project structure. +/// +/// Sibling order is fixed by sorting collected paths — the underlying +/// `WalkBuilder` follows the OS readdir order, which is non-deterministic +/// across filesystems. Sorting by full path preserves the tree shape (a +/// directory still precedes its children because `"src" < "src/lib.rs"`) +/// while making the rendered output byte-stable across runs. #[must_use] pub fn project_tree(root: &Path, max_depth: usize) -> String { - let mut tree_lines = Vec::new(); + let mut entries: Vec<(PathBuf, bool)> = Vec::new(); let mut builder = WalkBuilder::new(root); builder .hidden(false) .follow_links(true) .max_depth(Some(max_depth + 1)); - let walker = builder.build(); - for entry in walker { - let entry = match entry { - Ok(entry) => entry, - Err(_) => continue, - }; - - let path = entry.path(); + for entry in builder.build().flatten() { let depth = entry.depth(); - if depth == 0 || depth > max_depth { continue; } + let rel_path = entry + .path() + .strip_prefix(root) + .unwrap_or(entry.path()) + .to_path_buf(); + let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir()); + entries.push((rel_path, is_dir)); + } - let rel_path = path.strip_prefix(root).unwrap_or(path); - let indent = " ".repeat(depth - 1); - let prefix = if entry.file_type().is_some_and(|ft| ft.is_dir()) { - "DIR: " - } else { - "FILE: " - }; + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let mut tree_lines = Vec::with_capacity(entries.len()); + for (rel_path, is_dir) in entries { + let depth = rel_path.components().count(); + let indent = " ".repeat(depth.saturating_sub(1)); + let prefix = if is_dir { "DIR: " } else { "FILE: " }; tree_lines.push(format!( "{}{}{}", indent, @@ -310,3 +324,102 @@ mod tests { }); } } + +#[cfg(test)] +mod project_mapping_tests { + use super::{project_tree, summarize_project}; + use std::fs; + use tempfile::tempdir; + + #[test] + fn project_tree_sorts_siblings_alphabetically() { + // Cross-platform readdir doesn't guarantee alphabetical order — on + // ext4 with htree it's hash order, on APFS it's roughly insertion + // order, on ZFS it's storage-class dependent. The system prompt + // embeds this string in the cached prefix when a workspace has no + // AGENTS.md / CLAUDE.md, so the function has to be byte-stable + // across runs regardless of host filesystem. + let tmp = tempdir().expect("tempdir"); + let root = tmp.path(); + // Create files in a deliberately scrambled order to make the + // hosting filesystem's pre-sort (if any) less likely to mask a + // missing sort in our code. + fs::write(root.join("zebra.txt"), "z").expect("write zebra"); + fs::write(root.join("apple.txt"), "a").expect("write apple"); + fs::write(root.join("mango.txt"), "m").expect("write mango"); + + let tree = project_tree(root, 1); + let lines: Vec<&str> = tree.lines().collect(); + let apple_pos = lines + .iter() + .position(|l| l.contains("apple.txt")) + .expect("apple line"); + let mango_pos = lines + .iter() + .position(|l| l.contains("mango.txt")) + .expect("mango line"); + let zebra_pos = lines + .iter() + .position(|l| l.contains("zebra.txt")) + .expect("zebra line"); + + assert!(apple_pos < mango_pos); + assert!(mango_pos < zebra_pos); + } + + #[test] + fn project_tree_keeps_directory_before_its_children() { + // Sorting siblings by full path is enough to preserve tree shape: + // `"src" < "src/lib.rs"` because the shorter string compares less. + let tmp = tempdir().expect("tempdir"); + let root = tmp.path(); + let src = root.join("src"); + fs::create_dir_all(&src).expect("mkdir src"); + fs::write(src.join("lib.rs"), "lib").expect("write lib"); + fs::write(src.join("main.rs"), "main").expect("write main"); + + let tree = project_tree(root, 2); + let src_pos = tree.find("DIR: src").expect("src dir line"); + let lib_pos = tree.find("FILE: lib.rs").expect("lib file line"); + let main_pos = tree.find("FILE: main.rs").expect("main file line"); + + assert!(src_pos < lib_pos, "directory must precede its children"); + assert!(lib_pos < main_pos, "siblings sorted by name"); + } + + #[test] + fn project_tree_is_byte_stable_across_calls() { + let tmp = tempdir().expect("tempdir"); + let root = tmp.path(); + fs::write(root.join("z.txt"), "z").expect("write"); + fs::write(root.join("a.txt"), "a").expect("write"); + + assert_eq!(project_tree(root, 1), project_tree(root, 1)); + } + + #[test] + fn summarize_project_sorts_key_files_in_fallback() { + // When `summarize_project` can't classify a project type it falls + // back to listing the discovered key files. That joined list must + // be deterministic so the system prompt that embeds it doesn't + // drift between runs on filesystems that emit readdir in a + // non-alphabetical order. + let tmp = tempdir().expect("tempdir"); + let root = tmp.path(); + // Use key files that don't trigger any of the type detectors + // (Cargo.toml / package.json / requirements.txt) so the function + // hits the `Project with key files: …` branch. + fs::write(root.join("Makefile"), "all:").expect("write makefile"); + fs::write(root.join("README.md"), "# x").expect("write readme"); + + let summary = summarize_project(root); + assert!( + summary.starts_with("Project with key files: "), + "expected fallback branch; got: {summary}" + ); + let suffix = summary + .strip_prefix("Project with key files: ") + .expect("prefix"); + assert_eq!(suffix, "Makefile, README.md"); + } +} diff --git a/crates/tui/src/working_set.rs b/crates/tui/src/working_set.rs index 2d1b7fd7..21eb94d5 100644 --- a/crates/tui/src/working_set.rs +++ b/crates/tui/src/working_set.rs @@ -380,9 +380,17 @@ impl WorkingSet { } /// Render a compact working-set block for the system prompt. + /// + /// Byte-stable across `next_turn()` calls when no new paths are observed + /// (#280): the rendered lines drop the turn-relative `touches` and + /// `last seen N turn(s) ago` fields, and the order is taken from + /// `sorted_for_prompt` (turn-agnostic) instead of `sorted_entries`. + /// The block lands in the system prompt before the historical + /// conversation; any byte that drifts here cache-misses everything that + /// follows in DeepSeek's KV prefix cache. pub fn summary_block(&self, workspace: &Path) -> Option { - let entries = self.sorted_entries(); - let prompt_entries: Vec<&WorkingSetEntry> = entries + let prompt_entries: Vec<&WorkingSetEntry> = self + .sorted_for_prompt() .into_iter() .take(self.config.max_prompt_entries) .collect(); @@ -404,12 +412,8 @@ impl WorkingSet { if !prompt_entries.is_empty() { lines.push("Active paths (prioritize these):".to_string()); for entry in prompt_entries { - let age = self.turn.saturating_sub(entry.last_turn); let kind = if entry.is_dir { "dir" } else { "file" }; - lines.push(format!( - "- {} ({kind}, touches: {}, last seen: {} turn(s) ago)", - entry.path, entry.touches, age - )); + lines.push(format!("- {} ({kind})", entry.path)); } } @@ -531,6 +535,18 @@ impl WorkingSet { }); entries } + + /// Turn-agnostic ordering used when rendering the prompt summary block. + /// `sorted_entries` mixes in a recency bonus from `self.turn`, so its + /// output reorders as turns advance even when no new paths are touched — + /// that movement would cross `max_prompt_entries` boundaries and bust the + /// KV prefix cache (#280). Compaction pinning still uses the recency-aware + /// `sorted_entries`; only the prompt-facing surface is stabilised here. + fn sorted_for_prompt(&self) -> Vec<&WorkingSetEntry> { + let mut entries: Vec<&WorkingSetEntry> = self.entries.values().collect(); + entries.sort_by(|a, b| b.touches.cmp(&a.touches).then_with(|| a.path.cmp(&b.path))); + entries + } } fn score_entry(entry: &WorkingSetEntry, current_turn: u64) -> i64 { @@ -986,6 +1002,62 @@ mod tests { assert!(block.contains("src/lib.rs")); } + /// #280 regression: `summary_block` must produce byte-identical output + /// across `next_turn()` advances when no new paths are touched. Prior to + /// the fix, the rendered lines interpolated `entry.touches` and + /// `self.turn - entry.last_turn`, both of which drift turn-over-turn even + /// when the path set is unchanged. The drift busted DeepSeek's KV prefix + /// cache on every user message because the working-set block lands in the + /// system prompt before the historical conversation. + #[test] + fn summary_block_is_byte_stable_across_next_turn_when_no_new_paths_observed() { + use crate::test_support::assert_byte_identical; + + let tmp = TempDir::new().expect("tempdir"); + fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"").expect("write"); + let src = tmp.path().join("src"); + fs::create_dir_all(&src).expect("mkdir"); + fs::write(src.join("a.rs"), "a").expect("write"); + fs::write(src.join("b.rs"), "b").expect("write"); + + let mut ws = WorkingSet::default(); + ws.observe_user_message("Edit src/a.rs and src/b.rs", tmp.path()); + + let before = ws.summary_block(tmp.path()).expect("block before"); + ws.next_turn(); + let after = ws.summary_block(tmp.path()).expect("block after"); + + assert_byte_identical( + "summary_block must be stable across next_turn when no new paths touched", + &before, + &after, + ); + } + + /// Companion to the byte-stability test: a fresh path *should* invalidate + /// the block (the KV cache is allowed to miss when there's genuinely new + /// signal), so the model still sees newly touched paths after the block + /// stabilises across no-op turns. + #[test] + fn summary_block_changes_when_a_new_path_is_observed() { + let tmp = TempDir::new().expect("tempdir"); + fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"").expect("write"); + let src = tmp.path().join("src"); + fs::create_dir_all(&src).expect("mkdir"); + fs::write(src.join("a.rs"), "a").expect("write"); + fs::write(src.join("c.rs"), "c").expect("write"); + + let mut ws = WorkingSet::default(); + ws.observe_user_message("src/a.rs", tmp.path()); + let before = ws.summary_block(tmp.path()).expect("block before"); + + ws.observe_user_message("src/c.rs", tmp.path()); + let after = ws.summary_block(tmp.path()).expect("block after"); + + assert_ne!(before, after, "new path must update the rendered summary"); + assert!(after.contains("src/c.rs")); + } + #[test] fn extract_paths_from_message_picks_up_tool_results() { let msg = Message { diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index bfc940e7..8a8f44a3 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,7 +1,7 @@ { "name": "deepseek-tui", - "version": "0.8.3", - "deepseekBinaryVersion": "0.8.3", + "version": "0.8.4", + "deepseekBinaryVersion": "0.8.4", "description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.", "author": "Hmbown", "license": "MIT",