chore(release): prepare v0.8.39

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