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:
Hunter Bown
2026-05-10 02:14:06 -05:00
committed by GitHub
33 changed files with 1964 additions and 147 deletions
+88
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -7,5 +7,5 @@ repository.workspace = true
description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture"
[dependencies]
deepseek-config = { path = "../config", version = "0.8.25" }
deepseek-config = { path = "../config", version = "0.8.26" }
serde.workspace = true
+9 -9
View File
@@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect
anyhow.workspace = true
axum.workspace = true
clap.workspace = true
deepseek-agent = { path = "../agent", version = "0.8.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
+7 -7
View File
@@ -14,13 +14,13 @@ path = "src/main.rs"
anyhow.workspace = true
clap.workspace = true
clap_complete.workspace = true
deepseek-agent = { path = "../agent", version = "0.8.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
View File
@@ -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"))
+1 -1
View File
@@ -8,7 +8,7 @@ description = "Config schema and precedence model for DeepSeek workspace archite
[dependencies]
anyhow.workspace = true
deepseek-secrets = { path = "../secrets", version = "0.8.25" }
deepseek-secrets = { path = "../secrets", version = "0.8.26" }
dirs.workspace = true
serde.workspace = true
toml.workspace = true
+8 -8
View File
@@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
deepseek-agent = { path = "../agent", version = "0.8.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
+1 -1
View File
@@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace
[dependencies]
anyhow.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.8.25" }
deepseek-protocol = { path = "../protocol", version = "0.8.26" }
serde.workspace = true
+1 -1
View File
@@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc
anyhow.workspace = true
async-trait.workspace = true
chrono.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.8.25" }
deepseek-protocol = { path = "../protocol", version = "0.8.26" }
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
+1 -1
View File
@@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.8.25" }
deepseek-protocol = { path = "../protocol", version = "0.8.26" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
+2 -2
View File
@@ -21,8 +21,8 @@ path = "src/main.rs"
[dependencies]
anyhow = "1.0.100"
arboard = "3.4"
deepseek-secrets = { path = "../secrets", version = "0.8.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
View File
@@ -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;
+9 -3
View File
@@ -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,
);
}
+136
View File
@@ -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);
}
}
+33 -1
View File
@@ -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
View File
@@ -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();
+10 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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::{
+23 -4
View File
@@ -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}",
+46 -4
View File
@@ -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(&registry).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();
+38 -1
View File
@@ -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()));
+54 -1
View File
@@ -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};
+45 -6
View File
@@ -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());
+95 -7
View File
@@ -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
+16
View File
@@ -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) {
+160 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+52 -15
View File
@@ -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");
}
}
+2 -2
View File
@@ -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",