diff --git a/CHANGELOG.md b/CHANGELOG.md index aff05feb..be88ef4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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 @@ -22,6 +22,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `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 @@ -4301,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 diff --git a/Cargo.lock b/Cargo.lock index 3ac2d21d..f8fd5cf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index b790cc9d..b6a89361 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.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 diff --git a/Dockerfile b/Dockerfile index 17753674..65bdf693 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 12331e22..84f1171c 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 6697dfea..d4073e9c 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.38" } +deepseek-config = { path = "../config", version = "0.8.39" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index 9ccc3ea6..3232b90a 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.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 diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 9ed3cb49..1b1d7a37 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.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 diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index b3c36950..9da35b31 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.38" } +deepseek-secrets = { path = "../secrets", version = "0.8.39" } dirs.workspace = true serde.workspace = true toml.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 5fc3ddca..7f93d0ee 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -deepseek-agent = { path = "../agent", version = "0.8.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 diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 47f6490c..660115d0 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.38" } +deepseek-protocol = { path = "../protocol", version = "0.8.39" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index 074b76d5..8f91c970 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.38" } +deepseek-protocol = { path = "../protocol", version = "0.8.39" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index b4811b9b..5508e08a 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.38" } +deepseek-protocol = { path = "../protocol", version = "0.8.39" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index aff05feb..be88ef4b 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -5,7 +5,7 @@ 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 @@ -22,6 +22,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `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 @@ -4301,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 diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 9784397a..da469181 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -21,8 +21,8 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.100" arboard = "3.4" -deepseek-secrets = { path = "../secrets", version = "0.8.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" diff --git a/crates/tui/src/acp_server.rs b/crates/tui/src/acp_server.rs index 4cc4fdb3..1b2cbd38 100644 --- a/crates/tui/src/acp_server.rs +++ b/crates/tui/src/acp_server.rs @@ -359,6 +359,7 @@ async fn write_jsonrpc_result(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( 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); + } } diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 770cbdef..8e9af335 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -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 = 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 { + decode_chunk_with_reasoning(json_text, true) + } + + fn decode_chunk_with_reasoning(json_text: &str, is_reasoning_model: bool) -> Vec { 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)); + } } diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 8ffd73ac..0493ed31 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -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 { 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 // ======================================================================== diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 633f4825..5bc237ba 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -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); diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 0072aa9b..ecf7c176 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -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"); diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index 77dc8fcc..a77a2625 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -68,6 +68,7 @@ pub enum Op { session_id: Option, messages: Vec, system_prompt: Option, + system_prompt_override: bool, model: String, workspace: PathBuf, }, diff --git a/crates/tui/src/core/session.rs b/crates/tui/src/core/session.rs index 60aa9187..cde29b73 100644 --- a/crates/tui/src/core/session.rs +++ b/crates/tui/src/core/session.rs @@ -32,6 +32,9 @@ pub struct Session { /// System prompt (optional) pub system_prompt: Option, + /// 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, @@ -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(), diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index ee4874c0..85736310 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -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, }) diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index 054c1a86..e37e75c8 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -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 { + colorfgbg + .and_then(Self::from_colorfgbg) + .or(macos_fallback) .unwrap_or(Self::Dark) } } +#[cfg(target_os = "macos")] +fn detect_macos_palette_mode() -> Option { + 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 { + 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); diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 07ad8d6f..5ec3c8f6 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -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(), }) diff --git a/crates/tui/src/tools/approval_cache.rs b/crates/tui/src/tools/approval_cache.rs index fd5852b3..17d83e36 100644 --- a/crates/tui/src/tools/approval_cache.rs +++ b/crates/tui/src/tools/approval_cache.rs @@ -395,7 +395,8 @@ mod tests { #[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"})); + 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"})); diff --git a/crates/tui/src/tools/rlm.rs b/crates/tui/src/tools/rlm.rs index 086181e8..e3cdbb04 100644 --- a/crates/tui/src/tools/rlm.rs +++ b/crates/tui/src/tools/rlm.rs @@ -81,10 +81,7 @@ impl ToolSpec for RlmOpenTool { } async fn execute(&self, input: Value, context: &ToolContext) -> Result { - 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), 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(); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 093771b4..7d034653 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -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()); diff --git a/crates/tui/src/tui/composer_ui.rs b/crates/tui/src/tui/composer_ui.rs index dd1b3f61..c33c5db7 100644 --- a/crates/tui/src/tui/composer_ui.rs +++ b/crates/tui/src/tui/composer_ui.rs @@ -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 } diff --git a/crates/tui/src/tui/pager.rs b/crates/tui/src/tui/pager.rs index f646c2db..a67339c7 100644 --- a/crates/tui/src/tui/pager.rs +++ b/crates/tui/src/tui/pager.rs @@ -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); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 4d2748a8..520bda73 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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(), }) @@ -2300,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(), }) @@ -3177,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(), }) @@ -4244,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(), }) @@ -4356,6 +4360,7 @@ async fn apply_command_result( session_id, messages, system_prompt, + system_prompt_override: false, model, workspace, }) @@ -4673,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(), }) @@ -4819,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(), }) @@ -5808,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(), }) @@ -5951,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(), }) diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index b6d00045..edbabbae 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -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"); } diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index f7fb6f2e..e24432ff 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -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",