chore(release): prepare v0.8.39
Bump workspace, inter-crate, and npm package versions 0.8.38 -> 0.8.39. Roll CHANGELOG [Unreleased] into [0.8.39] with all fixes: - Revert v0.8.38 /model picker rework (back to instant curated picker) - Restore approval grouping (lossy v0.8.37 logic for approvals, exact key for denials) - Thinking-only turn surface fix (#1727) - ACP server JSON-RPC id stringification (#1696) - Chat client: reasoning_content for generic providers (#1673) - Compaction: user text query preservation (#1704) - Engine: system prompt override survival (#1688) - Pager: G/End overshoot fix (#1706), mouse scroll (#1716) - Composer: scroll with text (#1677), multiline arrows (#1721) - macOS system theme detection (#1670) - rlm_open blank source fields (#1712) - Terminal resize paging fix (#1724) - Docker first-run permission (#1684) - README Rust 1.88+ requirement note (#1718) Tests: 3149 passed, 0 failed (deepseek-tui crate) clippy: clean on --all-targets --all-features
This commit is contained in:
+66
-2
@@ -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
|
||||
|
||||
Generated
+14
-14
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+66
-2
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ========================================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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"}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user