Merge pull request #1335 from Hmbown/work/v0.8.26-security
chore(release): prepare v0.8.26 — security hotfix + release-pipeline polish
This commit is contained in:
@@ -5,6 +5,94 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.8.26] - 2026-05-09
|
||||
|
||||
A security + polish release. Two responsibly-disclosed issues were
|
||||
patched, plus a small batch of internal release-pipeline fixes. Big
|
||||
thanks to **@JafarAkhondali** and **@47Cid** for the disclosures.
|
||||
|
||||
### Security
|
||||
|
||||
- Hardened the `fetch_url` tool's network-target validation
|
||||
(GHSA-88gh-2526-gfrr). Thanks to **@JafarAkhondali**.
|
||||
- Tightened the default privileges of sub-agents created through
|
||||
`task_create` (GHSA-72w5-pf8h-xfp4). Thanks to **@47Cid**.
|
||||
|
||||
Both items will have full advisory text once the GHSA entries are
|
||||
published.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Hint when root `base_url` is set with a non-DeepSeek provider
|
||||
(#1308)** — config load now logs a warning telling the user to
|
||||
move the URL under the matching `[providers.<name>]` table or use
|
||||
the `*_BASE_URL` env var. Closes the silent-ignore footgun for
|
||||
Ollama / vLLM / OpenAI-compatible setups.
|
||||
- **Insecure base-URL error message is more discoverable (#1303)** —
|
||||
the rejection now spells out which env var to set (with underscores
|
||||
visible), notes that loopback hosts are auto-allowed, and shows a
|
||||
one-line `DEEPSEEK_ALLOW_INSECURE_HTTP=1 deepseek` example.
|
||||
- **Workspace skills survive prompt truncation** — when the skill
|
||||
catalog needs trimming to fit the prompt budget, workspace-local
|
||||
skills now keep precedence over global ones rather than being
|
||||
truncated indiscriminately. Thanks **@hhhaiai**.
|
||||
- **`/skills` listing has visual spacing** between entries so long
|
||||
skill descriptions don't run together. Thanks **@reidliu41**.
|
||||
- **Provider base-URL overrides reach the active provider** — the
|
||||
per-provider `*_BASE_URL` env vars (e.g. `OPENAI_BASE_URL`,
|
||||
`OPENROUTER_BASE_URL`) now propagate into the active provider's
|
||||
config entry consistently. Closes a gap where the override was
|
||||
parsed but never applied. Thanks **@reidliu41**.
|
||||
- **WSL2 turn-start timeout** — `TurnStarted` is now emitted before the
|
||||
snapshot step so a slow snapshot on WSL2's `/mnt/*` volumes doesn't
|
||||
push past the runtime watchdog and surface a spurious "engine may
|
||||
have stopped" error. Thanks **@michaeltse321**.
|
||||
- **`/init` auto-adds `.deepseek/` to `.gitignore` (#1326)** when the
|
||||
workspace is a git repo, so workspace-local snapshots, instructions,
|
||||
and pastes don't get accidentally committed. Idempotent on repeated
|
||||
runs. Thanks **@Giggitycountless**.
|
||||
- **MCP tool ordering is deterministic** — discovered tools and the
|
||||
resulting API tool block are now sorted by name so the prompt
|
||||
prefix the model sees is stable across runs, regardless of
|
||||
server-side pagination order. Improves prompt-cache hit rates with
|
||||
multi-server MCP setups. Thanks **@hxy91819**.
|
||||
- **Error cells render as plain text** so env-var names (`API_KEY_FOO`)
|
||||
in error messages keep their underscores instead of being parsed as
|
||||
markdown emphasis. Thanks **@douglarek**.
|
||||
- **`/clear` resets the Todos sidebar (#1258)** — previously `/clear`
|
||||
only reset the Plan panel; the Todos checklist persisted across
|
||||
clears. Thanks **@Giggitycountless**.
|
||||
- **Drag-select past the viewport edge auto-scrolls (#1163, #1255,
|
||||
#1292, #1298)** — when the mouse drag reaches the top or bottom of
|
||||
the transcript area the viewport now scrolls to follow the
|
||||
selection, the way text editors do. **Copy strips every visual-only
|
||||
decoration glyph** — tool-card rails (`╭│╰`), transcript rails
|
||||
(`▏`), reasoning rails (`╎`), tool-status symbols (`·•◦`), and
|
||||
tool-family glyphs no longer leak into clipboard output. Thanks
|
||||
**@Oliver-ZPLiu**.
|
||||
- MCP stdio servers no longer discard stderr. The spawn site now pipes
|
||||
stderr through a bounded ring buffer; when a server crashes
|
||||
mid-session, the transport-closed error includes the captured stderr
|
||||
tail instead of disappearing into `Stdio::null`. Useful for debugging
|
||||
Node/Python MCP servers that fail well after `initialize`.
|
||||
- Mouse capture now defaults on inside Windows Terminal (#1169, #1298,
|
||||
#1331). When `WT_SESSION` is set, in-app text selection is enabled
|
||||
by default and the wheel scrolls the transcript again (rather than
|
||||
the terminal interpreting wheel events as input-history keys).
|
||||
Legacy conhost stays opt-in via `--mouse-capture` or `[tui]
|
||||
mouse_capture = true` to preserve the protections from #878 / #898.
|
||||
Selection now clamps to the transcript region instead of the
|
||||
terminal painting native selection across the sidebar.
|
||||
- The build script now invalidates its cache on `.git/HEAD` changes, so
|
||||
the embedded short-SHA in `deepseek --version` stays current after
|
||||
commits and branch switches without needing `cargo clean`. Both
|
||||
regular checkouts and `git worktree` layouts are handled.
|
||||
- The release-time `changelog_entry_exists_for_current_package_version`
|
||||
gate walks up from the crate manifest to find `CHANGELOG.md` instead
|
||||
of assuming a fixed `../../CHANGELOG.md` layout. The workspace path
|
||||
still resolves; running the suite from a packaged crate skips the
|
||||
gate quietly instead of panicking.
|
||||
|
||||
## [0.8.25] - 2026-05-09
|
||||
|
||||
A stabilization + drift-fixes release. Headline work hardens the
|
||||
|
||||
Generated
+14
-14
@@ -1151,7 +1151,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-agent"
|
||||
version = "0.8.25"
|
||||
version = "0.8.26"
|
||||
dependencies = [
|
||||
"deepseek-config",
|
||||
"serde",
|
||||
@@ -1159,7 +1159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-app-server"
|
||||
version = "0.8.25"
|
||||
version = "0.8.26"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1181,7 +1181,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-config"
|
||||
version = "0.8.25"
|
||||
version = "0.8.26"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-secrets",
|
||||
@@ -1193,7 +1193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-core"
|
||||
version = "0.8.25"
|
||||
version = "0.8.26"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1211,7 +1211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-execpolicy"
|
||||
version = "0.8.25"
|
||||
version = "0.8.26"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-protocol",
|
||||
@@ -1220,7 +1220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-hooks"
|
||||
version = "0.8.25"
|
||||
version = "0.8.26"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1234,7 +1234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-mcp"
|
||||
version = "0.8.25"
|
||||
version = "0.8.26"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -1243,7 +1243,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-protocol"
|
||||
version = "0.8.25"
|
||||
version = "0.8.26"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1251,7 +1251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-secrets"
|
||||
version = "0.8.25"
|
||||
version = "0.8.26"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"keyring",
|
||||
@@ -1264,7 +1264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-state"
|
||||
version = "0.8.25"
|
||||
version = "0.8.26"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1276,7 +1276,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tools"
|
||||
version = "0.8.25"
|
||||
version = "0.8.26"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1289,7 +1289,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui"
|
||||
version = "0.8.25"
|
||||
version = "0.8.26"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -1350,7 +1350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-cli"
|
||||
version = "0.8.25"
|
||||
version = "0.8.26"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1375,7 +1375,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-core"
|
||||
version = "0.8.25"
|
||||
version = "0.8.26"
|
||||
|
||||
[[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.25"
|
||||
version = "0.8.26"
|
||||
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
|
||||
|
||||
@@ -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.25" }
|
||||
deepseek-config = { path = "../config", version = "0.8.26" }
|
||||
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.25" }
|
||||
deepseek-config = { path = "../config", version = "0.8.25" }
|
||||
deepseek-core = { path = "../core", version = "0.8.25" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.25" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.8.25" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.25" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.25" }
|
||||
deepseek-state = { path = "../state", version = "0.8.25" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.25" }
|
||||
deepseek-agent = { path = "../agent", version = "0.8.26" }
|
||||
deepseek-config = { path = "../config", version = "0.8.26" }
|
||||
deepseek-core = { path = "../core", version = "0.8.26" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.26" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.8.26" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.26" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.26" }
|
||||
deepseek-state = { path = "../state", version = "0.8.26" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.26" }
|
||||
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.25" }
|
||||
deepseek-app-server = { path = "../app-server", version = "0.8.25" }
|
||||
deepseek-config = { path = "../config", version = "0.8.25" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.25" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.25" }
|
||||
deepseek-secrets = { path = "../secrets", version = "0.8.25" }
|
||||
deepseek-state = { path = "../state", version = "0.8.25" }
|
||||
deepseek-agent = { path = "../agent", version = "0.8.26" }
|
||||
deepseek-app-server = { path = "../app-server", version = "0.8.26" }
|
||||
deepseek-config = { path = "../config", version = "0.8.26" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.26" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.26" }
|
||||
deepseek-secrets = { path = "../secrets", version = "0.8.26" }
|
||||
deepseek-state = { path = "../state", version = "0.8.26" }
|
||||
chrono.workspace = true
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
+101
-1
@@ -1,8 +1,12 @@
|
||||
use std::{path::PathBuf, process::Command};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=DEEPSEEK_BUILD_SHA");
|
||||
println!("cargo:rerun-if-env-changed=GITHUB_SHA");
|
||||
declare_git_head_rerun();
|
||||
|
||||
let package_version = env!("CARGO_PKG_VERSION");
|
||||
let build_version = build_sha()
|
||||
@@ -12,6 +16,102 @@ fn main() {
|
||||
println!("cargo:rustc-env=DEEPSEEK_BUILD_VERSION={build_version}");
|
||||
}
|
||||
|
||||
/// Tell Cargo to invalidate the cached build script output when `HEAD`
|
||||
/// moves, so the embedded short-SHA stays in sync with the checkout.
|
||||
///
|
||||
/// `.git/HEAD` only changes on branch switches and detached-HEAD moves —
|
||||
/// `git commit` on the current branch updates the underlying ref file
|
||||
/// (loose `refs/heads/<name>`, or `packed-refs` after `git pack-refs`)
|
||||
/// without touching `HEAD` itself. So when `HEAD` is a symbolic ref we
|
||||
/// also watch the resolved target and `packed-refs`. A non-existent
|
||||
/// `rerun-if-changed` path is treated as "always changed" by Cargo, which
|
||||
/// covers the loose→packed transition.
|
||||
fn declare_git_head_rerun() {
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace_root = manifest_dir.join("..").join("..");
|
||||
let git_meta = workspace_root.join(".git");
|
||||
|
||||
let gitdir = if git_meta.is_dir() {
|
||||
git_meta
|
||||
} else if git_meta.is_file() {
|
||||
// Worktree pointer file: watch it directly, then follow `gitdir:`.
|
||||
println!("cargo:rerun-if-changed={}", git_meta.display());
|
||||
let Ok(contents) = std::fs::read_to_string(&git_meta) else {
|
||||
return;
|
||||
};
|
||||
let Some(rest) = contents.lines().find_map(|l| l.strip_prefix("gitdir:")) else {
|
||||
return;
|
||||
};
|
||||
let trimmed = rest.trim();
|
||||
if Path::new(trimmed).is_absolute() {
|
||||
PathBuf::from(trimmed)
|
||||
} else {
|
||||
workspace_root.join(trimmed)
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
let head = gitdir.join("HEAD");
|
||||
println!("cargo:rerun-if-changed={}", head.display());
|
||||
|
||||
if let Ok(contents) = std::fs::read_to_string(&head)
|
||||
&& let Some(target) = parse_symbolic_ref(&contents)
|
||||
{
|
||||
println!("cargo:rerun-if-changed={}", gitdir.join(target).display());
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
gitdir.join("packed-refs").display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// If `.git/HEAD` is a symbolic ref (`ref: refs/heads/...`) return the
|
||||
/// target ref path. Returns `None` for a detached HEAD (raw SHA).
|
||||
fn parse_symbolic_ref(head_contents: &str) -> Option<&str> {
|
||||
head_contents
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.strip_prefix("ref:"))
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_symbolic_ref;
|
||||
|
||||
#[test]
|
||||
fn symbolic_ref_strips_prefix_and_whitespace() {
|
||||
assert_eq!(
|
||||
parse_symbolic_ref("ref: refs/heads/main\n"),
|
||||
Some("refs/heads/main")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbolic_ref_handles_no_trailing_newline() {
|
||||
assert_eq!(
|
||||
parse_symbolic_ref("ref: refs/heads/work/v0.8.26-security"),
|
||||
Some("refs/heads/work/v0.8.26-security")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detached_head_is_not_a_symbolic_ref() {
|
||||
assert_eq!(
|
||||
parse_symbolic_ref("506343f44e48b9c2c8d6b2d3e8e8e8e8e8e8e8e8\n"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_returns_none() {
|
||||
assert_eq!(parse_symbolic_ref(""), None);
|
||||
assert_eq!(parse_symbolic_ref("ref: \n"), None);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_sha() -> Option<String> {
|
||||
env_sha("DEEPSEEK_BUILD_SHA")
|
||||
.or_else(|| env_sha("GITHUB_SHA"))
|
||||
|
||||
@@ -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.25" }
|
||||
deepseek-secrets = { path = "../secrets", version = "0.8.26" }
|
||||
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.25" }
|
||||
deepseek-config = { path = "../config", version = "0.8.25" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.25" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.8.25" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.25" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.25" }
|
||||
deepseek-state = { path = "../state", version = "0.8.25" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.25" }
|
||||
deepseek-agent = { path = "../agent", version = "0.8.26" }
|
||||
deepseek-config = { path = "../config", version = "0.8.26" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.26" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.8.26" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.26" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.26" }
|
||||
deepseek-state = { path = "../state", version = "0.8.26" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.26" }
|
||||
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.25" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.26" }
|
||||
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.25" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.26" }
|
||||
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.25" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.26" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -21,8 +21,8 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
arboard = "3.4"
|
||||
deepseek-secrets = { path = "../secrets", version = "0.8.25" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.25" }
|
||||
deepseek-secrets = { path = "../secrets", version = "0.8.26" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.26" }
|
||||
schemaui = { version = "0.12.0", default-features = false, optional = true }
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1"
|
||||
|
||||
+101
-1
@@ -1,8 +1,12 @@
|
||||
use std::{path::PathBuf, process::Command};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=DEEPSEEK_BUILD_SHA");
|
||||
println!("cargo:rerun-if-env-changed=GITHUB_SHA");
|
||||
declare_git_head_rerun();
|
||||
configure_windows_stack();
|
||||
|
||||
let package_version = env!("CARGO_PKG_VERSION");
|
||||
@@ -13,6 +17,102 @@ fn main() {
|
||||
println!("cargo:rustc-env=DEEPSEEK_BUILD_VERSION={build_version}");
|
||||
}
|
||||
|
||||
/// Tell Cargo to invalidate the cached build script output when `HEAD`
|
||||
/// moves, so the embedded short-SHA stays in sync with the checkout.
|
||||
///
|
||||
/// `.git/HEAD` only changes on branch switches and detached-HEAD moves —
|
||||
/// `git commit` on the current branch updates the underlying ref file
|
||||
/// (loose `refs/heads/<name>`, or `packed-refs` after `git pack-refs`)
|
||||
/// without touching `HEAD` itself. So when `HEAD` is a symbolic ref we
|
||||
/// also watch the resolved target and `packed-refs`. A non-existent
|
||||
/// `rerun-if-changed` path is treated as "always changed" by Cargo, which
|
||||
/// covers the loose→packed transition.
|
||||
fn declare_git_head_rerun() {
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace_root = manifest_dir.join("..").join("..");
|
||||
let git_meta = workspace_root.join(".git");
|
||||
|
||||
let gitdir = if git_meta.is_dir() {
|
||||
git_meta
|
||||
} else if git_meta.is_file() {
|
||||
// Worktree pointer file: watch it directly, then follow `gitdir:`.
|
||||
println!("cargo:rerun-if-changed={}", git_meta.display());
|
||||
let Ok(contents) = std::fs::read_to_string(&git_meta) else {
|
||||
return;
|
||||
};
|
||||
let Some(rest) = contents.lines().find_map(|l| l.strip_prefix("gitdir:")) else {
|
||||
return;
|
||||
};
|
||||
let trimmed = rest.trim();
|
||||
if Path::new(trimmed).is_absolute() {
|
||||
PathBuf::from(trimmed)
|
||||
} else {
|
||||
workspace_root.join(trimmed)
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
let head = gitdir.join("HEAD");
|
||||
println!("cargo:rerun-if-changed={}", head.display());
|
||||
|
||||
if let Ok(contents) = std::fs::read_to_string(&head)
|
||||
&& let Some(target) = parse_symbolic_ref(&contents)
|
||||
{
|
||||
println!("cargo:rerun-if-changed={}", gitdir.join(target).display());
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
gitdir.join("packed-refs").display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// If `.git/HEAD` is a symbolic ref (`ref: refs/heads/...`) return the
|
||||
/// target ref path. Returns `None` for a detached HEAD (raw SHA).
|
||||
fn parse_symbolic_ref(head_contents: &str) -> Option<&str> {
|
||||
head_contents
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.strip_prefix("ref:"))
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_symbolic_ref;
|
||||
|
||||
#[test]
|
||||
fn symbolic_ref_strips_prefix_and_whitespace() {
|
||||
assert_eq!(
|
||||
parse_symbolic_ref("ref: refs/heads/main\n"),
|
||||
Some("refs/heads/main")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbolic_ref_handles_no_trailing_newline() {
|
||||
assert_eq!(
|
||||
parse_symbolic_ref("ref: refs/heads/work/v0.8.26-security"),
|
||||
Some("refs/heads/work/v0.8.26-security")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detached_head_is_not_a_symbolic_ref() {
|
||||
assert_eq!(
|
||||
parse_symbolic_ref("506343f44e48b9c2c8d6b2d3e8e8e8e8e8e8e8e8\n"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_returns_none() {
|
||||
assert_eq!(parse_symbolic_ref(""), None);
|
||||
assert_eq!(parse_symbolic_ref("ref: \n"), None);
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_windows_stack() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
|
||||
@@ -346,9 +346,15 @@ fn validate_base_url_security(base_url: &str) -> Result<()> {
|
||||
|
||||
if base_url.starts_with("http://") {
|
||||
anyhow::bail!(
|
||||
"Refusing insecure base URL '{}'. Use HTTPS or set {}=1 to override for trusted environments.",
|
||||
base_url,
|
||||
ALLOW_INSECURE_HTTP_ENV
|
||||
"Refusing insecure base URL '{base_url}'.\n\
|
||||
\n\
|
||||
Loopback hosts (localhost, 127.0.0.1, [::1]) are auto-allowed.\n\
|
||||
For other trusted local hosts (LAN, llama.cpp on a private IP, etc.)\n\
|
||||
set the env var `{env}=1` in the shell that runs deepseek and re-run.\n\
|
||||
\n\
|
||||
Example: `{env}=1 deepseek` (note the underscores).",
|
||||
base_url = base_url,
|
||||
env = ALLOW_INSECURE_HTTP_ENV,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! /init command - Generate AGENTS.md for project
|
||||
|
||||
use std::fmt::Write;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::tui::app::App;
|
||||
@@ -11,6 +12,9 @@ use super::CommandResult;
|
||||
pub fn init(app: &mut App) -> CommandResult {
|
||||
let workspace = &app.workspace;
|
||||
|
||||
// Ensure .deepseek/ is gitignored if we're inside a git repo.
|
||||
ensure_deepseek_gitignored(workspace);
|
||||
|
||||
// Check if AGENTS.md already exists
|
||||
let agents_path = workspace.join("AGENTS.md");
|
||||
if agents_path.exists() {
|
||||
@@ -33,6 +37,59 @@ pub fn init(app: &mut App) -> CommandResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// If `workspace` is inside a git repository, ensure `.deepseek/` is listed
|
||||
/// in the nearest `.gitignore` so that snapshots, instructions, and other
|
||||
/// workspace-local state are not accidentally committed.
|
||||
fn ensure_deepseek_gitignored(workspace: &Path) {
|
||||
// Only act if this workspace is a git repo.
|
||||
if !workspace.join(".git").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let gitignore = workspace.join(".gitignore");
|
||||
let entry = ".deepseek/";
|
||||
|
||||
// Read existing contents (if any) and check whether the entry is already present.
|
||||
// Check both with and without trailing slash to catch variants like
|
||||
// ".deepseek" and ".deepseek/".
|
||||
if let Ok(existing) = std::fs::read_to_string(&gitignore) {
|
||||
let entry_no_slash = entry.trim_end_matches('/');
|
||||
if existing.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed == entry || trimmed == entry_no_slash
|
||||
}) {
|
||||
return; // already ignored
|
||||
}
|
||||
}
|
||||
|
||||
// Append the entry. If .gitignore doesn't exist yet, create it with a header.
|
||||
// Ensure there's a trailing newline before our entry to avoid joining with
|
||||
// a previous unterminated line.
|
||||
use std::io::Write;
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&gitignore)
|
||||
{
|
||||
// If the file is non-empty and doesn't end with a newline, add one first.
|
||||
if let Ok(meta) = file.metadata()
|
||||
&& meta.len() > 0
|
||||
{
|
||||
// Read last byte to check for trailing newline.
|
||||
if let Ok(mut f) = std::fs::File::open(&gitignore) {
|
||||
use std::io::Seek;
|
||||
if f.seek(std::io::SeekFrom::End(-1)).is_ok() {
|
||||
let mut buf = [0u8; 1];
|
||||
if f.read_exact(&mut buf).is_ok() && buf[0] != b'\n' {
|
||||
let _ = writeln!(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = writeln!(file, "{entry}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate project documentation based on detected project type
|
||||
fn generate_project_doc(workspace: &Path) -> String {
|
||||
let mut doc = String::new();
|
||||
@@ -281,4 +338,83 @@ version = "1.0.0"
|
||||
let cargo = "[package]\nversion = \"1.0.0\"";
|
||||
assert_eq!(extract_cargo_name(cargo), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_deepseek_gitignored_creates_gitignore() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
// Simulate a git repo.
|
||||
std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap();
|
||||
|
||||
ensure_deepseek_gitignored(tmpdir.path());
|
||||
|
||||
let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap();
|
||||
assert!(content.contains(".deepseek/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_deepseek_gitignored_appends_to_existing() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap();
|
||||
std::fs::write(tmpdir.path().join(".gitignore"), "target/\n").unwrap();
|
||||
|
||||
ensure_deepseek_gitignored(tmpdir.path());
|
||||
|
||||
let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap();
|
||||
assert!(content.contains("target/"));
|
||||
assert!(content.contains(".deepseek/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_deepseek_gitignored_idempotent() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap();
|
||||
|
||||
ensure_deepseek_gitignored(tmpdir.path());
|
||||
ensure_deepseek_gitignored(tmpdir.path());
|
||||
|
||||
let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap();
|
||||
assert_eq!(content.matches(".deepseek/").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_deepseek_gitignored_skips_non_git_repo() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
// No .git directory — not a git repo.
|
||||
|
||||
ensure_deepseek_gitignored(tmpdir.path());
|
||||
|
||||
assert!(!tmpdir.path().join(".gitignore").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_deepseek_gitignored_handles_no_trailing_newline() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap();
|
||||
// Write a file that does NOT end with a newline.
|
||||
std::fs::write(tmpdir.path().join(".gitignore"), "target/").unwrap();
|
||||
|
||||
ensure_deepseek_gitignored(tmpdir.path());
|
||||
|
||||
let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap();
|
||||
// Must have both entries on separate lines.
|
||||
assert!(content.contains("target/"));
|
||||
assert!(content.contains(".deepseek/"));
|
||||
// The entries should be on different lines.
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert!(lines.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_deepseek_gitignored_detects_variant_without_slash() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap();
|
||||
// Write .deepseek without trailing slash.
|
||||
std::fs::write(tmpdir.path().join(".gitignore"), ".deepseek\n").unwrap();
|
||||
|
||||
ensure_deepseek_gitignored(tmpdir.path());
|
||||
|
||||
let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap();
|
||||
// Should NOT add a duplicate entry.
|
||||
assert_eq!(content.matches(".deepseek").count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,10 @@ pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
|
||||
let mut output = format!("Available skills ({}):\n", registry.len());
|
||||
output.push_str("─────────────────────────────\n");
|
||||
for skill in registry.list() {
|
||||
for (idx, skill) in registry.list().iter().enumerate() {
|
||||
if idx > 0 {
|
||||
output.push('\n');
|
||||
}
|
||||
let _ = writeln!(output, " /{} - {}", skill.name, skill.description);
|
||||
}
|
||||
let _ = write!(
|
||||
@@ -558,6 +561,35 @@ mod tests {
|
||||
assert!(msg.contains("/test-skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_skills_separates_entries_with_blank_line() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let _home = IsolatedHome::new(&tmpdir);
|
||||
create_skill_dir(
|
||||
&tmpdir,
|
||||
"alpha-skill",
|
||||
"---\nname: alpha-skill\ndescription: First skill\n---\nDo alpha work",
|
||||
);
|
||||
create_skill_dir(
|
||||
&tmpdir,
|
||||
"beta-skill",
|
||||
"---\nname: beta-skill\ndescription: Second skill\n---\nDo beta work",
|
||||
);
|
||||
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = list_skills(&mut app, None);
|
||||
let msg = result.message.unwrap();
|
||||
let alpha = msg.find("/alpha-skill").expect("alpha skill should render");
|
||||
let beta = msg.find("/beta-skill").expect("beta skill should render");
|
||||
let (first, second) = if alpha < beta {
|
||||
(alpha, beta)
|
||||
} else {
|
||||
(beta, alpha)
|
||||
};
|
||||
|
||||
assert!(msg[first..second].contains("\n\n"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_skills_merges_workspace_and_configured_dirs() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
|
||||
+144
-4
@@ -1078,9 +1078,60 @@ impl Config {
|
||||
apply_requirements(&mut config)?;
|
||||
normalize_model_config(&mut config);
|
||||
config.validate()?;
|
||||
config.warn_on_misplaced_root_base_url();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Surface a one-line warning when the user has set the legacy root
|
||||
/// `base_url` field but their active provider is not DeepSeek (the only
|
||||
/// provider that actually reads that field, plus an NvidiaNim back-compat
|
||||
/// sniff). Common confusion: users add `base_url = "..."` at the top of
|
||||
/// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers
|
||||
/// and wonder why it's silently ignored (#1308).
|
||||
fn warn_on_misplaced_root_base_url(&self) {
|
||||
let Some(root_base) = self.base_url.as_deref().map(str::trim) else {
|
||||
return;
|
||||
};
|
||||
if root_base.is_empty() {
|
||||
return;
|
||||
}
|
||||
let provider = self.api_provider();
|
||||
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
|
||||
return;
|
||||
}
|
||||
if matches!(provider, ApiProvider::NvidiaNim)
|
||||
&& root_base.contains("integrate.api.nvidia.com")
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Only warn if the per-provider table doesn't have an explicit
|
||||
// `base_url`, because if it does, the per-provider one wins and the
|
||||
// root field is just dead config — no behavior surprise.
|
||||
let has_provider_base = self
|
||||
.provider_config_for(provider)
|
||||
.and_then(|p| p.base_url.as_deref().map(str::trim))
|
||||
.is_some_and(|s| !s.is_empty());
|
||||
if has_provider_base {
|
||||
return;
|
||||
}
|
||||
let table = match provider {
|
||||
ApiProvider::Openai => "providers.openai",
|
||||
ApiProvider::Openrouter => "providers.openrouter",
|
||||
ApiProvider::Novita => "providers.novita",
|
||||
ApiProvider::Fireworks => "providers.fireworks",
|
||||
ApiProvider::Sglang => "providers.sglang",
|
||||
ApiProvider::Vllm => "providers.vllm",
|
||||
ApiProvider::Ollama => "providers.ollama",
|
||||
ApiProvider::NvidiaNim => "providers.nvidia_nim",
|
||||
ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,
|
||||
};
|
||||
tracing::warn!(
|
||||
"Top-level `base_url = \"{root_base}\"` is ignored for the {provider:?} provider. \
|
||||
Move it under `[{table}]` (e.g. `[{table}]\\nbase_url = \"...\"`) \
|
||||
or set the corresponding `*_BASE_URL` env var. (#1308)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Validate that critical config fields are present.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if let Some(provider) = self.provider.as_deref()
|
||||
@@ -1492,10 +1543,11 @@ impl Config {
|
||||
self.context.project_pack.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Return whether shell execution is allowed.
|
||||
/// Return whether shell execution is allowed. Defaults to `false`: shell
|
||||
/// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).
|
||||
#[must_use]
|
||||
pub fn allow_shell(&self) -> bool {
|
||||
self.allow_shell.unwrap_or(true)
|
||||
self.allow_shell.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Return the maximum number of concurrent sub-agents.
|
||||
@@ -1877,6 +1929,9 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_BASE_URL") {
|
||||
match config.api_provider() {
|
||||
ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
|
||||
config.base_url = Some(value);
|
||||
}
|
||||
ApiProvider::NvidiaNim => {
|
||||
config
|
||||
.providers
|
||||
@@ -1891,8 +1946,47 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
.openai
|
||||
.base_url = Some(value);
|
||||
}
|
||||
_ => {
|
||||
config.base_url = Some(value);
|
||||
ApiProvider::Openrouter => {
|
||||
config
|
||||
.providers
|
||||
.get_or_insert_with(ProvidersConfig::default)
|
||||
.openrouter
|
||||
.base_url = Some(value);
|
||||
}
|
||||
ApiProvider::Novita => {
|
||||
config
|
||||
.providers
|
||||
.get_or_insert_with(ProvidersConfig::default)
|
||||
.novita
|
||||
.base_url = Some(value);
|
||||
}
|
||||
ApiProvider::Fireworks => {
|
||||
config
|
||||
.providers
|
||||
.get_or_insert_with(ProvidersConfig::default)
|
||||
.fireworks
|
||||
.base_url = Some(value);
|
||||
}
|
||||
ApiProvider::Sglang => {
|
||||
config
|
||||
.providers
|
||||
.get_or_insert_with(ProvidersConfig::default)
|
||||
.sglang
|
||||
.base_url = Some(value);
|
||||
}
|
||||
ApiProvider::Vllm => {
|
||||
config
|
||||
.providers
|
||||
.get_or_insert_with(ProvidersConfig::default)
|
||||
.vllm
|
||||
.base_url = Some(value);
|
||||
}
|
||||
ApiProvider::Ollama => {
|
||||
config
|
||||
.providers
|
||||
.get_or_insert_with(ProvidersConfig::default)
|
||||
.ollama
|
||||
.base_url = Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3120,6 +3214,17 @@ mod tests {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.
|
||||
#[test]
|
||||
fn allow_shell_defaults_to_false_when_unset() {
|
||||
let config = Config::default();
|
||||
assert_eq!(config.allow_shell, None, "default Config has no opt-in set");
|
||||
assert!(
|
||||
!config.allow_shell(),
|
||||
"Config::allow_shell() must default to false when no opt-in is recorded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {
|
||||
let policy: NetworkPolicyToml = toml::from_str(
|
||||
@@ -4817,6 +4922,41 @@ model = "qwen2.5-coder:7b"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-tui-self-hosted-base-url-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root)?;
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_PROVIDER", "ollama");
|
||||
env::set_var("DEEPSEEK_BASE_URL", "http://ollama.remote:11434/v1");
|
||||
}
|
||||
let config = Config::load(None, None)?;
|
||||
assert_eq!(config.api_provider(), ApiProvider::Ollama);
|
||||
assert_eq!(config.deepseek_base_url(), "http://ollama.remote:11434/v1");
|
||||
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_PROVIDER", "vllm");
|
||||
env::set_var("DEEPSEEK_BASE_URL", "http://vllm.remote:8000/v1");
|
||||
}
|
||||
let config = Config::load(None, None)?;
|
||||
assert_eq!(config.api_provider(), ApiProvider::Vllm);
|
||||
assert_eq!(config.deepseek_base_url(), "http://vllm.remote:8000/v1");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_env_overrides_base_url_and_model() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
|
||||
@@ -895,6 +895,16 @@ impl Engine {
|
||||
self.turn_counter = self.turn_counter.saturating_add(1);
|
||||
self.capacity_controller.mark_turn_start(self.turn_counter);
|
||||
|
||||
// Emit turn started event IMMEDIATELY so the UI knows the turn is
|
||||
// active. The snapshot below can take 30+ seconds on slow filesystems
|
||||
// (e.g. WSL2 /mnt/c) and must not delay the TurnStarted event.
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::TurnStarted {
|
||||
turn_id: turn.id.clone(),
|
||||
})
|
||||
.await;
|
||||
|
||||
// Snapshot the workspace BEFORE we touch a single tool. Run the git
|
||||
// work on the blocking pool so the async runtime stays responsive;
|
||||
// failure is non-fatal (the helper logs at WARN).
|
||||
@@ -905,14 +915,6 @@ impl Engine {
|
||||
.await;
|
||||
}
|
||||
|
||||
// Emit turn started event
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::TurnStarted {
|
||||
turn_id: turn.id.clone(),
|
||||
})
|
||||
.await;
|
||||
|
||||
// A new turn means any leftover retry banner (success cleared
|
||||
// it, failure pinned it) is no longer relevant — reset to idle
|
||||
// so the footer doesn't display a stale failure row across
|
||||
|
||||
+74
-18
@@ -3644,7 +3644,14 @@ fn should_use_alt_screen(_cli: &Cli, _config: &Config) -> bool {
|
||||
|
||||
fn should_use_mouse_capture(cli: &Cli, config: &Config, use_alt_screen: bool) -> bool {
|
||||
let terminal_emulator = std::env::var("TERMINAL_EMULATOR").ok();
|
||||
should_use_mouse_capture_with(cli, config, use_alt_screen, terminal_emulator.as_deref())
|
||||
let wt_session = std::env::var("WT_SESSION").ok().filter(|s| !s.is_empty());
|
||||
should_use_mouse_capture_with(
|
||||
cli,
|
||||
config,
|
||||
use_alt_screen,
|
||||
terminal_emulator.as_deref(),
|
||||
wt_session.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn should_use_mouse_capture_with(
|
||||
@@ -3652,6 +3659,7 @@ fn should_use_mouse_capture_with(
|
||||
config: &Config,
|
||||
use_alt_screen: bool,
|
||||
terminal_emulator: Option<&str>,
|
||||
wt_session: Option<&str>,
|
||||
) -> bool {
|
||||
if !use_alt_screen || cli.no_mouse_capture {
|
||||
return false;
|
||||
@@ -3663,21 +3671,28 @@ fn should_use_mouse_capture_with(
|
||||
.tui
|
||||
.as_ref()
|
||||
.and_then(|tui| tui.mouse_capture)
|
||||
.unwrap_or_else(|| default_mouse_capture_enabled(terminal_emulator))
|
||||
.unwrap_or_else(|| default_mouse_capture_enabled(terminal_emulator, wt_session))
|
||||
}
|
||||
|
||||
/// Whether to enable terminal mouse capture by default for this platform/host.
|
||||
///
|
||||
/// Returns `false` on Windows (legacy console mouse-mode reporting is flaky;
|
||||
/// `--mouse-capture` opts in) and on JetBrains' JediTerm, which advertises
|
||||
/// mouse support but delivers SGR mouse-event escape sequences as raw text
|
||||
/// in the input stream — visible to users as garbled characters in the
|
||||
/// composer when they move the mouse over the TUI (#878, #898). The user
|
||||
/// can still opt back in with `[tui] mouse_capture = true` in
|
||||
/// On Windows the default depends on the host: Windows Terminal (which sets
|
||||
/// `WT_SESSION`) handles mouse-mode reporting cleanly, so default-on there
|
||||
/// gives users in-app text selection and keeps the application's selection
|
||||
/// clamped to the transcript area (#1169). Legacy conhost stays default-off
|
||||
/// because its mouse-mode reporting can leak SGR escape sequences as raw
|
||||
/// text into the composer (#878 / #898).
|
||||
///
|
||||
/// Off elsewhere only for JetBrains' JediTerm, which advertises mouse
|
||||
/// support but forwards the same SGR escape sequences as raw input. The
|
||||
/// user can still opt back in with `[tui] mouse_capture = true` in
|
||||
/// `~/.deepseek/config.toml` or `--mouse-capture`.
|
||||
fn default_mouse_capture_enabled(terminal_emulator: Option<&str>) -> bool {
|
||||
fn default_mouse_capture_enabled(
|
||||
terminal_emulator: Option<&str>,
|
||||
wt_session: Option<&str>,
|
||||
) -> bool {
|
||||
if cfg!(windows) {
|
||||
return false;
|
||||
return wt_session.is_some();
|
||||
}
|
||||
if matches!(terminal_emulator, Some(t) if t.eq_ignore_ascii_case("JetBrains-JediTerm")) {
|
||||
return false;
|
||||
@@ -4641,16 +4656,43 @@ mod terminal_mode_tests {
|
||||
let cli = parse_cli(&["deepseek"]);
|
||||
let config = Config::default();
|
||||
|
||||
assert!(should_use_mouse_capture_with(&cli, &config, true, None));
|
||||
assert!(should_use_mouse_capture_with(
|
||||
&cli, &config, true, None, None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn mouse_capture_defaults_off_on_windows_when_alternate_screen_is_active() {
|
||||
fn mouse_capture_defaults_off_on_legacy_windows_console() {
|
||||
// Legacy conhost (no `WT_SESSION`) keeps the v0.8.x default-off
|
||||
// behavior: mouse-mode reporting on legacy console can leak SGR
|
||||
// escapes into the composer.
|
||||
let cli = parse_cli(&["deepseek"]);
|
||||
let config = Config::default();
|
||||
|
||||
assert!(!should_use_mouse_capture_with(&cli, &config, true, None));
|
||||
assert!(!should_use_mouse_capture_with(
|
||||
&cli, &config, true, None, None
|
||||
));
|
||||
}
|
||||
|
||||
// #1169: Windows Terminal sets `WT_SESSION` and handles mouse-mode
|
||||
// reporting cleanly, so default-on there gives users in-app text
|
||||
// selection (and the side-effect of clamping selection to the
|
||||
// transcript region instead of the terminal painting across the
|
||||
// sidebar via native selection).
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn mouse_capture_defaults_on_in_windows_terminal() {
|
||||
let cli = parse_cli(&["deepseek"]);
|
||||
let config = Config::default();
|
||||
|
||||
assert!(should_use_mouse_capture_with(
|
||||
&cli,
|
||||
&config,
|
||||
true,
|
||||
None,
|
||||
Some("{a3a3b3a8-aa00-0000-0000-000000000000}"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4658,7 +4700,9 @@ mod terminal_mode_tests {
|
||||
let cli = parse_cli(&["deepseek", "--no-mouse-capture"]);
|
||||
let config = Config::default();
|
||||
|
||||
assert!(!should_use_mouse_capture_with(&cli, &config, true, None));
|
||||
assert!(!should_use_mouse_capture_with(
|
||||
&cli, &config, true, None, None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4676,7 +4720,9 @@ mod terminal_mode_tests {
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
assert!(!should_use_mouse_capture_with(&cli, &config, true, None));
|
||||
assert!(!should_use_mouse_capture_with(
|
||||
&cli, &config, true, None, None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4684,7 +4730,9 @@ mod terminal_mode_tests {
|
||||
let cli = parse_cli(&["deepseek", "--mouse-capture"]);
|
||||
let config = Config::default();
|
||||
|
||||
assert!(should_use_mouse_capture_with(&cli, &config, true, None));
|
||||
assert!(should_use_mouse_capture_with(
|
||||
&cli, &config, true, None, None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4702,7 +4750,9 @@ mod terminal_mode_tests {
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
assert!(should_use_mouse_capture_with(&cli, &config, true, None));
|
||||
assert!(should_use_mouse_capture_with(
|
||||
&cli, &config, true, None, None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4710,7 +4760,9 @@ mod terminal_mode_tests {
|
||||
let cli = parse_cli(&["deepseek", "--mouse-capture"]);
|
||||
let config = Config::default();
|
||||
|
||||
assert!(!should_use_mouse_capture_with(&cli, &config, false, None));
|
||||
assert!(!should_use_mouse_capture_with(
|
||||
&cli, &config, false, None, None
|
||||
));
|
||||
}
|
||||
|
||||
// Issue #878 / #898: JetBrains JediTerm advertises mouse support but
|
||||
@@ -4730,6 +4782,7 @@ mod terminal_mode_tests {
|
||||
&config,
|
||||
true,
|
||||
Some("JetBrains-JediTerm"),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -4745,6 +4798,7 @@ mod terminal_mode_tests {
|
||||
&config,
|
||||
true,
|
||||
Some("jetbrains-jediterm"),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -4758,6 +4812,7 @@ mod terminal_mode_tests {
|
||||
&config,
|
||||
true,
|
||||
Some("JetBrains-JediTerm"),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -4781,6 +4836,7 @@ mod terminal_mode_tests {
|
||||
&config,
|
||||
true,
|
||||
Some("JetBrains-JediTerm"),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+196
-2
@@ -8,6 +8,7 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::fs;
|
||||
use std::path::{Component, Path};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -17,6 +18,7 @@ use reqwest::header::{ACCEPT, CONTENT_TYPE};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
|
||||
use tokio::process::{Child, ChildStdin, ChildStdout};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
use crate::child_env;
|
||||
use crate::network_policy::{Decision, NetworkPolicyDecider, host_from_url};
|
||||
@@ -288,6 +290,10 @@ pub struct StdioTransport {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
reader: tokio::io::BufReader<ChildStdout>,
|
||||
/// Tail of stderr lines from the spawned MCP server. A background task
|
||||
/// drains the child's stderr into this buffer so a mid-run crash leaves
|
||||
/// some context behind instead of `Stdio::null` swallowing it.
|
||||
stderr_tail: Arc<StderrTail>,
|
||||
}
|
||||
|
||||
/// How long `StdioTransport::shutdown` waits for the child to exit on SIGTERM
|
||||
@@ -296,6 +302,54 @@ pub struct StdioTransport {
|
||||
/// a few hundred ms.
|
||||
const STDIO_SHUTDOWN_GRACE: Duration = Duration::from_millis(2_000);
|
||||
|
||||
/// How many lines of MCP-server stderr to keep around for crash diagnostics.
|
||||
/// Bounded so a chatty server can't grow this without limit; large enough to
|
||||
/// catch typical Node/Python startup or panic output.
|
||||
const STDERR_TAIL_CAPACITY: usize = 64;
|
||||
|
||||
/// Bounded ring buffer for the most recent stderr lines from a spawned MCP
|
||||
/// server. Used by `StdioTransport` to surface server-side context when the
|
||||
/// transport read side fails (server crashed, exited early, etc).
|
||||
#[derive(Default)]
|
||||
pub struct StderrTail {
|
||||
lines: TokioMutex<VecDeque<String>>,
|
||||
}
|
||||
|
||||
impl StderrTail {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
lines: TokioMutex::new(VecDeque::with_capacity(STDERR_TAIL_CAPACITY)),
|
||||
})
|
||||
}
|
||||
|
||||
async fn push(&self, line: String) {
|
||||
let mut buf = self.lines.lock().await;
|
||||
if buf.len() >= STDERR_TAIL_CAPACITY {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(line);
|
||||
}
|
||||
|
||||
async fn snapshot(&self) -> Vec<String> {
|
||||
self.lines.lock().await.iter().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format the captured stderr tail for inclusion in an error message. Empty
|
||||
/// tails return `None` so the caller can fall back to its original message.
|
||||
async fn format_stderr_context(tail: &StderrTail) -> Option<String> {
|
||||
let lines = tail.snapshot().await;
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"MCP server stderr (last {} line{}):\n{}",
|
||||
lines.len(),
|
||||
if lines.len() == 1 { "" } else { "s" },
|
||||
lines.join("\n"),
|
||||
))
|
||||
}
|
||||
|
||||
/// Best-effort SIGTERM. On Unix uses `libc::kill`; on Windows there's no
|
||||
/// equivalent so we let `kill_on_drop` (TerminateProcess) handle it via the
|
||||
/// subsequent Drop. Returns whether a signal was actually sent.
|
||||
@@ -334,8 +388,19 @@ impl McpTransport for StdioTransport {
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
let bytes = self.reader.read_line(&mut line).await?;
|
||||
let bytes = match self.reader.read_line(&mut line).await {
|
||||
Ok(b) => b,
|
||||
Err(err) => {
|
||||
if let Some(stderr) = format_stderr_context(&self.stderr_tail).await {
|
||||
anyhow::bail!("Stdio transport read error: {err}\n{stderr}");
|
||||
}
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
if bytes == 0 {
|
||||
if let Some(stderr) = format_stderr_context(&self.stderr_tail).await {
|
||||
anyhow::bail!("Stdio transport closed\n{stderr}");
|
||||
}
|
||||
anyhow::bail!("Stdio transport closed");
|
||||
}
|
||||
|
||||
@@ -883,7 +948,7 @@ impl McpConnection {
|
||||
cmd.args(&config.args)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
// MCP stdio servers are user-configured integrations. Use the
|
||||
@@ -903,11 +968,28 @@ impl McpConnection {
|
||||
|
||||
let stdin = child.stdin.take().context("Failed to get MCP stdin")?;
|
||||
let stdout = child.stdout.take().context("Failed to get MCP stdout")?;
|
||||
let stderr = child.stderr.take().context("Failed to get MCP stderr")?;
|
||||
|
||||
// Drain stderr into a bounded ring buffer so a crash mid-run
|
||||
// leaves diagnostic breadcrumbs instead of disappearing into
|
||||
// `Stdio::null`. The task exits naturally when the child closes
|
||||
// its stderr (kill_on_drop / exit / explicit shutdown).
|
||||
let stderr_tail = StderrTail::new();
|
||||
{
|
||||
let tail = Arc::clone(&stderr_tail);
|
||||
tokio::spawn(async move {
|
||||
let mut lines = tokio::io::BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tail.push(line).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Box::new(StdioTransport {
|
||||
child,
|
||||
stdin,
|
||||
reader: tokio::io::BufReader::new(stdout),
|
||||
stderr_tail,
|
||||
})
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
@@ -1026,6 +1108,10 @@ impl McpConnection {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Sort by tool name so the order the model sees doesn't depend on
|
||||
// server-side pagination ordering — keeps the prompt prefix stable
|
||||
// for cache-hit purposes (#1319).
|
||||
self.tools.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1459,6 +1545,9 @@ impl McpPool {
|
||||
tools.push((format!("mcp_{}_{}", server, tool.name), tool));
|
||||
}
|
||||
}
|
||||
// Sort by prefixed name so iteration order across servers is
|
||||
// deterministic for prefix-cache stability (#1319).
|
||||
tools.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
tools
|
||||
}
|
||||
|
||||
@@ -1734,6 +1823,9 @@ impl McpPool {
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by name for prefix-cache stability — the tool block sent to
|
||||
// the model needs to be deterministic across runs (#1319).
|
||||
api_tools.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
api_tools
|
||||
}
|
||||
|
||||
@@ -2538,6 +2630,49 @@ mod tests {
|
||||
assert!(pool.all_tools().is_empty());
|
||||
}
|
||||
|
||||
/// #1319: discovered tools must be sorted by name so the prompt prefix
|
||||
/// is stable across runs (cache-hit stability), even when the server
|
||||
/// returns them in arbitrary or paginated order.
|
||||
#[tokio::test]
|
||||
async fn discover_tools_sorts_by_name_for_cache_stability() {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
let transport = ScriptedValueTransport {
|
||||
sent: Arc::clone(&sent),
|
||||
responses: VecDeque::from([
|
||||
json_frame(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"tools": [
|
||||
{ "name": "zeta", "inputSchema": {} },
|
||||
{ "name": "alpha", "inputSchema": {} }
|
||||
],
|
||||
"nextCursor": "page-2"
|
||||
}
|
||||
})),
|
||||
json_frame(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"result": {
|
||||
"tools": [
|
||||
{ "name": "mu", "inputSchema": {} },
|
||||
{ "name": "beta", "inputSchema": {} }
|
||||
]
|
||||
}
|
||||
})),
|
||||
]),
|
||||
};
|
||||
let mut conn = test_connection(Box::new(transport));
|
||||
conn.discover_tools().await.expect("discover");
|
||||
|
||||
let names: Vec<&str> = conn.tools.iter().map(|t| t.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["alpha", "beta", "mu", "zeta"],
|
||||
"tools must be sorted by name regardless of server order or pagination"
|
||||
);
|
||||
}
|
||||
|
||||
/// #1244: when an MCP stdio server fails to spawn, the underlying OS
|
||||
/// error (e.g. ENOENT for a missing binary) must reach the user via the
|
||||
/// snapshot.error string. Regression test for `err.to_string()` dropping
|
||||
@@ -2782,6 +2917,7 @@ mod tests {
|
||||
child,
|
||||
stdin,
|
||||
reader: tokio::io::BufReader::new(stdout),
|
||||
stderr_tail: StderrTail::new(),
|
||||
};
|
||||
|
||||
// shutdown() should send SIGTERM and complete within the grace window.
|
||||
@@ -2806,6 +2942,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Mid-run MCP server crash: the v0.8.x spawn path used `Stdio::null` for
|
||||
/// stderr, so a server that died with a useful stderr message left the
|
||||
/// caller with only "Stdio transport closed". Now stderr is piped into a
|
||||
/// bounded ring buffer and surfaced when the read side fails.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn stdio_transport_recv_error_includes_stderr_tail() {
|
||||
use tokio::process::Command as TokioCommand;
|
||||
|
||||
let mut cmd = TokioCommand::new("sh");
|
||||
cmd.arg("-c")
|
||||
.arg("echo 'mcp-server: failed to load plugin' 1>&2; exit 1")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
let mut child = cmd.spawn().expect("spawn sh");
|
||||
let stdin = child.stdin.take().expect("stdin");
|
||||
let stdout = child.stdout.take().expect("stdout");
|
||||
let stderr = child.stderr.take().expect("stderr");
|
||||
|
||||
let stderr_tail = StderrTail::new();
|
||||
{
|
||||
let tail = Arc::clone(&stderr_tail);
|
||||
tokio::spawn(async move {
|
||||
let mut lines = tokio::io::BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tail.push(line).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut transport = StdioTransport {
|
||||
child,
|
||||
stdin,
|
||||
reader: tokio::io::BufReader::new(stdout),
|
||||
stderr_tail,
|
||||
};
|
||||
|
||||
// Give the subprocess time to write its stderr line and exit.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
let err = transport
|
||||
.recv()
|
||||
.await
|
||||
.expect_err("expected transport closed error");
|
||||
let err_str = format!("{err}");
|
||||
assert!(
|
||||
err_str.contains("Stdio transport closed"),
|
||||
"missing closed marker in: {err_str}"
|
||||
);
|
||||
assert!(
|
||||
err_str.contains("mcp-server: failed to load plugin"),
|
||||
"stderr context missing from error: {err_str}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_connect_waits_for_endpoint_before_first_send() {
|
||||
use std::sync::{
|
||||
|
||||
@@ -682,13 +682,32 @@ mod tests {
|
||||
/// `## [X.Y.Z]` heading matching the current `CARGO_PKG_VERSION`. No
|
||||
/// hardcoded version string — the test self-updates with the workspace
|
||||
/// version bump and only fires when the CHANGELOG is the missing piece.
|
||||
///
|
||||
/// Walks up from `CARGO_MANIFEST_DIR` to find `CHANGELOG.md` instead of
|
||||
/// assuming a fixed `../../CHANGELOG.md` layout. The workspace root is
|
||||
/// the common case, but the walk also tolerates deeper crate layouts and
|
||||
/// the packaged-crate case (where the workspace root has been stripped
|
||||
/// out): if no `CHANGELOG.md` is reachable, the gate quietly skips
|
||||
/// rather than panicking, so consumers running the suite outside the
|
||||
/// workspace checkout don't see a spurious failure.
|
||||
#[test]
|
||||
fn changelog_entry_exists_for_current_package_version() {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let changelog_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("CHANGELOG.md");
|
||||
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let Some(changelog_path) = manifest_dir
|
||||
.ancestors()
|
||||
.map(|dir| dir.join("CHANGELOG.md"))
|
||||
.find(|candidate| candidate.is_file())
|
||||
else {
|
||||
eprintln!(
|
||||
"changelog_entry_exists_for_current_package_version: no \
|
||||
CHANGELOG.md found above {} — skipping (this gate only \
|
||||
fires inside a workspace checkout).",
|
||||
manifest_dir.display()
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let contents = std::fs::read_to_string(&changelog_path).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to read CHANGELOG.md at {}: {err}",
|
||||
|
||||
@@ -113,6 +113,9 @@ impl SkillRegistry {
|
||||
let mut visited = HashSet::new();
|
||||
Self::discover_recursive(dir, 0, &mut registry, &mut visited);
|
||||
registry
|
||||
.skills
|
||||
.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
|
||||
registry
|
||||
}
|
||||
|
||||
fn discover_recursive(
|
||||
@@ -496,9 +499,6 @@ fn render_skills_block(registry: &SkillRegistry) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut skills = registry.list().to_vec();
|
||||
skills.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str("## Skills\n");
|
||||
out.push_str(
|
||||
@@ -510,7 +510,7 @@ instructions when using a specific skill.\n\n",
|
||||
out.push_str("### Available skills\n");
|
||||
|
||||
let mut omitted = 0usize;
|
||||
for skill in skills {
|
||||
for skill in registry.list() {
|
||||
// Use the real on-disk path captured at discovery — the directory
|
||||
// name can differ from the frontmatter `name` for community
|
||||
// installs, in which case `<dir>/<name>/SKILL.md` would not exist
|
||||
@@ -770,6 +770,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_skills_block_preserves_registry_precedence_under_prompt_budget() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut registry = super::SkillRegistry::default();
|
||||
registry.skills.push(super::Skill {
|
||||
name: "workspace-priority".to_string(),
|
||||
description: "must survive truncation".to_string(),
|
||||
body: "body".to_string(),
|
||||
path: tmpdir
|
||||
.path()
|
||||
.join(".claude")
|
||||
.join("skills")
|
||||
.join("workspace-priority")
|
||||
.join("SKILL.md"),
|
||||
});
|
||||
|
||||
let big_desc = "y".repeat(super::MAX_SKILL_DESCRIPTION_CHARS - 20);
|
||||
for i in 0..200 {
|
||||
registry.skills.push(super::Skill {
|
||||
name: format!("aaa-global-{i:03}"),
|
||||
description: big_desc.clone(),
|
||||
body: "body".to_string(),
|
||||
path: tmpdir
|
||||
.path()
|
||||
.join(".deepseek")
|
||||
.join("skills")
|
||||
.join(format!("aaa-global-{i:03}"))
|
||||
.join("SKILL.md"),
|
||||
});
|
||||
}
|
||||
|
||||
let rendered = super::render_skills_block(®istry).expect("skill context");
|
||||
assert!(
|
||||
rendered.contains("workspace-priority"),
|
||||
"higher-precedence workspace skills must not be reordered behind globals:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("additional skills omitted from this prompt budget"),
|
||||
"fixture should exceed prompt budget"
|
||||
);
|
||||
}
|
||||
|
||||
fn write_skill(dir: &std::path::Path, name: &str, description: &str, body: &str) {
|
||||
let skill_dir = dir.join(name);
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
|
||||
@@ -828,7 +828,9 @@ impl TaskManager {
|
||||
mode: req.mode.unwrap_or_else(|| self.cfg.default_mode.clone()),
|
||||
allow_shell: req.allow_shell.unwrap_or(self.cfg.allow_shell),
|
||||
trust_mode: req.trust_mode.unwrap_or(self.cfg.trust_mode),
|
||||
auto_approve: req.auto_approve.unwrap_or(true),
|
||||
// Auto-approval must be opted into explicitly
|
||||
// (GHSA-72w5-pf8h-xfp4).
|
||||
auto_approve: req.auto_approve.unwrap_or(false),
|
||||
status: TaskStatus::Queued,
|
||||
created_at: Utc::now(),
|
||||
started_at: None,
|
||||
@@ -1818,6 +1820,41 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// GHSA-72w5-pf8h-xfp4 — regression: omitted optional fields must not
|
||||
// silently elevate the spawned task's privileges.
|
||||
#[tokio::test]
|
||||
async fn add_task_without_optional_fields_does_not_grant_shell_or_auto_approve() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4()));
|
||||
let manager =
|
||||
TaskManager::start_with_executor(test_config(root.clone()), Arc::new(MockExecutor))
|
||||
.await?;
|
||||
|
||||
let req = NewTaskRequest {
|
||||
prompt: "fix TODOs and write a README".to_string(),
|
||||
model: None,
|
||||
workspace: None,
|
||||
mode: None,
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
};
|
||||
let task = manager.add_task(req).await?;
|
||||
|
||||
assert!(
|
||||
!task.allow_shell,
|
||||
"model-omitted allow_shell must default to false (no silent shell grant)"
|
||||
);
|
||||
assert!(
|
||||
!task.auto_approve,
|
||||
"model-omitted auto_approve must default to false (no silent auto-approval)"
|
||||
);
|
||||
assert!(
|
||||
!task.trust_mode,
|
||||
"model-omitted trust_mode must default to false"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_newer_task_schema_on_recovery() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4()));
|
||||
|
||||
@@ -318,7 +318,14 @@ async fn validate_fetch_target(
|
||||
"requests to localhost are not allowed",
|
||||
));
|
||||
}
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
// Normalize bracketed IPv6 literals before the literal-IP check so they
|
||||
// route through the same restricted-IP policy as unbracketed forms
|
||||
// (GHSA-88gh-2526-gfrr).
|
||||
let ip_candidate = host
|
||||
.strip_prefix('[')
|
||||
.and_then(|s| s.strip_suffix(']'))
|
||||
.unwrap_or(host.as_str());
|
||||
if let Ok(ip) = ip_candidate.parse::<std::net::IpAddr>() {
|
||||
if is_restricted_ip(&ip) {
|
||||
return Err(ToolError::permission_denied(format!(
|
||||
"IP {ip} is a restricted address (private/loopback/link-local)"
|
||||
@@ -569,6 +576,52 @@ mod tests {
|
||||
assert!(format!("{err}").contains("restricted address"));
|
||||
}
|
||||
|
||||
// GHSA-88gh-2526-gfrr — regression coverage for bracketed IPv6 literals.
|
||||
#[tokio::test]
|
||||
async fn rejects_ipv6_literal_loopback() {
|
||||
let url = reqwest::Url::parse("http://[::1]/").unwrap();
|
||||
let err = validate_fetch_target(&url, &ctx())
|
||||
.await
|
||||
.expect_err("[::1] must be rejected as restricted");
|
||||
assert!(format!("{err}").contains("restricted"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_ipv6_literal_ula() {
|
||||
let url = reqwest::Url::parse("http://[fc00::1]/").unwrap();
|
||||
let err = validate_fetch_target(&url, &ctx())
|
||||
.await
|
||||
.expect_err("[fc00::1] must be rejected as restricted");
|
||||
assert!(format!("{err}").contains("restricted"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_ipv6_literal_link_local() {
|
||||
let url = reqwest::Url::parse("http://[fe80::1]/").unwrap();
|
||||
let err = validate_fetch_target(&url, &ctx())
|
||||
.await
|
||||
.expect_err("[fe80::1] must be rejected as restricted");
|
||||
assert!(format!("{err}").contains("restricted"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_ipv6_literal_ipv4_mapped_loopback() {
|
||||
let url = reqwest::Url::parse("http://[::ffff:127.0.0.1]/").unwrap();
|
||||
let err = validate_fetch_target(&url, &ctx())
|
||||
.await
|
||||
.expect_err("[::ffff:127.0.0.1] must be rejected as restricted");
|
||||
assert!(format!("{err}").contains("restricted"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_ipv6_literal_unspecified() {
|
||||
let url = reqwest::Url::parse("http://[::]/").unwrap();
|
||||
let err = validate_fetch_target(&url, &ctx())
|
||||
.await
|
||||
.expect_err("[::] must be rejected as restricted");
|
||||
assert!(format!("{err}").contains("restricted"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn redirected_host_respects_network_policy() {
|
||||
use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
|
||||
|
||||
@@ -35,7 +35,7 @@ use crate::tui::file_mention::ContextReference;
|
||||
use crate::tui::history::{HistoryCell, TranscriptRenderOptions};
|
||||
use crate::tui::paste_burst::{FlushResult, PasteBurst};
|
||||
use crate::tui::scrolling::{MouseScrollState, TranscriptLineMeta, TranscriptScroll};
|
||||
use crate::tui::selection::TranscriptSelection;
|
||||
use crate::tui::selection::{SelectionAutoscroll, TranscriptSelection};
|
||||
use crate::tui::streaming::StreamingState;
|
||||
use crate::tui::transcript::TranscriptViewCache;
|
||||
use crate::tui::views::ViewStack;
|
||||
@@ -575,6 +575,8 @@ pub struct ViewportState {
|
||||
pub mouse_scroll: MouseScrollState,
|
||||
pub transcript_cache: TranscriptViewCache,
|
||||
pub transcript_selection: TranscriptSelection,
|
||||
pub selection_autoscroll: Option<SelectionAutoscroll>,
|
||||
pub transcript_scrollbar_dragging: bool,
|
||||
pub last_transcript_area: Option<Rect>,
|
||||
pub last_transcript_top: usize,
|
||||
pub last_transcript_visible: usize,
|
||||
@@ -591,6 +593,8 @@ impl Default for ViewportState {
|
||||
mouse_scroll: MouseScrollState::new(),
|
||||
transcript_cache: TranscriptViewCache::new(),
|
||||
transcript_selection: TranscriptSelection::default(),
|
||||
selection_autoscroll: None,
|
||||
transcript_scrollbar_dragging: false,
|
||||
last_transcript_area: None,
|
||||
last_transcript_top: 0,
|
||||
last_transcript_visible: 0,
|
||||
@@ -3694,18 +3698,34 @@ impl App {
|
||||
self.history_navigation_draft = None;
|
||||
}
|
||||
|
||||
/// Retry a `try_lock` up to `retries` times with a 1ms pause between
|
||||
/// attempts. Returns `Some(guard)` on success, `None` if the lock
|
||||
/// remains contended after all retries.
|
||||
fn retry_lock<T>(
|
||||
mutex: &tokio::sync::Mutex<T>,
|
||||
retries: u32,
|
||||
) -> Option<tokio::sync::MutexGuard<'_, T>> {
|
||||
for _ in 0..retries {
|
||||
if let Ok(guard) = mutex.try_lock() {
|
||||
return Some(guard);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn clear_todos(&mut self) -> bool {
|
||||
// Clear the todo list (the sidebar checklist). Uses try_lock so the
|
||||
// UI thread doesn't block if the engine briefly holds the mutex
|
||||
// during tool execution; the caller can retry or show a busy message.
|
||||
let todos_cleared = if let Ok(mut todos) = self.todos.try_lock() {
|
||||
// Clear the todo list (the sidebar checklist). Retry with try_lock
|
||||
// so /clear always resets todos even when the engine briefly holds
|
||||
// the mutex during tool execution.
|
||||
let todos_cleared = if let Some(mut todos) = Self::retry_lock(&self.todos, 100) {
|
||||
todos.clear();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Also clear the plan state — /clear means a full reset.
|
||||
if let Ok(mut plan) = self.plan_state.try_lock() {
|
||||
if let Some(mut plan) = Self::retry_lock(&self.plan_state, 100) {
|
||||
*plan = crate::tools::plan::PlanState::default();
|
||||
}
|
||||
todos_cleared
|
||||
@@ -3911,6 +3931,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs};
|
||||
use crate::tools::todo::TodoStatus;
|
||||
use crate::tui::clipboard::PastedImage;
|
||||
|
||||
fn test_options(yolo: bool) -> TuiOptions {
|
||||
@@ -4061,6 +4082,24 @@ mod tests {
|
||||
assert_eq!(app.history_version, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_todos_resets_todos_list() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
|
||||
// Seed some todos.
|
||||
{
|
||||
let mut todos = app.todos.try_lock().expect("todos lock");
|
||||
todos.add("buy milk".to_string(), TodoStatus::Pending);
|
||||
todos.add("write code".to_string(), TodoStatus::InProgress);
|
||||
assert_eq!(todos.snapshot().items.len(), 2);
|
||||
}
|
||||
|
||||
assert!(app.clear_todos());
|
||||
|
||||
let todos = app.todos.try_lock().expect("todos lock");
|
||||
assert!(todos.snapshot().items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_todos_resets_plan_state() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
|
||||
@@ -207,13 +207,30 @@ impl HistoryCell {
|
||||
)
|
||||
}
|
||||
}
|
||||
HistoryCell::Error { message, severity } => render_message(
|
||||
error_label_text(*severity),
|
||||
error_label_style(*severity),
|
||||
error_body_style(*severity),
|
||||
message,
|
||||
width,
|
||||
),
|
||||
HistoryCell::Error { message, severity } => {
|
||||
// Error messages are machine-generated and should not be run
|
||||
// through markdown rendering, which would mangle env-var names
|
||||
// containing underscores (e.g. DEEPSEEK_ALLOW_INSECURE_HTTP
|
||||
// would lose its underscores as italic markers).
|
||||
let label = error_label_text(*severity);
|
||||
let label_style = error_label_style(*severity);
|
||||
let body_style = error_body_style(*severity);
|
||||
let prefix_width = UnicodeWidthStr::width(label);
|
||||
let content_width = width.saturating_sub(2 + prefix_width as u16).max(1);
|
||||
let mut lines = wrap_plain_line(message, body_style, content_width);
|
||||
// Add the label prefix to the first line
|
||||
if let Some(first) = lines.get_mut(0) {
|
||||
first.spans.insert(0, Span::raw(" "));
|
||||
first.spans.insert(0, Span::styled(label, label_style));
|
||||
}
|
||||
// Continuation rail for subsequent lines
|
||||
let rail = format!("{}{}", '\u{258F}', " ".repeat(prefix_width));
|
||||
let rail_style = Style::default().fg(palette::TEXT_DIM);
|
||||
for line in lines.iter_mut().skip(1) {
|
||||
line.spans.insert(0, Span::styled(rail.clone(), rail_style));
|
||||
}
|
||||
lines
|
||||
}
|
||||
HistoryCell::Thinking {
|
||||
content,
|
||||
streaming,
|
||||
@@ -3685,6 +3702,77 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #1212 repro: a multi-line SQL fence rendered after a short
|
||||
/// intro paragraph. Every code-block line — not just the first or last —
|
||||
/// must avoid the `▏` rail.
|
||||
#[test]
|
||||
fn assistant_long_code_block_keeps_every_line_rail_free() {
|
||||
let cell = HistoryCell::Assistant {
|
||||
content: "Here's the query:\n```sql\nSELECT\n c.customer_id,\n c.name,\n COUNT(o.order_id) AS order_count\nFROM customers c\nJOIN orders o ON c.customer_id = o.customer_id;\n```".to_string(),
|
||||
streaming: false,
|
||||
};
|
||||
let visible: Vec<String> = cell
|
||||
.lines(80)
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let code_markers = ["SELECT", "customer_id", "name,", "COUNT", "FROM", "JOIN"];
|
||||
for marker in code_markers {
|
||||
let line = visible
|
||||
.iter()
|
||||
.find(|line| line.contains(marker))
|
||||
.unwrap_or_else(|| panic!("expected code line containing {marker:?}"));
|
||||
assert!(
|
||||
!line.contains('\u{258F}'),
|
||||
"code block line containing {marker:?} must not have the transcript rail: {line:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge case: a blank line inside a fence is still a code line; it must
|
||||
/// not regress to the rail because the empty body falls through a
|
||||
/// different wrap branch.
|
||||
#[test]
|
||||
fn assistant_code_block_blank_line_keeps_no_rail() {
|
||||
let cell = HistoryCell::Assistant {
|
||||
content: "```\nfn one() {}\n\nfn two() {}\n```".to_string(),
|
||||
streaming: false,
|
||||
};
|
||||
for line in cell.lines(80).iter().skip(1) {
|
||||
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
|
||||
assert!(
|
||||
!text.contains('\u{258F}'),
|
||||
"fence body line must stay rail-free: {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapped code lines (a single source line longer than the viewport)
|
||||
/// emit multiple rendered lines from one `Block::Code`. None of them
|
||||
/// should leak the rail.
|
||||
#[test]
|
||||
fn assistant_wrapped_code_lines_keep_no_rail() {
|
||||
let long = "let x = ".to_string() + &"abcdef ".repeat(40);
|
||||
let content = format!("```\n{long}\n```");
|
||||
let cell = HistoryCell::Assistant {
|
||||
content,
|
||||
streaming: false,
|
||||
};
|
||||
for line in cell.lines(40).iter().skip(1) {
|
||||
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
|
||||
assert!(
|
||||
!text.contains('\u{258F}'),
|
||||
"wrapped code line must stay rail-free: {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_glyph_holds_full_brightness_when_idle() {
|
||||
// Idle (streaming=false) and low_motion both pin the colour to the
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Text selection state for the transcript view.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
// === Types ===
|
||||
|
||||
/// A selection endpoint in the transcript (line/column).
|
||||
@@ -17,6 +19,20 @@ pub struct TranscriptSelection {
|
||||
pub dragging: bool,
|
||||
}
|
||||
|
||||
/// Drag-past-edge auto-scroll state. While the user holds the left button
|
||||
/// and the cursor is above or below the transcript rect, the main loop
|
||||
/// advances `pending_scroll_delta` and extends the selection head on a
|
||||
/// fixed cadence so a long passage can be selected in one drag (#1163).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SelectionAutoscroll {
|
||||
/// `-1` scrolls up, `+1` scrolls down. Never `0`.
|
||||
pub direction: i32,
|
||||
/// Last in-bounds mouse column, in absolute terminal coordinates.
|
||||
pub column: u16,
|
||||
/// When the next tick is allowed to fire.
|
||||
pub next_tick: Instant,
|
||||
}
|
||||
|
||||
impl TranscriptSelection {
|
||||
/// Clear any active selection.
|
||||
pub fn clear(&mut self) {
|
||||
|
||||
@@ -73,6 +73,11 @@ pub struct TranscriptViewCache {
|
||||
lines: Vec<Line<'static>>,
|
||||
/// Per-line metadata aligned with `lines`.
|
||||
line_meta: Vec<TranscriptLineMeta>,
|
||||
/// Per-line rail-prefix display-column count (`0` or `2`), aligned with
|
||||
/// `lines`. Populated during flatten so that selection-to-text can shift
|
||||
/// columns past visual-only decoration glyphs without guessing which
|
||||
/// spans are decorative (#1163).
|
||||
rail_prefix_widths: Vec<usize>,
|
||||
}
|
||||
|
||||
impl TranscriptViewCache {
|
||||
@@ -85,6 +90,7 @@ impl TranscriptViewCache {
|
||||
per_cell: Vec::new(),
|
||||
lines: Vec::new(),
|
||||
line_meta: Vec::new(),
|
||||
rail_prefix_widths: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +225,7 @@ impl TranscriptViewCache {
|
||||
fn flatten(&mut self, spacing: TranscriptSpacing) {
|
||||
self.lines.clear();
|
||||
self.line_meta.clear();
|
||||
self.rail_prefix_widths.clear();
|
||||
self.append_flattened_cells(spacing, 0);
|
||||
}
|
||||
|
||||
@@ -243,6 +250,7 @@ impl TranscriptViewCache {
|
||||
.unwrap_or(self.lines.len());
|
||||
self.lines.truncate(truncate_at);
|
||||
self.line_meta.truncate(truncate_at);
|
||||
self.rail_prefix_widths.truncate(truncate_at);
|
||||
self.append_flattened_cells(spacing, first_cell);
|
||||
}
|
||||
|
||||
@@ -256,7 +264,7 @@ impl TranscriptViewCache {
|
||||
// Deref is zero-cost and gives us &[Line].
|
||||
let rendered_line_count = cached.lines.len();
|
||||
for (line_in_cell, line) in cached.lines.iter().enumerate() {
|
||||
self.lines.push(line_with_group_rail(
|
||||
let final_line = line_with_group_rail(
|
||||
line,
|
||||
tool_group_rail(
|
||||
self.per_cell.as_slice(),
|
||||
@@ -265,7 +273,10 @@ impl TranscriptViewCache {
|
||||
rendered_line_count,
|
||||
),
|
||||
usize::from(self.width),
|
||||
));
|
||||
);
|
||||
self.rail_prefix_widths
|
||||
.push(compute_rail_prefix_width(&final_line));
|
||||
self.lines.push(final_line);
|
||||
self.line_meta.push(TranscriptLineMeta::CellLine {
|
||||
cell_index,
|
||||
line_in_cell,
|
||||
@@ -277,6 +288,7 @@ impl TranscriptViewCache {
|
||||
for _ in 0..spacer_rows {
|
||||
self.lines.push(Line::from(""));
|
||||
self.line_meta.push(TranscriptLineMeta::Spacer);
|
||||
self.rail_prefix_widths.push(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -299,6 +311,18 @@ impl TranscriptViewCache {
|
||||
pub fn total_lines(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
/// Return the rail-prefix display-column count for the line at
|
||||
/// `line_index`. Callers use this to shift selection coordinates past
|
||||
/// visual-only decoration glyphs without guessing which spans are
|
||||
/// decorative (#1163).
|
||||
#[must_use]
|
||||
pub fn rail_prefix_width(&self, line_index: usize) -> usize {
|
||||
self.rail_prefix_widths
|
||||
.get(line_index)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn spacer_rows_between(
|
||||
@@ -391,6 +415,75 @@ fn line_with_group_rail(
|
||||
rendered
|
||||
}
|
||||
|
||||
/// Return the display-column count of consecutive visual-only decorative
|
||||
/// spans at the start of a rendered transcript line. Iterates through
|
||||
/// leading spans matching either of two patterns:
|
||||
///
|
||||
/// * Pattern A — span is `"<glyph>[<glyph>…]<space>"` where every character
|
||||
/// except the trailing space is a rail-drawing character (e.g. `▏ `,
|
||||
/// `▶ `, `⋮⋮ `). The entire span width is accumulated.
|
||||
/// * Pattern B — span is `"<glyph>"` (1 drawing char) followed by a lone
|
||||
/// space span `" "` (e.g. `●` then ` `, `▎` then ` `).
|
||||
///
|
||||
/// Stops at the first non-matching span. Every decorated glyph used by the
|
||||
/// TUI is a single display-column character, so char-count = display width.
|
||||
///
|
||||
/// Returns `0` for lines whose first span is not a decorative prefix.
|
||||
fn compute_rail_prefix_width(line: &Line<'static>) -> usize {
|
||||
let spans = line.spans.as_slice();
|
||||
let mut total = 0;
|
||||
let mut i = 0;
|
||||
|
||||
while i < spans.len() {
|
||||
let content = spans[i].content.as_ref();
|
||||
let n_chars = content.chars().count();
|
||||
|
||||
// Pattern A — span "<glyph>[<glyph>…]<space>" (≥ 2 chars, trailing
|
||||
// space, all preceding chars are drawing chars).
|
||||
if n_chars >= 2
|
||||
&& content.ends_with(' ')
|
||||
&& content
|
||||
.chars()
|
||||
.take(n_chars.saturating_sub(1))
|
||||
.all(is_rail_drawing_char)
|
||||
{
|
||||
total += n_chars;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pattern B — span "<glyph>" (1 drawing char) + next span " ".
|
||||
if n_chars == 1
|
||||
&& content.chars().next().is_some_and(is_rail_drawing_char)
|
||||
&& spans.get(i + 1).is_some_and(|s| s.content.as_ref() == " ")
|
||||
{
|
||||
total += 2;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
|
||||
/// Characters that serve as decoration glyphs in the TUI left-rail and
|
||||
/// tool-header prefix system. All are single display-column characters.
|
||||
fn is_rail_drawing_char(ch: char) -> bool {
|
||||
matches!(
|
||||
ch,
|
||||
'\u{2500}'..='\u{257F}' // Box Drawing (╭ ╮ ╰ ╯ │ ╎ …)
|
||||
| '\u{2580}'..='\u{259F}' // Block Elements (▏ ▎ ▍ ▌ …)
|
||||
| '\u{25A0}'..='\u{25FF}' // Geometric Shapes (● ▶ ▷ ◆ ◐ …)
|
||||
| '\u{2022}' // • bullet (tool status / generic tool)
|
||||
| '\u{2026}' // … ellipsis (reasoning opener)
|
||||
| '\u{00B7}' // · middle dot (tool running symbol)
|
||||
| '\u{2315}' // ⌕ telephone recorder (find/search tool)
|
||||
| '\u{22EE}' // ⋮ vertical ellipsis (fanout/rlm tool)
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate_spans_to_width(spans: Vec<Span<'static>>, max_width: usize) -> Vec<Span<'static>> {
|
||||
if max_width == 0 || spans.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -817,4 +910,69 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulate a long, complex conversation (thinking + multi-line tool output +
|
||||
/// tool headers with multiple decorative spans) and report the memory
|
||||
/// consumed by `rail_prefix_widths`. This is informational — the assertion
|
||||
/// only fails if the per-line overhead exceeds a generous bound.
|
||||
#[test]
|
||||
fn rail_prefix_widths_memory_overhead_complex_session() {
|
||||
let mut cells: Vec<HistoryCell> = Vec::new();
|
||||
// Build ~60 turns covering the typical deep-reasoning workflow:
|
||||
// user → thinking (5-15 lines) → assistant → tool → tool output →
|
||||
// thinking → assistant → ... repeat.
|
||||
for i in 0..30 {
|
||||
cells.push(user_cell(&format!("complex query {i} about system design")));
|
||||
cells.push(HistoryCell::Thinking {
|
||||
content:
|
||||
"line A\nline B\nline C\nline D\nline E\nline F\nline G\nline H\nline I\nline J"
|
||||
.to_string(),
|
||||
streaming: false,
|
||||
duration_secs: Some(3.5),
|
||||
});
|
||||
cells.push(assistant_cell(
|
||||
&format!("response {i} with multi-line\ntext content spanning\nseveral lines"),
|
||||
false,
|
||||
));
|
||||
cells.push(exec_tool_cell(
|
||||
"cargo test --package my_crate -- --nocapture 2>&1 | head -40",
|
||||
));
|
||||
// Insert a second tool so adjacent tool cells merge into a railed group.
|
||||
cells.push(exec_tool_cell(&format!("git diff --stat HEAD~{i}")));
|
||||
}
|
||||
let revisions: Vec<u64> = (0..cells.len()).map(|i| i as u64 + 1).collect();
|
||||
|
||||
let mut cache = TranscriptViewCache::new();
|
||||
cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
|
||||
|
||||
let total_lines = cache.total_lines();
|
||||
let pw_len = cache.rail_prefix_widths.len();
|
||||
let pw_cap = cache.rail_prefix_widths.capacity();
|
||||
// The Vec's inlined buffer on most platforms is small; capacity
|
||||
// should be >= len. Both must equal total_lines.
|
||||
assert_eq!(pw_len, total_lines);
|
||||
assert!(pw_cap >= pw_len);
|
||||
|
||||
let memory_bytes = pw_cap * std::mem::size_of::<usize>();
|
||||
let memory_kb = memory_bytes as f64 / 1024.0;
|
||||
// Each usize is 8 bytes on 64-bit. Even with 100k lines this stays
|
||||
// under 1 MB.
|
||||
let kbytes_per_1k_lines = (memory_bytes as f64 / total_lines as f64) * 1000.0 / 1024.0;
|
||||
|
||||
eprintln!("=== rail_prefix_widths memory (complex session) ===");
|
||||
eprintln!(" total_lines: {total_lines}");
|
||||
eprintln!(" vec len: {pw_len}");
|
||||
eprintln!(" vec capacity: {pw_cap}");
|
||||
eprintln!(" memory (bytes): {memory_bytes}");
|
||||
eprintln!(" memory (KB): {memory_kb:.2}");
|
||||
eprintln!(" KB per 1k lines: {kbytes_per_1k_lines:.2}");
|
||||
eprintln!(" lines × 8 bytes: {} KB", total_lines * 8 / 1024);
|
||||
|
||||
// Sanity: per-line overhead must be reasonable.
|
||||
assert!(
|
||||
memory_kb < 1024.0,
|
||||
"rail_prefix_widths memory unexpectedly large: {memory_kb:.1} KB"
|
||||
);
|
||||
eprintln!(" ✓ well under 1 MB even for very long sessions");
|
||||
}
|
||||
}
|
||||
|
||||
+206
-9
@@ -69,7 +69,7 @@ use crate::tui::pager::PagerView;
|
||||
use crate::tui::persistence_actor::{self, PersistRequest};
|
||||
use crate::tui::plan_prompt::PlanPromptView;
|
||||
use crate::tui::scrolling::{ScrollDirection, TranscriptScroll};
|
||||
use crate::tui::selection::TranscriptSelectionPoint;
|
||||
use crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};
|
||||
use crate::tui::session_picker::SessionPickerView;
|
||||
use crate::tui::shell_job_routing::{
|
||||
add_shell_job_message, format_shell_job_list, format_shell_poll, open_shell_job_pager,
|
||||
@@ -1494,6 +1494,10 @@ async fn run_event_loop(
|
||||
// Expire the "Press Ctrl+C again to quit" prompt silently after its
|
||||
// window. Triggers a redraw if the prompt was visible.
|
||||
app.tick_quit_armed();
|
||||
// While the user is drag-selecting past the transcript edge, advance
|
||||
// the viewport on a fixed cadence and extend the selection head so a
|
||||
// long passage can be selected in one drag (#1163).
|
||||
tick_selection_autoscroll(app);
|
||||
let allow_workspace_context_refresh =
|
||||
!app.is_loading && !has_running_agents && !app.is_compacting;
|
||||
refresh_workspace_context_if_needed(app, now, allow_workspace_context_refresh);
|
||||
@@ -1544,6 +1548,13 @@ async fn run_event_loop(
|
||||
let remaining = deadline.saturating_duration_since(now);
|
||||
poll_timeout = poll_timeout.min(remaining.max(Duration::from_millis(50)));
|
||||
}
|
||||
// Drag-edge auto-scroll wakes the loop on its own cadence so the
|
||||
// viewport keeps advancing while the user holds the mouse outside
|
||||
// the transcript rect (#1163).
|
||||
if let Some(state) = app.viewport.selection_autoscroll {
|
||||
let remaining = state.next_tick.saturating_duration_since(now);
|
||||
poll_timeout = poll_timeout.min(remaining);
|
||||
}
|
||||
poll_timeout = clamp_event_poll_timeout(poll_timeout);
|
||||
|
||||
// #549: this async task also performs a blocking terminal poll. Give
|
||||
@@ -7591,6 +7602,17 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEvent> {
|
||||
}
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
app.viewport.transcript_scrollbar_dragging = false;
|
||||
app.viewport.selection_autoscroll = None;
|
||||
|
||||
// Click on the transcript scrollbar gutter starts a scrollbar
|
||||
// drag so the visible thumb remains interactive for users who
|
||||
// prefer mouse-based navigation.
|
||||
if mouse_hits_transcript_scrollbar(app, mouse) {
|
||||
app.viewport.transcript_scrollbar_dragging = true;
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if mouse_hits_rect(mouse, app.viewport.jump_to_latest_button_area) {
|
||||
app.scroll_to_bottom();
|
||||
return Vec::new();
|
||||
@@ -7615,14 +7637,23 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEvent> {
|
||||
}
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
if app.viewport.transcript_selection.dragging
|
||||
&& let Some(point) = selection_point_from_mouse(app, mouse)
|
||||
{
|
||||
app.viewport.transcript_selection.head = Some(point);
|
||||
if app.viewport.transcript_scrollbar_dragging {
|
||||
scroll_transcript_to_mouse_row(app, mouse.row);
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if app.viewport.transcript_selection.dragging {
|
||||
update_selection_drag(app, mouse);
|
||||
}
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_scrollbar_dragging => {
|
||||
app.viewport.transcript_scrollbar_dragging = false;
|
||||
app.viewport.selection_autoscroll = None;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_selection.dragging => {
|
||||
app.viewport.transcript_selection.dragging = false;
|
||||
app.viewport.selection_autoscroll = None;
|
||||
if selection_has_content(app) {
|
||||
copy_active_selection(app);
|
||||
}
|
||||
@@ -7636,6 +7667,154 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn mouse_hits_transcript_scrollbar(app: &App, mouse: MouseEvent) -> bool {
|
||||
let Some(area) = app.viewport.last_transcript_area else {
|
||||
return false;
|
||||
};
|
||||
if area.width <= 1 || app.viewport.last_transcript_total <= app.viewport.last_transcript_visible
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let scrollbar_col = area.x.saturating_add(area.width.saturating_sub(1));
|
||||
mouse.column == scrollbar_col
|
||||
&& mouse.row >= area.y
|
||||
&& mouse.row < area.y.saturating_add(area.height)
|
||||
}
|
||||
|
||||
fn scroll_transcript_to_mouse_row(app: &mut App, row: u16) -> bool {
|
||||
let Some(area) = app.viewport.last_transcript_area else {
|
||||
return false;
|
||||
};
|
||||
let total = app.viewport.last_transcript_total;
|
||||
let visible = app.viewport.last_transcript_visible;
|
||||
if area.height == 0 || total <= visible {
|
||||
return false;
|
||||
}
|
||||
|
||||
let max_start = total.saturating_sub(visible);
|
||||
if max_start == 0 {
|
||||
app.scroll_to_bottom();
|
||||
return true;
|
||||
}
|
||||
|
||||
let max_row = usize::from(area.height.saturating_sub(1));
|
||||
let relative_row = usize::from(row.saturating_sub(area.y)).min(max_row);
|
||||
let numerator = relative_row
|
||||
.saturating_mul(max_start)
|
||||
.saturating_add(max_row / 2);
|
||||
// Round to the nearest transcript offset so short thumbs still feel
|
||||
// responsive on compact terminals.
|
||||
let top = numerator.checked_div(max_row).unwrap_or(0);
|
||||
|
||||
app.viewport.transcript_scroll = if top >= max_start {
|
||||
TranscriptScroll::to_bottom()
|
||||
} else {
|
||||
TranscriptScroll::at_line(top)
|
||||
};
|
||||
app.viewport.pending_scroll_delta = 0;
|
||||
app.user_scrolled_during_stream = !app.viewport.transcript_scroll.is_at_tail();
|
||||
app.needs_redraw = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// Cadence between auto-scroll ticks while drag-selecting past the
|
||||
/// transcript edge (#1163). 30 ms ≈ 33 lines/sec, comparable to the feel
|
||||
/// of a steady scroll-wheel drag.
|
||||
const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(30);
|
||||
|
||||
/// Update the transcript selection while the left button is dragging.
|
||||
/// When the mouse leaves the transcript rect vertically, arm
|
||||
/// `selection_autoscroll` so the main loop can advance the viewport on a
|
||||
/// fixed cadence; when the mouse returns inside, disarm it.
|
||||
fn update_selection_drag(app: &mut App, mouse: MouseEvent) {
|
||||
if let Some(point) = selection_point_from_mouse(app, mouse) {
|
||||
app.viewport.transcript_selection.head = Some(point);
|
||||
app.viewport.selection_autoscroll = None;
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(area) = app.viewport.last_transcript_area else {
|
||||
return;
|
||||
};
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let direction = if mouse.row < area.y {
|
||||
-1
|
||||
} else if mouse.row >= area.y.saturating_add(area.height) {
|
||||
1
|
||||
} else {
|
||||
// Outside horizontally only — leave selection head where it is.
|
||||
return;
|
||||
};
|
||||
|
||||
let max_col = area.x.saturating_add(area.width.saturating_sub(1));
|
||||
let column = mouse.column.clamp(area.x, max_col);
|
||||
|
||||
// Fire on the next tick immediately by setting `next_tick` to now.
|
||||
app.viewport.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
direction,
|
||||
column,
|
||||
next_tick: Instant::now(),
|
||||
});
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// Advance the drag-edge auto-scroll one step if its cadence has elapsed.
|
||||
/// Called once per main-loop iteration.
|
||||
fn tick_selection_autoscroll(app: &mut App) {
|
||||
let Some(state) = app.viewport.selection_autoscroll else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !app.viewport.transcript_selection.dragging {
|
||||
app.viewport.selection_autoscroll = None;
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(area) = app.viewport.last_transcript_area else {
|
||||
return;
|
||||
};
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
if now < state.next_tick {
|
||||
return;
|
||||
}
|
||||
|
||||
app.viewport.pending_scroll_delta = app
|
||||
.viewport
|
||||
.pending_scroll_delta
|
||||
.saturating_add(state.direction);
|
||||
app.user_scrolled_during_stream = true;
|
||||
|
||||
let edge_row = if state.direction < 0 {
|
||||
area.y
|
||||
} else {
|
||||
area.y.saturating_add(area.height.saturating_sub(1))
|
||||
};
|
||||
if let Some(point) = selection_point_from_position(
|
||||
area,
|
||||
state.column,
|
||||
edge_row,
|
||||
app.viewport.last_transcript_top,
|
||||
app.viewport.last_transcript_total,
|
||||
app.viewport.last_transcript_padding_top,
|
||||
) {
|
||||
app.viewport.transcript_selection.head = Some(point);
|
||||
}
|
||||
|
||||
app.viewport.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
next_tick: now + SELECTION_AUTOSCROLL_INTERVAL,
|
||||
..state
|
||||
});
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
|
||||
fn mouse_hits_rect(mouse: MouseEvent, area: Option<Rect>) -> bool {
|
||||
let Some(area) = area else {
|
||||
return false;
|
||||
@@ -7923,18 +8102,36 @@ fn selection_to_text(app: &App) -> Option<String> {
|
||||
let mut selected_lines = Vec::new();
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for line_index in start_index..=end_index {
|
||||
let line_text = line_to_plain(&lines[line_index]);
|
||||
// Rail-prefix decorations are stored as cache metadata rather than
|
||||
// detected from glyphs, so new decoration types are covered without
|
||||
// changes to the copy path (#1163).
|
||||
let rail_width = app.viewport.transcript_cache.rail_prefix_width(line_index);
|
||||
// Convert the rendered line to plain text (strips OSC-8), then
|
||||
// slice off the rail prefix so subsequent column offsets operate
|
||||
// on content-only text.
|
||||
let full_text = line_to_plain(&lines[line_index]);
|
||||
let line_text = if rail_width > 0 {
|
||||
slice_text(&full_text, rail_width, text_display_width(&full_text))
|
||||
} else {
|
||||
full_text
|
||||
};
|
||||
let line_width = text_display_width(&line_text);
|
||||
let (col_start, col_end) = if start_index == end_index {
|
||||
// Selection coordinates are recorded in rendered-column space, which
|
||||
// includes the visual rail prefix. Add rail_width back so the column
|
||||
// window maps correctly into the rail-stripped text.
|
||||
let (raw_col_start, raw_col_end) = if start_index == end_index {
|
||||
(start.column, end.column)
|
||||
} else if line_index == start_index {
|
||||
(start.column, line_width)
|
||||
(start.column, line_width.saturating_add(rail_width))
|
||||
} else if line_index == end_index {
|
||||
(0, end.column)
|
||||
} else {
|
||||
(0, line_width)
|
||||
(0, line_width.saturating_add(rail_width))
|
||||
};
|
||||
|
||||
let col_start = raw_col_start.saturating_sub(rail_width).min(line_width);
|
||||
let col_end = raw_col_end.saturating_sub(rail_width).min(line_width);
|
||||
|
||||
let slice = slice_text(&line_text, col_start, col_end);
|
||||
selected_lines.push(slice);
|
||||
}
|
||||
|
||||
+289
-12
@@ -221,7 +221,7 @@ fn selection_to_text_handles_multiline_and_reversed_endpoints() {
|
||||
column: 6,
|
||||
});
|
||||
|
||||
assert_eq!(selection_to_text(&app).as_deref(), Some("a beta\n▏ gam"));
|
||||
assert_eq!(selection_to_text(&app).as_deref(), Some("a beta\ngam"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -275,10 +275,19 @@ fn selection_to_text_copies_rendered_transcript_block() {
|
||||
|
||||
let selected = selection_to_text(&app).expect("selection text");
|
||||
assert!(selected.contains("Note copy system"), "{selected:?}");
|
||||
assert!(selected.contains("▎ copy user"), "{selected:?}");
|
||||
assert!(selected.contains("copy user"), "{selected:?}");
|
||||
assert!(selected.contains("copy thinking"), "{selected:?}");
|
||||
assert!(selected.contains("tool output line"), "{selected:?}");
|
||||
assert!(selected.contains("● copy assistant"), "{selected:?}");
|
||||
assert!(selected.contains("copy assistant"), "{selected:?}");
|
||||
// #1163: tool-card middle lines are rendered with a `│ ` left rail
|
||||
// glyph, but that decoration must not leak into copied text. Assert
|
||||
// no isolated rail glyph survives at the start of any line.
|
||||
for (idx, line) in selected.lines().enumerate() {
|
||||
assert!(
|
||||
!line.starts_with("\u{2502} "),
|
||||
"line {idx} retained tool-card rail prefix: {line:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -384,8 +393,11 @@ fn jump_to_latest_button_click_scrolls_to_tail() {
|
||||
assert!(!app.viewport.transcript_selection.dragging);
|
||||
}
|
||||
|
||||
/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not
|
||||
/// text selection) so the visible thumb remains interactive for users who
|
||||
/// prefer mouse-based navigation.
|
||||
#[test]
|
||||
fn transcript_scrollbar_gutter_is_not_draggable() {
|
||||
fn transcript_scrollbar_gutter_starts_scrollbar_drag() {
|
||||
let mut app = create_test_app();
|
||||
app.history = vec![HistoryCell::Assistant {
|
||||
content: "alpha beta".to_string(),
|
||||
@@ -409,6 +421,8 @@ fn transcript_scrollbar_gutter_is_not_draggable() {
|
||||
app.viewport.transcript_scroll = TranscriptScroll::to_bottom();
|
||||
app.user_scrolled_during_stream = false;
|
||||
|
||||
// Left-down on the scrollbar gutter (column == right edge) starts a
|
||||
// scrollbar drag, not a transcript selection.
|
||||
let events = handle_mouse_event(
|
||||
&mut app,
|
||||
MouseEvent {
|
||||
@@ -420,10 +434,17 @@ fn transcript_scrollbar_gutter_is_not_draggable() {
|
||||
);
|
||||
|
||||
assert!(events.is_empty());
|
||||
assert!(app.viewport.transcript_selection.dragging);
|
||||
assert!(app.viewport.transcript_scroll.is_at_tail());
|
||||
assert!(!app.user_scrolled_during_stream);
|
||||
assert!(
|
||||
app.viewport.transcript_scrollbar_dragging,
|
||||
"gutter click should start scrollbar drag"
|
||||
);
|
||||
assert!(
|
||||
!app.viewport.transcript_selection.dragging,
|
||||
"gutter click should NOT start text selection"
|
||||
);
|
||||
|
||||
// Drag moves the viewport (no assertion on exact scroll position — the
|
||||
// mapping depends on area geometry).
|
||||
handle_mouse_event(
|
||||
&mut app,
|
||||
MouseEvent {
|
||||
@@ -433,11 +454,9 @@ fn transcript_scrollbar_gutter_is_not_draggable() {
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
);
|
||||
assert!(app.viewport.transcript_scrollbar_dragging);
|
||||
|
||||
assert!(app.viewport.transcript_scroll.is_at_tail());
|
||||
assert!(!app.user_scrolled_during_stream);
|
||||
assert!(app.viewport.transcript_selection.dragging);
|
||||
|
||||
// Left-up ends the scrollbar drag.
|
||||
handle_mouse_event(
|
||||
&mut app,
|
||||
MouseEvent {
|
||||
@@ -448,7 +467,7 @@ fn transcript_scrollbar_gutter_is_not_draggable() {
|
||||
},
|
||||
);
|
||||
|
||||
assert!(!app.viewport.transcript_selection.dragging);
|
||||
assert!(!app.viewport.transcript_scrollbar_dragging);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -482,6 +501,264 @@ fn left_down_inside_transcript_starts_selection() {
|
||||
assert!(app.viewport.transcript_selection.dragging);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_below_viewport_arms_autoscroll_down() {
|
||||
let mut app = create_test_app();
|
||||
app.history = vec![HistoryCell::Assistant {
|
||||
content: "alpha beta".to_string(),
|
||||
streaming: false,
|
||||
}];
|
||||
app.resync_history_revisions();
|
||||
app.viewport.transcript_cache.ensure(
|
||||
&app.history,
|
||||
&app.history_revisions,
|
||||
80,
|
||||
app.transcript_render_options(),
|
||||
);
|
||||
app.viewport.last_transcript_area = Some(Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 8,
|
||||
});
|
||||
app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();
|
||||
app.viewport.transcript_selection.dragging = true;
|
||||
app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
|
||||
line_index: 0,
|
||||
column: 0,
|
||||
});
|
||||
app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {
|
||||
line_index: 0,
|
||||
column: 0,
|
||||
});
|
||||
|
||||
handle_mouse_event(
|
||||
&mut app,
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||||
column: 4,
|
||||
row: 12, // below area.y + area.height (= 8)
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
);
|
||||
|
||||
let state = app.viewport.selection_autoscroll.expect("autoscroll armed");
|
||||
assert_eq!(state.direction, 1);
|
||||
assert_eq!(state.column, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_above_viewport_arms_autoscroll_up() {
|
||||
let mut app = create_test_app();
|
||||
app.viewport.last_transcript_area = Some(Rect {
|
||||
x: 5,
|
||||
y: 4,
|
||||
width: 40,
|
||||
height: 6,
|
||||
});
|
||||
app.viewport.transcript_selection.dragging = true;
|
||||
app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
|
||||
line_index: 5,
|
||||
column: 0,
|
||||
});
|
||||
app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {
|
||||
line_index: 5,
|
||||
column: 0,
|
||||
});
|
||||
|
||||
handle_mouse_event(
|
||||
&mut app,
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||||
column: 50, // outside horizontally too — clamped to area.x + width - 1
|
||||
row: 1, // above area.y (= 4)
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
);
|
||||
|
||||
let state = app.viewport.selection_autoscroll.expect("autoscroll armed");
|
||||
assert_eq!(state.direction, -1);
|
||||
assert_eq!(state.column, 5 + 40 - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_back_inside_disarms_autoscroll() {
|
||||
let mut app = create_test_app();
|
||||
app.history = vec![HistoryCell::Assistant {
|
||||
content: "alpha beta".to_string(),
|
||||
streaming: false,
|
||||
}];
|
||||
app.resync_history_revisions();
|
||||
app.viewport.transcript_cache.ensure(
|
||||
&app.history,
|
||||
&app.history_revisions,
|
||||
80,
|
||||
app.transcript_render_options(),
|
||||
);
|
||||
app.viewport.last_transcript_area = Some(Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 8,
|
||||
});
|
||||
app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();
|
||||
app.viewport.transcript_selection.dragging = true;
|
||||
app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
|
||||
line_index: 0,
|
||||
column: 0,
|
||||
});
|
||||
app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {
|
||||
line_index: 0,
|
||||
column: 0,
|
||||
});
|
||||
app.viewport.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
direction: 1,
|
||||
column: 4,
|
||||
next_tick: Instant::now(),
|
||||
});
|
||||
|
||||
handle_mouse_event(
|
||||
&mut app,
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||||
column: 6,
|
||||
row: 0, // inside area
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(app.viewport.selection_autoscroll.is_none());
|
||||
let head = app
|
||||
.viewport
|
||||
.transcript_selection
|
||||
.head
|
||||
.expect("head present");
|
||||
assert_eq!(head.column, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_up_clears_selection_autoscroll() {
|
||||
let mut app = create_test_app();
|
||||
app.viewport.transcript_selection.dragging = true;
|
||||
app.viewport.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
direction: -1,
|
||||
column: 0,
|
||||
next_tick: Instant::now(),
|
||||
});
|
||||
|
||||
handle_mouse_event(
|
||||
&mut app,
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Up(MouseButton::Left),
|
||||
column: 0,
|
||||
row: 0,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(app.viewport.selection_autoscroll.is_none());
|
||||
assert!(!app.viewport.transcript_selection.dragging);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_selection_autoscroll_advances_pending_scroll_when_due() {
|
||||
let mut app = create_test_app();
|
||||
app.viewport.last_transcript_area = Some(Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 8,
|
||||
});
|
||||
app.viewport.last_transcript_total = 200;
|
||||
app.viewport.transcript_selection.dragging = true;
|
||||
app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
|
||||
line_index: 0,
|
||||
column: 0,
|
||||
});
|
||||
app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {
|
||||
line_index: 0,
|
||||
column: 0,
|
||||
});
|
||||
let earlier = Instant::now() - Duration::from_millis(100);
|
||||
app.viewport.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
direction: 1,
|
||||
column: 10,
|
||||
next_tick: earlier,
|
||||
});
|
||||
|
||||
tick_selection_autoscroll(&mut app);
|
||||
|
||||
assert_eq!(app.viewport.pending_scroll_delta, 1);
|
||||
assert!(app.user_scrolled_during_stream);
|
||||
let next_tick = app
|
||||
.viewport
|
||||
.selection_autoscroll
|
||||
.expect("still armed")
|
||||
.next_tick;
|
||||
assert!(next_tick > earlier);
|
||||
let head = app
|
||||
.viewport
|
||||
.transcript_selection
|
||||
.head
|
||||
.expect("head extended");
|
||||
// Edge row for direction = +1 is the bottom of area (height - 1 = 7),
|
||||
// so head.line_index should equal last_transcript_top + 7.
|
||||
assert_eq!(head.line_index, 7);
|
||||
assert_eq!(head.column, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_selection_autoscroll_respects_cadence() {
|
||||
let mut app = create_test_app();
|
||||
app.viewport.last_transcript_area = Some(Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 8,
|
||||
});
|
||||
app.viewport.transcript_selection.dragging = true;
|
||||
let future = Instant::now() + Duration::from_secs(60);
|
||||
app.viewport.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
direction: 1,
|
||||
column: 0,
|
||||
next_tick: future,
|
||||
});
|
||||
|
||||
tick_selection_autoscroll(&mut app);
|
||||
|
||||
assert_eq!(app.viewport.pending_scroll_delta, 0);
|
||||
assert_eq!(
|
||||
app.viewport
|
||||
.selection_autoscroll
|
||||
.expect("still armed")
|
||||
.next_tick,
|
||||
future,
|
||||
"next_tick must not advance before its deadline"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_selection_autoscroll_clears_when_drag_ended() {
|
||||
let mut app = create_test_app();
|
||||
app.viewport.last_transcript_area = Some(Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 8,
|
||||
});
|
||||
app.viewport.transcript_selection.dragging = false;
|
||||
app.viewport.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
direction: 1,
|
||||
column: 0,
|
||||
next_tick: Instant::now() - Duration::from_millis(100),
|
||||
});
|
||||
|
||||
tick_selection_autoscroll(&mut app);
|
||||
|
||||
assert!(app.viewport.selection_autoscroll.is_none());
|
||||
assert_eq!(app.viewport.pending_scroll_delta, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_click_opens_context_menu() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Shared text helpers for TUI selection and clipboard workflows.
|
||||
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::{Line, Span};
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use crate::tui::history::HistoryCell;
|
||||
@@ -16,26 +16,31 @@ pub(super) fn history_cell_to_text(cell: &HistoryCell, width: u16) -> String {
|
||||
|
||||
fn line_to_string(line: Line<'static>) -> String {
|
||||
let mut out = String::new();
|
||||
for span in line.spans {
|
||||
if span.content.contains('\x1b') {
|
||||
osc8::strip_into(&span.content, &mut out);
|
||||
} else {
|
||||
out.push_str(&span.content);
|
||||
}
|
||||
}
|
||||
append_spans_plain(line.spans.iter(), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert a rendered transcript line to plain text, stripping OSC-8 link
|
||||
/// escape sequences. The caller is responsible for shifting selection columns
|
||||
/// to account for any visual-only rail prefix (see
|
||||
/// `TranscriptViewCache::rail_prefix_width`).
|
||||
pub(super) fn line_to_plain(line: &Line<'static>) -> String {
|
||||
let mut out = String::new();
|
||||
for span in &line.spans {
|
||||
append_spans_plain(line.spans.iter(), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn append_spans_plain<'a, I>(spans: I, out: &mut String)
|
||||
where
|
||||
I: Iterator<Item = &'a Span<'a>>,
|
||||
{
|
||||
for span in spans {
|
||||
if span.content.contains('\x1b') {
|
||||
osc8::strip_into(&span.content, &mut out);
|
||||
osc8::strip_into(&span.content, out);
|
||||
} else {
|
||||
out.push_str(span.content.as_ref());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn text_display_width(text: &str) -> usize {
|
||||
@@ -79,8 +84,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn line_to_plain_strips_osc_8_wrapper() {
|
||||
// A span carrying an OSC 8-wrapped URL must not leak the escape into
|
||||
// selection / clipboard output. The visible label survives.
|
||||
let wrapped = format!(
|
||||
"\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\",
|
||||
"https://example.com", "https://example.com"
|
||||
@@ -90,12 +93,46 @@ mod tests {
|
||||
Span::raw(wrapped),
|
||||
Span::raw(" for details"),
|
||||
]);
|
||||
assert_eq!(line_to_plain(&line), "see https://example.com for details");
|
||||
let text = line_to_plain(&line);
|
||||
assert_eq!(text, "see https://example.com for details");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_to_plain_passes_through_plain_spans() {
|
||||
let line = Line::from(vec![Span::raw("plain "), Span::raw("text")]);
|
||||
assert_eq!(line_to_plain(&line), "plain text");
|
||||
let text = line_to_plain(&line);
|
||||
assert_eq!(text, "plain text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_to_plain_includes_all_spans() {
|
||||
// Visual-only rail spans are stripped by the caller using
|
||||
// TranscriptViewCache::rail_prefix_width — line_to_plain itself
|
||||
// is a faithful span-to-string pass-through.
|
||||
let line = Line::from(vec![Span::raw("\u{2502} "), Span::raw("tool output")]);
|
||||
let text = line_to_plain(&line);
|
||||
assert_eq!(text, "\u{2502} tool output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_text_respects_column_bounds() {
|
||||
let text = "hello world";
|
||||
assert_eq!(slice_text(text, 0, 5), "hello");
|
||||
assert_eq!(slice_text(text, 6, 11), "world");
|
||||
assert_eq!(slice_text(text, 0, 0), "");
|
||||
assert_eq!(slice_text(text, 0, 100), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_text_handles_multibyte_characters() {
|
||||
let text = "a─b"; // U+2500 is 1 display column on supported terminals
|
||||
assert_eq!(slice_text(text, 1, 2), "─");
|
||||
assert_eq!(slice_text(text, 0, 3), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_text_truncates_at_end() {
|
||||
let text = "ab";
|
||||
assert_eq!(slice_text(text, 1, 5), "b");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "deepseek-tui",
|
||||
"version": "0.8.25",
|
||||
"deepseekBinaryVersion": "0.8.25",
|
||||
"version": "0.8.26",
|
||||
"deepseekBinaryVersion": "0.8.26",
|
||||
"description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.",
|
||||
"author": "Hmbown",
|
||||
"license": "MIT",
|
||||
|
||||
Reference in New Issue
Block a user