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
+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"] }