supercode-cli 0.5.80

Volter Harness — a lightweight, fully-customizable AI coding agent CLI in Rust. Any model via OpenRouter; natively continues Claude Code and Codex sessions.
//! UX-15: a lightweight "Thinking… (Ns)" progress indicator shown on stderr
//! while `supercode` is waiting on the model (or a long local operation like
//! `audit`/`convert`), so the terminal doesn't look frozen.
//!
//! Design goals (see `.volter/tracker/markdown/UX-15.md`):
//! - Zero-dependency: a plain thread + a couple of atomics, no spinner crate.
//! - Byte-clean on any non-interactive path: `run --output-format json`,
//!   piped/redirected output, `NO_COLOR`, or a dumb terminal must never see a
//!   single spinner byte on stdout *or* stderr. Achieved by never
//!   constructing/starting a [`Spinner`] on those paths (the gating predicate
//!   below, mirroring `ui::detect_color_level`'s pure-function shape) rather
//!   than trying to suppress output after the fact.
//! - Never touches stdout: all rendering goes to stderr, so the model's
//!   streamed answer (and any `--output-format json` payload) is never at
//!   risk of interleaving with spinner bytes.
//! - Cleared the instant real output starts: callers stop the spinner before
//!   printing anything of their own.
//!
//! ## UX-39: live streaming token counter
//!
//! Once tokens start streaming, [`Spinner`] ALSO drives a live "this turn: ~N
//! tok · session: ~M tok" status line (`record_delta`/`render_counter`/
//! `clear_counter`) — deliberately folded into this same struct rather than
//! adding a second thing that writes to stderr, so there is only ever one
//! owner of the status line:
//! - before the first token: the background-thread "Thinking… (Ns)"
//!   animation (unchanged by this ticket);
//! - once tokens are streaming: the synchronous counter below, drawn from
//!   the SAME calling thread that already invokes `stop()` at the top of
//!   every sink event — by the time a counter redraw happens, the animation
//!   thread has always already been joined, so the two can never race on
//!   the same line.
//!
//! Counts are the documented `ceil(utf8_bytes / 4)` estimator
//! (`supercode_runtime::estimate_tokens`) applied to a running byte total —
//! supercode has no real tokenizer, and more fundamentally, providers only
//! report exact `Usage.completion_tokens` once at the END of a round-trip,
//! never incrementally per delta, so a genuinely LIVE count can only ever be
//! an estimate. Always rendered with a leading `~` so it reads as one,
//! matching every other estimated figure in the codebase. The exact,
//! provider-reported total remains available on request via `/tokens`
//! (`Agent::total_output_tokens()`), unaffected by any of this. Gated by the
//! exact same `enabled` decision as the spinner (tty/quiet/`NO_COLOR`/dumb
//! term) — see `should_show_spinner`.

use std::io::{IsTerminal, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};

/// Braille frames — cheap, small, and legible even on a narrow terminal.
const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];

/// How long to wait before the first frame renders, so a fast (sub-second)
/// response never flashes a spinner the user barely perceives.
const START_DELAY: Duration = Duration::from_millis(200);

/// How often the frame/elapsed-time text is redrawn once visible.
const FRAME_INTERVAL: Duration = Duration::from_millis(90);

/// How often the background thread re-checks the stop flag while waiting
/// (keeps `stop()` responsive without busy-spinning).
const POLL_INTERVAL: Duration = Duration::from_millis(10);

/// UX-39: minimum gap between consecutive live-counter redraws — the same
/// spirit as `FRAME_INTERVAL` (the spinner's own redraw cadence): frequent
/// enough to feel live, far too coarse to flood the terminal with a `\r`
/// write on every single small delta a provider streams (many APIs send
/// deltas a few characters at a time).
const COUNTER_THROTTLE: Duration = Duration::from_millis(120);

/// Pure decision function for "should a spinner ever be shown?" — no I/O, so
/// it's unit-testable without a real tty or mutated env vars, the same shape
/// as `ui::detect_color_level`.
///
/// A spinner is suppressed when:
/// - `NO_COLOR` is set (the spinner is cursor-control/animation, not color,
///   but UX-15's AC groups it with the other chrome `NO_COLOR` silences);
/// - the caller is in `--quiet`/non-interactive mode (`quiet`);
/// - `TERM=dumb` (no cursor control the spinner could rely on); or
/// - stderr isn't a terminal (piped/redirected — the spinner writes there,
///   so this is the byte-cleanliness gate for `run ... | cat` /
///   `run --output-format json` / CI).
pub(crate) fn should_show_spinner(
    no_color: bool,
    quiet: bool,
    term_dumb: bool,
    stderr_is_tty: bool,
) -> bool {
    !(no_color || quiet || term_dumb || !stderr_is_tty)
}

/// Real-world gating: reads `NO_COLOR`/`TERM` and stderr's tty-ness. `quiet`
/// is threaded in by the caller — UX-38 landed the real `--quiet`/`-q` CLI
/// flag, so every call site now passes `main.rs`'s `effective_quiet(cli)`
/// (`cli.quiet || env_quiet()`), not a bare env check.
fn should_show_spinner_now(quiet: bool) -> bool {
    should_show_spinner(
        std::env::var_os("NO_COLOR").is_some(),
        quiet,
        std::env::var("TERM").ok().as_deref() == Some("dumb"),
        std::io::stderr().is_terminal(),
    )
}

/// `true` when `SUPERCODE_QUIET` is set (any value). UX-38 landed the real
/// `--quiet`/`-q` flag; this env var remains a first-class, equally-honored
/// alternative (a wrapper script that can't easily thread an extra flag
/// through still works) — `main.rs::effective_quiet` ORs the two together
/// (`cli.quiet || env_quiet()`) at every call site so the flag and the env
/// var can never give different answers.
pub(crate) fn env_quiet() -> bool {
    std::env::var_os("SUPERCODE_QUIET").is_some()
}

/// A background "Thinking… (Ns)" indicator on stderr. Cheap to construct;
/// `start`/`stop` are idempotent and safe to call from multiple sites (e.g.
/// once around a whole multi-turn `agent.send()`, and again from the event
/// sink on every streamed event) — only the first `start` after a `stop`
/// actually spawns a thread, and `stop` on an already-stopped spinner is a
/// no-op.
pub struct Spinner {
    enabled: bool,
    running: AtomicBool,
    stop_flag: Arc<AtomicBool>,
    handle: Mutex<Option<JoinHandle<()>>>,
    // UX-39: live token-counter state (see the module doc's "live streaming
    // token counter" section). `turn_bytes` resets every new user turn
    // (`reset_turn`); `session_bytes` accumulates for this `Spinner`'s whole
    // lifetime (one CLI process).
    turn_bytes: AtomicU64,
    session_bytes: AtomicU64,
    counter_drawn: AtomicBool,
    last_counter_render: Mutex<Instant>,
}

impl Spinner {
    /// A spinner gated on the real environment (stderr tty, `NO_COLOR`,
    /// `TERM=dumb`, and `quiet`). When gating disallows it, every method on
    /// the returned `Spinner` is a no-op that never touches stderr — this is
    /// the single choke point that keeps `--output-format json` / piped /
    /// `NO_COLOR` runs byte-clean.
    pub fn new(quiet: bool) -> Self {
        Self::with_enabled(should_show_spinner_now(quiet))
    }

    /// Construct with an explicit enabled/disabled decision — used by tests
    /// (and available to callers that already computed gating themselves).
    pub(crate) fn with_enabled(enabled: bool) -> Self {
        Self {
            enabled,
            running: AtomicBool::new(false),
            stop_flag: Arc::new(AtomicBool::new(false)),
            handle: Mutex::new(None),
            turn_bytes: AtomicU64::new(0),
            session_bytes: AtomicU64::new(0),
            counter_drawn: AtomicBool::new(false),
            // Backdated so the very first `render_counter` after a turn
            // starts always draws immediately rather than waiting out a
            // full throttle window (`checked_sub` guards the (practically
            // unreachable) case of a monotonic clock younger than the
            // throttle window itself).
            last_counter_render: Mutex::new(
                Instant::now()
                    .checked_sub(COUNTER_THROTTLE)
                    .unwrap_or_else(Instant::now),
            ),
        }
    }

    /// Start animating (after `START_DELAY`) with the given label, e.g.
    /// `"Thinking…"` or `"Working…"`. No-op if disabled or already running.
    pub fn start(&self, label: &str) {
        if !self.enabled {
            return;
        }
        // Only the transition false -> true actually spawns a thread.
        if self.running.swap(true, Ordering::AcqRel) {
            return;
        }
        self.stop_flag.store(false, Ordering::Release);
        let stop_flag = self.stop_flag.clone();
        let label = label.to_string();
        let handle = thread::spawn(move || spin(&stop_flag, &label));
        *self.handle.lock().unwrap() = Some(handle);
    }

    /// Stop animating and clear the line, if a frame was ever drawn. No-op
    /// if not running (including when disabled, since it's never running).
    /// Blocks briefly (bounded by `POLL_INTERVAL`) for the render thread to
    /// acknowledge the stop and finish clearing, so callers can rely on the
    /// line being clean the instant `stop()` returns.
    pub fn stop(&self) {
        if !self.running.swap(false, Ordering::AcqRel) {
            return;
        }
        self.stop_flag.store(true, Ordering::Release);
        if let Some(handle) = self.handle.lock().unwrap().take() {
            let _ = handle.join();
        }
    }

    /// UX-39: reset the "this turn" counter to zero. Call once, immediately
    /// before `start("Thinking…")`, at the TRUE beginning of a new
    /// `agent.send()`/`agent.send_with_images()` call — never on the
    /// mid-turn restarts `streaming_sink` issues after a tool call
    /// completes, since those continue the SAME turn (the counter must keep
    /// accumulating across a turn's tool round-trips, not zero between
    /// them). The "session" total is never reset by this call — it
    /// accumulates for this `Spinner`'s whole lifetime (one CLI process /
    /// one REPL session). No-op when disabled.
    pub(crate) fn reset_turn(&self) {
        if !self.enabled {
            return;
        }
        self.turn_bytes.store(0, Ordering::Relaxed);
    }

    /// UX-39: record a streamed chunk of assistant text toward both the
    /// "this turn" and "session" running byte totals. No-op when disabled,
    /// so a piped/`--quiet`/non-tty/`--output-format json` run never even
    /// pays for the extra atomic adds. Does not itself draw anything —
    /// pair with `render_counter`.
    pub(crate) fn record_delta(&self, text: &str) {
        if !self.enabled {
            return;
        }
        let bytes = text.len() as u64;
        self.turn_bytes.fetch_add(bytes, Ordering::Relaxed);
        self.session_bytes.fetch_add(bytes, Ordering::Relaxed);
    }

    /// UX-39: redraw the live counter line on stderr, throttled to at most
    /// once per [`COUNTER_THROTTLE`] (the very first call after a
    /// `reset_turn` always draws immediately — see the backdated initial
    /// value of `last_counter_render`). No-op when disabled — the same
    /// choke point shape as the spinner's own `enabled` gate, so
    /// `--output-format json`/piped/`NO_COLOR`/`--quiet` runs stay
    /// byte-clean by construction.
    ///
    /// Writes `\r<text>\x1b[K` (return to column 0, print, erase to end of
    /// line) rather than clear-then-write, so a shorter redraw never leaves
    /// a trailing fragment of a longer previous one, and so the line never
    /// flashes empty between redraws.
    pub(crate) fn render_counter(&self) {
        if !self.enabled {
            return;
        }
        {
            let mut last = self.last_counter_render.lock().unwrap();
            if last.elapsed() < COUNTER_THROTTLE {
                return;
            }
            *last = Instant::now();
        }

        // The same `ceil(bytes / 4)` heuristic as
        // `supercode_runtime::estimate_tokens`, applied to a running byte
        // total instead of a materialized string (avoids retaining the
        // whole streamed reply just to re-estimate it on every redraw).
        let turn_tok = self.turn_bytes.load(Ordering::Relaxed).div_ceil(4);
        let session_tok = self.session_bytes.load(Ordering::Relaxed).div_ceil(4);

        let mut out = std::io::stderr();
        let _ = write!(
            out,
            "\rthis turn: ~{turn_tok} tok · session: ~{session_tok} tok\x1b[K"
        );
        let _ = out.flush();
        self.counter_drawn.store(true, Ordering::Release);
    }

    /// UX-39: clear the live counter line, if one is currently drawn —
    /// idempotent (a no-op if nothing was drawn), same teardown discipline
    /// as the animated spinner. Called at the top of every sink event
    /// (alongside `stop()`) so whatever renders next — a tool-call trace
    /// line, the "Thinking…" spinner restarting, or nothing at all once the
    /// turn is over — never has to share the line with a stale counter.
    /// Also called explicitly from `race_ctrl_c`'s Ctrl-C arm: a mid-stream
    /// SIGINT drops the in-flight turn's future before any further sink
    /// event would otherwise have cleared it naturally.
    pub(crate) fn clear_counter(&self) {
        if !self.counter_drawn.swap(false, Ordering::AcqRel) {
            return;
        }
        let mut out = std::io::stderr();
        let _ = write!(out, "\r\x1b[2K");
        let _ = out.flush();
    }
}

impl Drop for Spinner {
    fn drop(&mut self) {
        // Belt-and-suspenders teardown on every exit path (early return,
        // `?`, panic-unwind) that didn't already call `stop()`/
        // `clear_counter()` explicitly.
        self.stop();
        self.clear_counter();
    }
}

/// The render-thread body: wait `START_DELAY` (bailing early if stopped
/// before it elapses, so a fast response never draws anything), then animate
/// until told to stop, then clear the line. Only reachable via `thread::spawn`
/// in [`Spinner::start`], which already checked `enabled`.
fn spin(stop_flag: &AtomicBool, label: &str) {
    let started = Instant::now();
    while started.elapsed() < START_DELAY {
        if stop_flag.load(Ordering::Acquire) {
            return; // stopped before the delay elapsed: nothing was drawn.
        }
        thread::sleep(POLL_INTERVAL);
    }

    let mut out = std::io::stderr();
    let mut frame = 0usize;
    loop {
        if stop_flag.load(Ordering::Acquire) {
            break;
        }
        let elapsed = started.elapsed().as_secs_f32();
        let _ = write!(
            out,
            "\r{} {label} ({elapsed:.1}s)",
            FRAMES[frame % FRAMES.len()]
        );
        let _ = out.flush();
        frame += 1;

        let deadline = Instant::now() + FRAME_INTERVAL;
        while Instant::now() < deadline {
            if stop_flag.load(Ordering::Acquire) {
                break;
            }
            thread::sleep(POLL_INTERVAL);
        }
    }
    // At least one frame was drawn to reach here — clear it: return to
    // column 0 and erase to end of line, leaving no residue.
    let _ = write!(out, "\r\x1b[2K");
    let _ = out.flush();
}

/// Run a synchronous, possibly-slow local operation (UX-15 dev/03: `audit`/
/// `convert` on a large corpus) with a `"Working…"` spinner, gated exactly
/// like the model-wait spinner. Frozen-terminal feedback for local ops that
/// have no incremental progress hook to drive a real progress bar.
pub fn with_spinner<T>(quiet: bool, f: impl FnOnce() -> T) -> T {
    let spinner = Spinner::new(quiet);
    spinner.start("Working…");
    let out = f();
    spinner.stop();
    out
}