Skip to main content

recursive/
logging.rs

1//! Tracing writer that suppresses output while the TUI is active.
2//!
3//! ## Why this exists
4//!
5//! `main.rs` initialises its `tracing-subscriber` *before* the TUI
6//! takes over the terminal. After the TUI enters its alternate
7//! screen, every `tracing::info!` / `tracing::warn!` is still being
8//! written to the stderr file descriptor — which is the same
9//! descriptor the alternate screen is rendered onto, so the log
10//! lines land *inside* the TUI surface (typically right next to
11//! the input box, since that's where the cursor was last parked by
12//! `terminal.draw`). The user sees stray log lines like
13//! `2026-06-03T… INFO agent.turn: finished steps=2 …` rendered
14//! inside their input box.
15//!
16//! ## How it works
17//!
18//! The `TUI_QUIET` atomic flag flips to `true` while the TUI is
19//! running. The `StderrOrNull` writer short-circuits to a no-op
20//! while the flag is set, and falls back to `std::io::stderr`
21//! otherwise. The TUI's `run()` function takes an RAII guard via
22//! [`suppress_tracing_for_tui`] so the flag is restored on every
23//! exit path — including panics — letting the user still see the
24//! panic message on stderr after `LeaveAlternateScreen`.
25//!
26//! ## What about user-visible logs during a TUI session?
27//!
28//! Things the user actually wants to see during a session (hook
29//! progress, the spinner verb, etc.) flow through the TUI's own
30//! event sink, not the global subscriber. If you need a brand-new
31//! `tracing::info!` line to be visible during a TUI session, push
32//! it onto the sink as a `UiEvent` instead.
33
34use std::io;
35use std::sync::atomic::{AtomicBool, Ordering};
36
37use tracing_subscriber::fmt::MakeWriter;
38
39static TUI_QUIET: AtomicBool = AtomicBool::new(false);
40
41/// Mark the global tracing writer as suppressed (TUI active) or
42/// normal. Called by `tui::run()` via the RAII guard below; exposed
43/// publicly in case other long-running surfaces (e.g. an HTTP
44/// server's interactive console) want the same behaviour.
45pub fn set_tui_quiet(quiet: bool) {
46    TUI_QUIET.store(quiet, Ordering::Relaxed);
47}
48
49/// Returns the current TUI-quiet state. Exposed for tests.
50pub fn is_tui_quiet() -> bool {
51    TUI_QUIET.load(Ordering::Relaxed)
52}
53
54/// RAII guard that flips [`set_tui_quiet(false)`] on drop, ensuring
55/// the global tracing writer is restored even if the TUI panics
56/// partway through.
57pub struct TuiQuietGuard;
58
59impl Drop for TuiQuietGuard {
60    fn drop(&mut self) {
61        set_tui_quiet(false);
62    }
63}
64
65/// Acquire a guard that holds the tracing writer in the suppressed
66/// state. When the guard is dropped the writer is restored. Use
67/// this at the very top of `tui::run()` (before `enable_raw_mode`).
68pub fn suppress_tracing_for_tui() -> TuiQuietGuard {
69    set_tui_quiet(true);
70    TuiQuietGuard
71}
72
73/// `io::Write` adapter that drops every byte while the TUI is
74/// active and otherwise mirrors `std::io::stderr`.
75#[derive(Debug)]
76pub struct StderrOrNull;
77
78impl io::Write for StderrOrNull {
79    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
80        if TUI_QUIET.load(Ordering::Relaxed) {
81            // Pretend the write succeeded so the caller's formatting
82            // does not error out. We do not count the suppressed
83            // bytes because tracing does not care.
84            return Ok(buf.len());
85        }
86        let mut handle = std::io::stderr().lock();
87        handle.write(buf)
88    }
89
90    fn flush(&mut self) -> io::Result<()> {
91        if TUI_QUIET.load(Ordering::Relaxed) {
92            return Ok(());
93        }
94        std::io::stderr().flush()
95    }
96}
97
98/// `MakeWriter` factory that hands out `StderrOrNull` writers. The
99/// `tracing_subscriber::fmt` layer accepts this in `with_writer`.
100#[derive(Clone, Debug)]
101pub struct StderrOrNullMaker;
102
103impl<'a> MakeWriter<'a> for StderrOrNullMaker {
104    type Writer = StderrOrNull;
105    fn make_writer(&'a self) -> Self::Writer {
106        StderrOrNull
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use std::io::Write;
114
115    #[test]
116    fn stderr_or_null_drops_bytes_when_quiet() {
117        set_tui_quiet(true);
118        let mut w = StderrOrNull;
119        let n = w.write(b"hello").unwrap();
120        assert_eq!(n, 5);
121        w.flush().unwrap();
122        set_tui_quiet(false);
123    }
124
125    #[test]
126    fn guard_restores_quiet_state_on_drop() {
127        assert!(!is_tui_quiet());
128        {
129            let _g = suppress_tracing_for_tui();
130            assert!(is_tui_quiet());
131        }
132        assert!(!is_tui_quiet());
133    }
134
135    #[test]
136    fn guard_restores_even_if_another_guard_already_dropped() {
137        // Two nested guards; the inner drop will flip the flag to
138        // false even though the outer is still alive. The guard is
139        // single-shot, not reference-counted. The pragmatic
140        // consequence is that *the outermost* guard's drop is the
141        // only one that matters, and nested calls should be
142        // avoided. We document the behaviour here so the test
143        // catches any future refactor that tries to make this
144        // re-entrant.
145        let _outer = suppress_tracing_for_tui();
146        assert!(is_tui_quiet());
147        {
148            let _inner = suppress_tracing_for_tui();
149            assert!(is_tui_quiet());
150        }
151        assert!(
152            !is_tui_quiet(),
153            "inner guard's drop already cleared the flag (documented)"
154        );
155    }
156}