fix(tui): close the scroll-demon class structurally (#1085 regression)

Issue #1085 ("TUI viewport drifts down inside alt-screen at end of
turn, leaving top rows blank, esp. after sub-agents") was closed in
v0.8.18 by adding `reset_terminal_viewport()` to home the cursor on
TurnComplete / focus / resize. v0.8.27's flicker fix (`abf3fa66f`)
dropped the `\x1b[2J\x1b[3J` deep-clear from that path to stop the
double-clear flicker on Ghostty / VSCode / Win10 conhost. That left
ratatui's incremental-diff renderer relying on its internal model
matching reality — which only holds while nothing else writes to
the terminal.

Two latent `eprintln!` sites had been quietly emitting raw bytes
into the alt-screen for the entire v0.8.x cycle:

* `tools/subagent/mod.rs::persist_state_best_effort` (fires whenever
  the per-step sub-agent state save hits an error; under parallel
  sub-agents this can fire dozens of times per turn)
* `tools/subagent/mod.rs::new_shared_subagent_manager` (fires once
  on init if the prior state file fails to load)

Plus a third found during this fix:

* `network_policy.rs::record` (fires every time a network-policy
  audit write fails)

Each eprintln advanced the alt-screen cursor by one row and
scrolled the buffer up by one row, but ratatui's renderer didn't
know — it kept writing to absolute row positions, which now meant
"one row higher than visible." After ~30 leaks the TUI content
appeared to drift downward, with a blank band growing above the
header. v0.8.18's periodic full-clear had been masking it; v0.8.27's
flicker fix unmasked it.

Three layers of defence so this class of bug "isn't an option
anymore":

1. **`crates/tui/src/runtime_log.rs` — file-backed tracing
   subscriber + Unix fd-level stderr redirect.** A daily-rolling log
   file at `~/.deepseek/logs/tui-YYYY-MM-DD.log` is created at TUI
   startup (right after `EnterAlternateScreen`). A
   `tracing-subscriber` registry routes `tracing::warn!` /
   `tracing::error!` calls to it. On Unix, the process's stderr fd
   is `dup2`'d to the same file for the lifetime of the
   `TuiLogGuard`. Any future raw `eprintln!` — ours, a panic
   message, a third-party crate's verbose output — lands in the log
   file instead of the alt-screen. The guard restores the original
   stderr fd on drop so shutdown messages still reach the user's
   terminal.

2. **`tracing::warn!` replacements** for the three known leak sites
   (`subagent/mod.rs` ×2, `network_policy.rs` ×1). With (1) in
   place these messages now go to the log file with structured
   fields (`?err`, `host`, `tool`) instead of opaque text rows in
   the alt-screen.

3. **Module-level
   `#![deny(clippy::print_stdout, clippy::print_stderr)]`** on
   `tools/`, `core/`, `tui/`, `runtime_threads.rs`, and
   `network_policy.rs`. Any future `eprintln!` / `println!` added
   to a TUI runtime path fails the lint at compile time.
   Legitimate CLI-print paths (`main.rs` eval / init / doctor,
   `runtime_api.rs` server banners, `logging.rs` verbose helpers,
   `skills/mod.rs` listing utilities, `execpolicy/execpolicycheck.rs`
   JSON output, `ui::run_event_loop` post-`LeaveAlternateScreen`
   resume hint, two `#[test] #[ignore]` perf benches in
   `tui/transcript.rs` / `tui/widgets/mod.rs` / `core/capacity.rs`)
   keep their existing prints — they all run outside the alt-screen
   lifetime.

The dup2 redirect is Unix-only because there's no equivalent stable
Rust API for fd-redirecting `STDERR_FILENO` on Windows; on Windows
the tracing-subscriber layer + the clippy denies still apply, and
ratatui's own use of crossterm avoids the worst leakage classes.
Cross-platform stderr redirect via `SetStdHandle` is a follow-up.

The new `runtime_log` module ships with one test
(`log_directory_prefers_home`) that pins the `HOME` /
`USERPROFILE` / `dirs::home_dir()` resolution order — uses the
process-wide `test_support::lock_test_env()` lock for env-mutation
safety. Two `#[test] #[ignore]` benches in
`tui/transcript.rs` (rail-prefix memory) and `tui/widgets/mod.rs`
(transcript scroll bench) and one in `core/capacity.rs`
(`bench_compute_profile`) keep their stdout prints via
`#[allow(clippy::print_stdout)]` on the individual test.

New dependencies: `tracing-subscriber 0.3` (env-filter + fmt
features) and `tracing-appender 0.2` at the workspace root, both
pulled into `crates/tui` only.

Closes the v0.8.28 regression Hunter reported in screenshots:
parallel sub-agents running `exec_shell` triggered the scroll
demon with the TUI content squeezed into the bottom third of the
terminal and ~30 rows of blank above the header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-10 20:20:25 -05:00
parent 602f7b5f1c
commit e4255539fc
16 changed files with 420 additions and 18 deletions
Generated
+108 -13
View File
@@ -160,7 +160,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -171,7 +171,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -813,7 +813,7 @@ version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -938,6 +938,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -1338,6 +1347,8 @@ dependencies = [
"toml 0.9.11+spec-1.1.0",
"tower-http",
"tracing",
"tracing-appender",
"tracing-subscriber",
"unicode-segmentation",
"unicode-width 0.2.0",
"uuid",
@@ -1502,7 +1513,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1688,7 +1699,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1803,7 +1814,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix 1.1.3",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -2579,7 +2590,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2941,6 +2952,15 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.8.4"
@@ -3120,6 +3140,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num"
version = "0.4.3"
@@ -4125,7 +4154,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -4138,7 +4167,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4195,7 +4224,7 @@ dependencies = [
"security-framework 3.5.1",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4620,6 +4649,15 @@ dependencies = [
"digest 0.11.2",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shared_library"
version = "0.1.9"
@@ -4938,7 +4976,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.3",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5073,6 +5111,15 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "tiff"
version = "0.10.3"
@@ -5355,6 +5402,18 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf"
dependencies = [
"crossbeam-channel",
"thiserror 2.0.17",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
@@ -5373,6 +5432,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -5410,7 +5499,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset 0.9.1",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -5528,6 +5617,12 @@ dependencies = [
"vsimd",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -5831,7 +5926,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
+2
View File
@@ -47,4 +47,6 @@ toml = "0.9.7"
sha2 = "0.10"
tower-http = { version = "0.6", features = ["cors"] }
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
uuid = { version = "1.11", features = ["v4"] }
+2
View File
@@ -55,6 +55,8 @@ chrono = { version = "0.4", features = ["serde"] }
tempfile = "3.16"
thiserror = "2.0"
tracing = "0.1"
tracing-appender = { workspace = true }
tracing-subscriber = { workspace = true }
tower-http = { version = "0.6", features = ["cors"] }
wait-timeout = "0.2"
multimap = "0.10.0"
+3
View File
@@ -772,6 +772,9 @@ mod tests {
/// Establishes a baseline cost so we can detect regressions when the
/// observation cadence is high (50+ message turns × per-step calls). Adds
/// no dev-deps; we measure with `Instant` and print rather than gating CI.
// Perf bench prints per-call timing — runs in `cargo test`, never
// inside the TUI alt-screen.
#[allow(clippy::print_stdout)]
#[test]
#[ignore]
fn bench_compute_profile() {
+5
View File
@@ -9,6 +9,11 @@
//! - `session`: Session state management
//! - `turn`: Turn context and tracking
// Engine code runs inside the TUI alt-screen — see `runtime_log` for why
// raw stdio prints must not appear here. Use `tracing::*` instead.
#![deny(clippy::print_stdout)]
#![deny(clippy::print_stderr)]
pub mod capacity;
pub mod capacity_memory;
pub mod coherence;
+1
View File
@@ -54,6 +54,7 @@ pub mod repl;
mod retry_status;
pub mod rlm;
mod runtime_api;
mod runtime_log;
mod runtime_threads;
mod sandbox;
mod schema_migration;
+9 -1
View File
@@ -3,6 +3,10 @@
// approval-modal hook that v0.7.x adds incrementally. Dead-code warnings
// would otherwise be noisy until those call sites land.
#![allow(dead_code)]
// Audit-write failure must route through `tracing::*`, not raw stderr —
// see `runtime_log` for the scroll-demon rationale.
#![deny(clippy::print_stdout)]
#![deny(clippy::print_stderr)]
//! Per-domain network policy for outbound network calls (#135).
//!
@@ -290,7 +294,11 @@ impl NetworkAuditor {
return;
}
if let Err(err) = self.try_record(host, tool, decision_label) {
eprintln!("network audit write failed: {err}");
// Routed through tracing so it lands in
// `~/.deepseek/logs/tui-YYYY-MM-DD.log` rather than the
// alt-screen — see `runtime_log` for the scroll-demon
// rationale.
tracing::warn!(target: "network_policy", ?err, host, tool, "network audit write failed");
}
}
+4 -1
View File
@@ -569,7 +569,10 @@ mod tests {
// Explicit-user-override clause keeps the prompt useful for the
// opposite preference (#1118 commenters who want English
// thinking for token-cost reasons).
for phrase in ["think in English", "\u{7528}\u{82F1}\u{6587}\u{601D}\u{8003}"] {
for phrase in [
"think in English",
"\u{7528}\u{82F1}\u{6587}\u{601D}\u{8003}",
] {
assert!(
lang.contains(phrase),
"expected the user-override example `{phrase}`"
+221
View File
@@ -0,0 +1,221 @@
//! TUI runtime logging. Initializes a `tracing-subscriber` that writes to a
//! daily-rolling file under `~/.deepseek/logs/`, and (on Unix) redirects the
//! process's `stderr` fd to that same file for the lifetime of the alt-screen
//! TUI.
//!
//! Why this exists:
//!
//! The TUI runs inside an alt-screen buffer drawn by `ratatui` using an
//! incremental diff renderer. The renderer assumes nothing else is writing
//! to the terminal — its internal "current cells" model is the only source
//! of truth for what's on screen. If anything emits raw bytes to stdout or
//! stderr while the alt-screen is active (an `eprintln!` from a sub-agent,
//! a `tracing` warning that defaulted to `stderr`, a panic message, a
//! third-party crate's verbose output, …) those bytes land in the alt-screen
//! buffer at the current cursor position, scroll the buffer up, and leave
//! the renderer's model out of sync with reality. The visible symptom is
//! "scroll demon": the TUI content drifts down, leaving a band of blank
//! rows above the header. This was the regression in issue #1085 (fixed in
//! v0.8.18 by adding a viewport-reset path) and re-surfaced in v0.8.27
//! when the flicker fix dropped the `\x1b[2J\x1b[3J` deep-clear that had
//! been masking the underlying leak.
//!
//! Defence-in-depth:
//! 1. A `tracing-subscriber` writes formatted logs to
//! `~/.deepseek/logs/tui-YYYY-MM-DD.log` so `tracing::warn!` /
//! `tracing::error!` calls go somewhere observable instead of
//! disappearing into the void (the TUI previously had no global
//! subscriber, so contributors reached for `eprintln!`).
//! 2. On Unix the process's stderr fd is redirected (via `dup2`) to the
//! same log file for the lifetime of `TuiLogGuard`. Any raw stderr
//! write — ours, a dependency's, a panic message — lands in the log
//! file instead of the alt-screen. The guard restores the original
//! stderr fd on drop so post-TUI shutdown messages still reach the
//! user's terminal.
//! 3. Crate-level `#![deny(clippy::print_stderr, clippy::print_stdout)]`
//! on the TUI runtime modules forbids new `eprintln!` / `println!`
//! calls at compile time. CLI-output paths (`main.rs` eval, init,
//! `runtime_api::print_*`, `logging::info`/`warn`) keep their existing
//! prints via `#[allow(clippy::print_stderr)]` because they run before
//! the alt-screen is entered.
use std::fs::{self, File, OpenOptions};
use std::path::PathBuf;
use anyhow::{Context, Result};
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
/// Owns the active tracing subscriber and (on Unix) a saved copy of the
/// original `stderr` fd so it can be restored on drop. Dropped when the TUI
/// exits the alt-screen.
pub struct TuiLogGuard {
#[cfg(unix)]
saved_stderr_fd: Option<libc::c_int>,
_file: File,
// Exposed via `log_path()` for diagnostics (e.g. `/doctor`,
// `--print-log-path`). Currently no caller — keep the accessor
// wired up so adding one later doesn't require revisiting the
// guard struct.
#[allow(dead_code)]
log_path: PathBuf,
}
impl TuiLogGuard {
/// Path the subscriber is writing to.
#[allow(dead_code)]
#[must_use]
pub fn log_path(&self) -> &std::path::Path {
&self.log_path
}
}
#[cfg(unix)]
impl Drop for TuiLogGuard {
fn drop(&mut self) {
if let Some(saved) = self.saved_stderr_fd.take() {
// SAFETY: `saved` came from `libc::dup` of the original stderr
// fd in `init`; calling `dup2` to restore it is the standard
// pairing. If `dup2` fails we just leak the saved fd — the
// process is exiting anyway.
unsafe {
let _ = libc::dup2(saved, libc::STDERR_FILENO);
let _ = libc::close(saved);
}
}
}
}
#[cfg(not(unix))]
impl Drop for TuiLogGuard {
fn drop(&mut self) {}
}
/// Initialize the TUI logging subsystem. Idempotent across re-entry by way
/// of `set_default` — if a global subscriber is already set we still install
/// the stderr redirect.
///
/// Returns a guard that must outlive the alt-screen session. Drop it after
/// `LeaveAlternateScreen` so any shutdown messages reach the user.
pub fn init() -> Result<TuiLogGuard> {
let log_dir = log_directory().context("could not resolve TUI log directory")?;
fs::create_dir_all(&log_dir)
.with_context(|| format!("failed to create {}", log_dir.display()))?;
let date = chrono::Local::now().format("%Y-%m-%d");
let log_path = log_dir.join(format!("tui-{date}.log"));
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.with_context(|| format!("failed to open {}", log_path.display()))?;
// The tracing-subscriber consumes a clone of the file handle for its
// writer. We keep our own handle for the dup2 redirect below — we need
// the same on-disk file but a separate fd so the subscriber's writes
// and the raw-stderr writes don't fight over the same kernel offset.
let subscriber_file = file
.try_clone()
.context("failed to clone log file handle for subscriber")?;
let env_filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("info"))
.unwrap_or_else(|_| EnvFilter::new("info"));
let subscriber = tracing_subscriber::registry().with(env_filter).with(
fmt::layer()
.with_writer(move || {
subscriber_file
.try_clone()
.expect("clone log file handle for tracing writer")
})
.with_ansi(false)
.with_target(true)
.with_thread_ids(false),
);
// Best-effort: if a subscriber is already set (e.g., re-entry, or a
// host process installed one), we skip ours rather than panic. The
// stderr redirect below still happens.
let _ = tracing::subscriber::set_global_default(subscriber);
#[cfg(unix)]
let saved_stderr_fd = redirect_stderr_to(&file).ok();
Ok(TuiLogGuard {
#[cfg(unix)]
saved_stderr_fd,
_file: file,
log_path,
})
}
fn log_directory() -> Option<PathBuf> {
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from)
&& !home.as_os_str().is_empty()
{
return Some(home.join(".deepseek").join("logs"));
}
if let Some(userprofile) = std::env::var_os("USERPROFILE").map(PathBuf::from)
&& !userprofile.as_os_str().is_empty()
{
return Some(userprofile.join(".deepseek").join("logs"));
}
dirs::home_dir().map(|h| h.join(".deepseek").join("logs"))
}
#[cfg(unix)]
fn redirect_stderr_to(file: &File) -> Result<libc::c_int> {
use std::os::fd::AsRawFd;
let target = file.as_raw_fd();
// SAFETY: `libc::dup` and `libc::dup2` are the documented fd-management
// primitives. We save the current stderr fd before reassigning so the
// guard can restore it on drop.
unsafe {
let saved = libc::dup(libc::STDERR_FILENO);
if saved < 0 {
return Err(
anyhow::Error::from(std::io::Error::last_os_error()).context("dup(STDERR_FILENO)")
);
}
if libc::dup2(target, libc::STDERR_FILENO) < 0 {
let err = std::io::Error::last_os_error();
let _ = libc::close(saved);
return Err(anyhow::Error::from(err).context("dup2(log_file, STDERR_FILENO)"));
}
Ok(saved)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn log_directory_prefers_home() {
let _lock = crate::test_support::lock_test_env();
let tmp = tempfile::TempDir::new().unwrap();
let prev_home = std::env::var_os("HOME");
let prev_userprofile = std::env::var_os("USERPROFILE");
// SAFETY: serialised by lock_test_env.
unsafe {
std::env::set_var("HOME", tmp.path());
std::env::set_var("USERPROFILE", "");
}
let resolved = log_directory().expect("log_directory should resolve");
assert_eq!(resolved, tmp.path().join(".deepseek").join("logs"));
// SAFETY: cleanup under the same lock.
unsafe {
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match prev_userprofile {
Some(v) => std::env::set_var("USERPROFILE", v),
None => std::env::remove_var("USERPROFILE"),
}
}
}
}
+7
View File
@@ -3,6 +3,13 @@
//! This module keeps DeepSeek-only execution while exposing Codex-like lifecycle
//! semantics (threads, turns, items, interrupt/steer, and replayable events).
// Background-task runtime — runs alongside the TUI. Raw stdio prints
// here would still land in the alt-screen on whichever terminal the
// foreground TUI happens to own. Route everything through `tracing::*`
// instead — see `runtime_log` for the rationale.
#![deny(clippy::print_stdout)]
#![deny(clippy::print_stderr)]
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
+8
View File
@@ -1,5 +1,13 @@
//! Tool system modules and re-exports.
// Tools run inside the TUI alt-screen runtime. Raw `print!` / `eprintln!`
// inside this module tree leaks into ratatui's diff-renderer buffer and
// produces the "scroll demon" regression (#1085 / v0.8.27 follow-up).
// Route status/error reporting through `tracing::*` instead — the
// `runtime_log` subscriber captures it to `~/.deepseek/logs/`.
#![deny(clippy::print_stdout)]
#![deny(clippy::print_stderr)]
pub mod apply_patch;
pub mod approval_cache;
pub mod arg_repair;
+8 -2
View File
@@ -956,7 +956,11 @@ impl SubAgentManager {
fn persist_state_best_effort(&self) {
if let Err(err) = self.persist_state() {
eprintln!("Failed to persist sub-agent state: {err}");
// Must not be `eprintln!` — raw stderr inside the alt-screen
// leaks into the buffer and produces the scroll-demon
// regression (#1085). Routed through tracing so the
// file-backed subscriber in `runtime_log` captures it.
tracing::warn!(target: "subagent", ?err, "failed to persist sub-agent state");
}
}
@@ -1539,7 +1543,9 @@ pub fn new_shared_subagent_manager(workspace: PathBuf, max_agents: usize) -> Sha
let state_path = default_state_path(&workspace);
let mut manager = SubAgentManager::new(workspace, max_agents).with_state_path(state_path);
if let Err(err) = manager.load_state() {
eprintln!("Failed to load sub-agent state: {err}");
// Routed through tracing instead of stderr — see comment in
// `persist_state_best_effort` above.
tracing::warn!(target: "subagent", ?err, "failed to load sub-agent state");
}
Arc::new(RwLock::new(manager))
}
+9
View File
@@ -1,5 +1,14 @@
//! Terminal UI (TUI) module for `DeepSeek` CLI.
// The rendering layer runs inside the alt-screen. Raw stdio prints
// produce the scroll demon (see `runtime_log` for full context). Use
// `tracing::*` for diagnostics — `runtime_log` captures it to disk.
// `ui::run_event_loop` legitimately prints a post-exit resume hint
// AFTER `LeaveAlternateScreen`; that single site uses
// `#[allow(clippy::print_stdout)]` locally.
#![deny(clippy::print_stdout)]
#![deny(clippy::print_stderr)]
// === Submodules ===
pub mod active_cell;
+3
View File
@@ -916,6 +916,9 @@ mod tests {
/// 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 prints memory-overhead diagnostics — runs in `cargo test`, never
// inside the TUI alt-screen, so the module-level deny doesn't apply.
#[allow(clippy::print_stderr)]
#[test]
fn rail_prefix_widths_memory_overhead_complex_session() {
let mut cells: Vec<HistoryCell> = Vec::new();
+27 -1
View File
@@ -226,6 +226,24 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
if use_alt_screen {
execute!(stdout, EnterAlternateScreen)?;
}
// Initialize the file-backed TUI log and (on Unix) redirect raw stderr
// away from the alt-screen for the lifetime of this guard. Any
// `eprintln!`, panic message, or third-party stderr write that would
// otherwise leak into the alt-screen buffer and shift ratatui's
// diff-renderer view (the "scroll demon" reported in #1085) now lands
// in `~/.deepseek/logs/tui-YYYY-MM-DD.log` instead. The guard is held
// until the function returns; dropping it (after `LeaveAlternateScreen`
// below) restores the original stderr fd so shutdown messages reach
// the user's terminal. We accept the init failing (e.g., read-only
// `$HOME`) and continue without the redirect rather than refusing to
// start the TUI.
let _tui_log_guard = match crate::runtime_log::init() {
Ok(guard) => Some(guard),
Err(err) => {
tracing::warn!(target: "runtime_log", ?err, "TUI log init failed; stderr leaks may render as scroll-demon");
None
}
};
// Mouse capture, bracketed paste, focus events, and the Kitty
// keyboard-protocol escape-disambiguation flag (#442). Single source
// of truth shared with the FocusGained recovery path and
@@ -447,7 +465,15 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
if result.is_ok()
&& let Some(hint) = format_resume_hint(app.current_session_id.as_deref())
{
println!("{hint}");
// Printed AFTER `LeaveAlternateScreen` / `drop(terminal)` above,
// so we're back on the primary screen — this is the one
// legitimate stdout write in the TUI module tree. The
// module-level `#![deny(clippy::print_stdout)]` would otherwise
// refuse it.
#[allow(clippy::print_stdout)]
{
println!("{hint}");
}
}
result
+3
View File
@@ -3104,6 +3104,9 @@ mod tests {
///
/// Run with: `cargo test -p deepseek-tui --release bench_transcript_scroll
/// -- --ignored --nocapture`
// Perf bench prints timing rows to stdout — runs in `cargo test`,
// never inside the TUI alt-screen.
#[allow(clippy::print_stdout)]
#[test]
#[ignore = "perf bench; run with --release"]
fn bench_transcript_scroll_5000_messages() {