fix(tui): dup fd 1 to /dev/null during turns — no more output bleeding

THE REAL FIX: Before each model turn, save fd 1 (stdout) via dup(),
redirect it to /dev/null via dup2(), run the turn, then restore the
original fd. This catches EVERY possible stdout write during the turn:
runtime streaming, tool executor formatted output, bash subprocess
inheritance, crossterm escape codes, println! — all silently swallowed.

Previous approaches (emit_output=false, Vec<u8> buffer, screen clear)
were insufficient because the runtime's consume_stream writes to the
real io::stdout() through separate code paths that bypass our buffer.

Changed workspace unsafe_code from forbid→warn, and allow it in
rusty-claude-cli specifically. The dup/dup2/close calls are
single-threaded POSIX syscalls with well-understood semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
TheArchitectit 2026-06-12 11:11:19 -05:00
parent 99c4650ac7
commit 8f1ad0dc9a
4 changed files with 35 additions and 16 deletions

1
rust/Cargo.lock generated
View File

@ -2398,6 +2398,7 @@ dependencies = [
"api",
"commands",
"crossterm",
"libc",
"log",
"mock-anthropic-service",
"once_cell",

View File

@ -12,7 +12,9 @@ publish = false
serde_json = "1"
[workspace.lints.rust]
unsafe_code = "forbid"
# rusty-claude-cli needs unsafe for TUI stdout suppression (dup/dup2),
# all other crates should avoid it — override per-crate if needed.
unsafe_code = "warn"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }

View File

@ -26,11 +26,19 @@ syntect = "5"
tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
tools = { path = "../tools" }
log = "0.4"
libc = "0.2"
unicode-width = "0.2"
[lints]
workspace = true
[lints.rust]
unsafe_code = "allow"
[lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "allow", priority = -1 }
module_name_repetitions = "allow"
missing_panics_doc = "allow"
missing_errors_doc = "allow"
[dev-dependencies]
mock-anthropic-service = { path = "../mock-anthropic-service" }

View File

@ -7295,22 +7295,30 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
update_dashboard(&dashboard_state, &cli);
app.set_status("Thinking...");
// Run the turn into a buffer instead of stdout.
// This is the KEY fix: no bytes hit the alternate screen during
// the turn, so nothing bleeds past the conversation pane boundary.
// ── Nuclear stdout suppression ──
// dup fd 1 (stdout), redirect to /dev/null, run the turn,
// then restore. This catches ALL writes to stdout: runtime
// streaming, tool executor output, child processes, println!,
// crossterm escape codes — everything hits /dev/null.
let saved_fd = unsafe { libc::dup(1) };
let devnull = unsafe {
libc::open(
b"/dev/null\0".as_ptr() as *const i8,
libc::O_WRONLY | libc::O_CLOEXEC,
)
};
if devnull >= 0 {
unsafe { libc::dup2(devnull, 1); }
unsafe { libc::close(devnull); }
}
let mut buf: Vec<u8> = Vec::new();
let result = cli.run_turn_to(&trimmed, &mut buf, false);
// Wipe the alternate screen in case any runtime internals
// wrote to the real stdout fd despite emit_output=false
// (e.g. child process stderr, tokio tracing, etc.)
{
use crossterm::terminal::ClearType;
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::terminal::Clear(ClearType::All),
crossterm::cursor::MoveTo(0, 0),
);
// Restore fd 1 (stdout)
if saved_fd >= 0 {
unsafe { libc::dup2(saved_fd, 1); }
unsafe { libc::close(saved_fd); }
}
// Read the last assistant message from the session for