diff --git a/Cargo.lock b/Cargo.lock index 71a6685a..3333c934 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 3045ba43..a537da8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 1544145b..74d8f428 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -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" diff --git a/crates/tui/src/core/capacity.rs b/crates/tui/src/core/capacity.rs index 819e76ad..2a9e442a 100644 --- a/crates/tui/src/core/capacity.rs +++ b/crates/tui/src/core/capacity.rs @@ -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() { diff --git a/crates/tui/src/core/mod.rs b/crates/tui/src/core/mod.rs index 7e995bad..cd1c1251 100644 --- a/crates/tui/src/core/mod.rs +++ b/crates/tui/src/core/mod.rs @@ -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; diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index b3725990..1b9f4fc0 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -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; diff --git a/crates/tui/src/network_policy.rs b/crates/tui/src/network_policy.rs index f1e9a944..ba2332df 100644 --- a/crates/tui/src/network_policy.rs +++ b/crates/tui/src/network_policy.rs @@ -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"); } } diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 13b641a6..8b2ad522 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -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}`" diff --git a/crates/tui/src/runtime_log.rs b/crates/tui/src/runtime_log.rs new file mode 100644 index 00000000..98c4a7d3 --- /dev/null +++ b/crates/tui/src/runtime_log.rs @@ -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, + _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 { + 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 { + 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 { + 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"), + } + } + } +} diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 0f992a14..6c0e6219 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -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}; diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index ce7d7422..851743ec 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -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; diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index cc3948d6..abfb25bd 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -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)) } diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index f89ec799..60e83f86 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -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; diff --git a/crates/tui/src/tui/transcript.rs b/crates/tui/src/tui/transcript.rs index bf1f6198..9616a9c7 100644 --- a/crates/tui/src/tui/transcript.rs +++ b/crates/tui/src/tui/transcript.rs @@ -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 = Vec::new(); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index cdd45049..cf92fbdb 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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 diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index ea4858b5..7d290e7c 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -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() {