supercode-cli 0.4.9

supercode — a lightweight, fully-customizable AI coding agent CLI in Rust. Any model via OpenRouter; natively continues Claude Code and Codex sessions.
//! UX-21: OSC terminal-title updates — sets the terminal window/tab title to
//! reflect supercode's current activity (`supercode · <model>` while idle at
//! the prompt, `supercode · <model> · thinking…` while a turn is in flight)
//! so a session is identifiable among other tabs, and restores whatever
//! title was there before on exit.
//!
//! Design goals (mirroring `spinner.rs`'s and `ui.rs`'s established shape —
//! see `spinner::should_show_spinner` / `ui::detect_color_level`):
//! - Zero-dependency: two escape-sequence writes, no crate.
//! - Byte-clean on any non-interactive path: piped/redirected stderr,
//!   `--quiet`/`SUPERCODE_QUIET`, or a dumb terminal must never see a single
//!   title byte. Achieved by never emitting anything when
//!   [`should_set_title`] says no — the single choke point, same discipline
//!   as `Spinner::enabled`.
//! - Never touches stdout: the OSC bytes go to stderr only, so `run
//!   --output-format json` / piped answers are never at risk of an escape
//!   sequence landing in machine-readable output.
//! - Restores on exit: [`TitleGuard::enter`] pushes the terminal's current
//!   title onto its title stack (xterm window-ops `CSI 22 ; 0 t`) before
//!   setting the first title; `Drop` pops it back (`CSI 23 ; 0 t`) on every
//!   exit path (normal return, `?`, or panic-unwind) — the same
//!   belt-and-suspenders teardown `Spinner`'s `Drop` uses.

use std::io::{IsTerminal, Write};

/// Push the current title onto the terminal's title stack (xterm window
/// manipulation, `CSI 22 ; 0 t` with the `0` parameter meaning "both icon
/// and window title"). Widely supported (xterm, most VTE-based terminals,
/// iTerm2, Windows Terminal); on a terminal that doesn't understand it, it's
/// silently ignored like any unrecognized CSI sequence.
const PUSH_TITLE: &str = "\x1b[22;0t";
/// Pop the previously-pushed title back off the stack, restoring whatever
/// the terminal showed before supercode started touching it.
const POP_TITLE: &str = "\x1b[23;0t";

/// The activity supercode's title should reflect. Deliberately just two
/// states (idle / thinking) — the AC calls for "model / running / idle",
/// and "running" and "thinking" are the same state from the title's
/// perspective (a turn is in flight).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TitleState {
    /// At the REPL prompt, waiting for input.
    Idle,
    /// A turn (`agent.send`) is in flight.
    Thinking,
}

/// Pure decision function for "should the title ever be touched?" — no I/O,
/// unit-testable without a real tty or mutated env vars, the same shape as
/// `spinner::should_show_spinner`.
///
/// Title emission is suppressed when:
/// - the caller is in `--quiet`/`SUPERCODE_QUIET` mode (`quiet`) — UX-21
///   dev/02;
/// - `TERM=dumb` (a terminal that declares no capabilities shouldn't be
///   sent window-manipulation escapes); or
/// - stderr isn't a terminal (piped/redirected/non-interactive — UX-21
///   dev/02's "non-tty/piped" case; this is also what makes `run
///   --output-format json` and any non-interactive path byte-clean, since
///   those never construct a `TitleGuard` enabled in the first place).
pub(crate) fn should_set_title(quiet: bool, term_dumb: bool, stderr_is_tty: bool) -> bool {
    !(quiet || term_dumb || !stderr_is_tty)
}

/// Real-world gating: reads `TERM` and stderr's tty-ness. `quiet` is
/// threaded in by the caller, exactly like `spinner::should_show_spinner_now`
/// — every call site passes `main.rs`'s `effective_quiet(cli)`
/// (`cli.quiet || SUPERCODE_QUIET`), so the flag and the env var can never
/// give different answers.
fn should_set_title_now(quiet: bool) -> bool {
    should_set_title(
        quiet,
        std::env::var("TERM").ok().as_deref() == Some("dumb"),
        std::io::stderr().is_terminal(),
    )
}

/// Strip control characters (including ESC/BEL) from a title component
/// before it's interpolated into an OSC sequence. `model` ultimately comes
/// from `--model`/config — untrusted enough that a crafted value containing
/// an embedded `ESC`/`BEL` must not be able to break out of the title
/// sequence and inject arbitrary escapes into the user's terminal.
fn sanitize(s: &str) -> String {
    s.chars().filter(|c| !c.is_control()).collect()
}

/// Build the title string for a given model/state, e.g.
/// `"supercode · claude-opus-4-6 · thinking…"`.
fn title_for(model: &str, state: TitleState) -> String {
    let model = sanitize(model);
    match state {
        TitleState::Idle => format!("supercode · {model}"),
        TitleState::Thinking => format!("supercode · {model} · thinking…"),
    }
}

/// Emit an OSC 0 (icon + window title) set sequence: `ESC ] 0 ; <title> BEL`.
/// Written to stderr only, never stdout, and flushed immediately so it lands
/// before whatever the caller prints next.
fn emit_set(title: &str) {
    let mut out = std::io::stderr();
    let _ = write!(out, "\x1b]0;{title}\x07");
    let _ = out.flush();
}

/// RAII scope for a title-managed interactive session (the REPL, `chat()`).
/// Construct once via [`TitleGuard::enter`] at the top of the session;
/// [`TitleGuard::set`] updates it on turn start/finish; `Drop` restores the
/// terminal's previous title on every exit path. A disabled guard (gating
/// says no) is a true no-op end to end — `enter`/`set`/`drop` never touch
/// stderr — so `--quiet`/non-tty/dumb-terminal sessions stay byte-clean.
pub(crate) struct TitleGuard {
    enabled: bool,
}

impl TitleGuard {
    /// Enter a title-managed scope for `model`: if gating allows, push the
    /// terminal's current title and set the initial `Idle` title. `quiet`
    /// should be the caller's already-computed `effective_quiet(cli)`.
    pub(crate) fn enter(quiet: bool, model: &str) -> Self {
        let enabled = should_set_title_now(quiet);
        if enabled {
            let mut out = std::io::stderr();
            let _ = write!(out, "{PUSH_TITLE}");
            let _ = out.flush();
        }
        let guard = Self { enabled };
        guard.set(model, TitleState::Idle);
        guard
    }

    /// Update the title to reflect `model`/`state`. No-op when disabled.
    pub(crate) fn set(&self, model: &str, state: TitleState) {
        if !self.enabled {
            return;
        }
        emit_set(&title_for(model, state));
    }
}

impl Drop for TitleGuard {
    fn drop(&mut self) {
        // Belt-and-suspenders teardown on every exit path (early return,
        // `?`, panic-unwind), mirroring `Spinner`'s `Drop` — pop restores
        // whatever title the terminal showed before `enter()`.
        if self.enabled {
            let mut out = std::io::stderr();
            let _ = write!(out, "{POP_TITLE}");
            let _ = out.flush();
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // ---- should_set_title: the gating predicate, unit-tested like
    // spinner::should_show_spinner (non-vacuous: each case flips exactly one
    // input and checks the boolean actually changes). ----

    #[test]
    fn shows_on_a_plain_interactive_tty() {
        assert!(should_set_title(false, false, true));
    }

    #[test]
    fn suppressed_in_quiet_mode() {
        assert!(!should_set_title(true, false, true));
    }

    #[test]
    fn suppressed_on_dumb_terminal() {
        assert!(!should_set_title(false, true, true));
    }

    #[test]
    fn suppressed_when_stderr_is_not_a_tty() {
        // The piped/redirected/non-interactive case (`run | cat`,
        // `--output-format json`, CI) — the byte-cleanliness gate.
        assert!(!should_set_title(false, false, false));
    }

    #[test]
    fn multiple_suppressors_still_suppress() {
        assert!(!should_set_title(true, true, false));
    }

    // ---- title_for / sanitize ----

    #[test]
    fn idle_title_has_no_state_suffix() {
        assert_eq!(
            title_for("claude-opus-4-6", TitleState::Idle),
            "supercode · claude-opus-4-6"
        );
    }

    #[test]
    fn thinking_title_reflects_state() {
        assert_eq!(
            title_for("claude-opus-4-6", TitleState::Thinking),
            "supercode · claude-opus-4-6 · thinking…"
        );
    }

    #[test]
    fn idle_and_thinking_titles_differ() {
        // The core AC: the title must actually change between turn
        // start/finish, not just contain the model name statically.
        assert_ne!(
            title_for("gpt-5", TitleState::Idle),
            title_for("gpt-5", TitleState::Thinking)
        );
    }

    #[test]
    fn sanitize_strips_control_characters() {
        // A crafted --model value must not be able to embed ESC/BEL and
        // break out of the OSC title sequence into arbitrary escapes.
        let evil = "gpt\x1b]0;pwned\x07-5";
        assert_eq!(sanitize(evil), "gpt]0;pwned-5");
        assert!(!sanitize(evil).contains('\x1b'));
        assert!(!sanitize(evil).contains('\x07'));
    }

    #[test]
    fn title_for_sanitizes_the_model_component() {
        let title = title_for("m\x1bodel", TitleState::Idle);
        assert!(!title.contains('\x1b'));
        assert_eq!(title, "supercode · model");
    }

    // ---- TitleGuard: disabled guard is a true no-op (can't assert on
    // stderr bytes directly here without a pty, but we can assert it
    // doesn't panic and its `enabled` flag stays false end to end; the real
    // byte-cleanliness proof is the pty capture in the CLI proof, not a unit
    // test). ----

    #[test]
    fn disabled_guard_set_and_drop_do_not_panic() {
        let guard = TitleGuard { enabled: false };
        guard.set("m", TitleState::Thinking);
        guard.set("m", TitleState::Idle);
        drop(guard);
    }

    #[test]
    fn enabled_guard_field_reflects_gating() {
        // Can't force a real tty in a unit test, but we can construct the
        // struct directly (as `enter` does internally) and confirm the
        // enabled path doesn't panic either.
        //
        // This bypasses `should_set_title`'s real gating on purpose (that's
        // what's under test), so `guard.set(...)` below unconditionally
        // writes real OSC title-set bytes to *this test process's* stderr.
        // Under CI / non-interactive `cargo test` runs stderr is redirected
        // (a pipe or file), so the escape bytes are harmless — but if a
        // human runs `cargo test` from an actual interactive terminal, they
        // land on stderr for real and retitle that terminal window/tab.
        // Skip the OSC-emitting assertions in that one case; the gating
        // logic itself (`should_set_title`, tested exhaustively above) and
        // the no-panic disabled-path test above are unaffected, and the pty
        // capture in the CLI proof is the actual byte-cleanliness check.
        if std::io::stderr().is_terminal() {
            return;
        }
        let guard = TitleGuard { enabled: true };
        guard.set("m", TitleState::Thinking);
        guard.set("m", TitleState::Idle);
        drop(guard);
    }
}