openlatch-client 0.6.0

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! What the terminal behind stderr can do, decided once when a rail opens.
//!
//! Every human line goes to stderr, so stderr is the stream that is asked. Three
//! modes, from richest to plainest:
//!
//! | Mode | When | Looks like |
//! | ---- | ---- | ---------- |
//! | [`Mode::Live`] | stderr is a terminal that takes VT sequences | spinner, redrawn active block, key-driven select, Unicode glyphs, colour |
//! | [`Mode::Append`] | stderr is a terminal that refused VT (legacy console) | every line appended, never redrawn; ASCII glyphs, no colour |
//! | [`Mode::Plain`] | stderr is not a terminal (MDM, CI, a pipe) | `[n/N]` stage lines, detail only with `--verbose`, `result=` line |

use std::io::IsTerminal;

use crate::cli::output::{OutputConfig, OutputFormat};

/// How the rail draws. See the module table.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
    /// Interactive terminal with VT support: spinner, redraw, key select.
    Live,
    /// Terminal without VT: append-only, ASCII glyphs, no colour.
    Append,
    /// No terminal: one plain line per stage, then a `result=` line.
    Plain,
}

/// The glyph set a rail draws with.
#[derive(Debug)]
pub struct Glyphs {
    /// Opens the rail (`┌`).
    pub start: &'static str,
    /// The gutter (`│`).
    pub bar: &'static str,
    /// Closes the rail (`└`).
    pub end: &'static str,
    /// A finished stage (`◇`).
    pub done: &'static str,
    /// A stage waiting for the user (`◆`).
    pub active: &'static str,
    /// Card verdict: live (`✓`).
    pub ok: &'static str,
    /// Card verdict / stage outcome: needs attention (`!`).
    pub warn: &'static str,
    /// Card verdict / stage outcome: failed (`✗`).
    pub fail: &'static str,
    /// Separator between a stage and its result (`·`).
    pub dot: &'static str,
    /// Select cursor (`›`).
    pub arrow: &'static str,
    /// Trailing ellipsis on a running stage (`…`).
    pub ellipsis: &'static str,
    /// Selected option (`●`).
    pub on: &'static str,
    /// Unselected option (`○`).
    pub off: &'static str,
    /// Spinner frames.
    pub spinner: &'static [&'static str],
    /// Card corners and edges.
    pub top_left: &'static str,
    pub top_right: &'static str,
    pub bottom_left: &'static str,
    pub bottom_right: &'static str,
    pub horizontal: &'static str,
    pub vertical: &'static str,
}

/// Unicode set, drawn on terminals that take VT sequences.
pub const UNICODE: Glyphs = Glyphs {
    start: "┌",
    bar: "│",
    end: "└",
    done: "◇",
    active: "◆",
    ok: "✓",
    warn: "!",
    fail: "✗",
    dot: "·",
    arrow: "›",
    ellipsis: "…",
    on: "●",
    off: "○",
    spinner: &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
    top_left: "╭",
    top_right: "╮",
    bottom_left: "╰",
    bottom_right: "╯",
    horizontal: "─",
    vertical: "│",
};

/// ASCII set, for a console that refused VT and for plain (no-TTY) output,
/// where the reader is a log file that may not be decoded as UTF-8.
pub const ASCII: Glyphs = Glyphs {
    start: "+",
    bar: "|",
    end: "+",
    done: "+",
    active: ">",
    ok: "+",
    warn: "!",
    fail: "x",
    dot: "-",
    arrow: ">",
    ellipsis: "...",
    on: "(*)",
    off: "( )",
    spinner: &["|", "/", "-", "\\"],
    top_left: "+",
    top_right: "+",
    bottom_left: "+",
    bottom_right: "+",
    horizontal: "-",
    vertical: "|",
};

/// Narrowest and widest line the rail wraps notes to.
const MIN_WIDTH: usize = 60;
const MAX_WIDTH: usize = 100;

/// Resolved capabilities for one rail.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Caps {
    pub mode: Mode,
    /// Unicode glyphs ([`UNICODE`]) rather than [`ASCII`].
    pub unicode: bool,
    /// ANSI colour allowed.
    pub color: bool,
    /// `--verbose`: detail lines are shown, not only logged.
    pub verbose: bool,
    /// Wrap width for notes, clamped to 60–100.
    pub width: usize,
}

impl Caps {
    /// Capabilities for a given mode, with the glyph and colour rules each mode
    /// implies. `Live` keeps the caller's colour choice; the other two never colour.
    pub fn new(mode: Mode, color: bool, verbose: bool, width: usize) -> Self {
        Self {
            mode,
            unicode: mode == Mode::Live,
            color: color && mode == Mode::Live,
            verbose,
            width: width.clamp(MIN_WIDTH, MAX_WIDTH),
        }
    }

    /// Decide from the real stderr. `None` when no rail should open at all:
    /// `--json` and `--quiet` keep today's output untouched.
    pub fn detect(output: &OutputConfig) -> Option<Self> {
        if output.format == OutputFormat::Json || output.quiet {
            return None;
        }
        let mode = if !std::io::stderr().is_terminal() {
            Mode::Plain
        } else if enable_vt() {
            Mode::Live
        } else {
            Mode::Append
        };
        Some(Self::new(
            mode,
            output.color,
            output.verbose,
            stderr_width(),
        ))
    }

    /// The glyph set these capabilities draw with.
    pub fn glyphs(&self) -> &'static Glyphs {
        if self.unicode {
            &UNICODE
        } else {
            &ASCII
        }
    }
}

/// Columns of the terminal behind stderr, 80 when it cannot be read.
fn stderr_width() -> usize {
    console::Term::stderr()
        .size_checked()
        .map_or(80, |(_, cols)| usize::from(cols))
}

/// Make sure stdout and stderr interpret VT sequences; returns whether stderr
/// does. Always true off Windows.
///
/// Windows Terminal turns VT processing on for its panes, but a conhost window
/// (the default on Windows 10, what Windows Sandbox opens, and what
/// `powershell -c "irm … | iex"` often lands in) leaves it off unless the
/// program asks, and then every colour and every cursor move prints as
/// `←[32m` garbage. Asking is one `SetConsoleMode` call per handle. It runs
/// once per process, for **every** command, from
/// [`crate::cli::build_output_config`] — `init` alone asking left `doctor`,
/// `status` and every error block printing escape codes. A console too old to
/// accept it gets colour switched off ([`crate::cli::color::is_color_enabled`])
/// and the append-only ASCII rail.
#[cfg(windows)]
pub fn enable_vt() -> bool {
    use winapi::um::winbase::{STD_ERROR_HANDLE, STD_OUTPUT_HANDLE};

    // Stdout is best effort: only the help banner colours it, and it asks
    // stdout for a terminal before doing so.
    enable_vt_on(STD_OUTPUT_HANDLE);
    enable_vt_on(STD_ERROR_HANDLE)
}

#[cfg(windows)]
fn enable_vt_on(std_handle: u32) -> bool {
    use winapi::um::consoleapi::{GetConsoleMode, SetConsoleMode};
    use winapi::um::handleapi::INVALID_HANDLE_VALUE;
    use winapi::um::processenv::GetStdHandle;
    use winapi::um::wincon::ENABLE_VIRTUAL_TERMINAL_PROCESSING;

    // SAFETY: GetStdHandle takes a constant and returns a borrowed handle (or
    // null / INVALID_HANDLE_VALUE, both checked); Get/SetConsoleMode only read
    // and write the mode word through a valid pointer to a local.
    unsafe {
        let handle = GetStdHandle(std_handle);
        if handle.is_null() || handle == INVALID_HANDLE_VALUE {
            return false;
        }
        let mut mode = 0;
        if GetConsoleMode(handle, &mut mode) == 0 {
            return false;
        }
        if mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0 {
            return true;
        }
        SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0
    }
}

/// Make sure stdout and stderr interpret VT sequences. Always true off Windows.
#[cfg(not(windows))]
pub fn enable_vt() -> bool {
    true
}

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

    #[test]
    fn test_live_keeps_colour_and_unicode() {
        let caps = Caps::new(Mode::Live, true, false, 80);
        assert!(caps.unicode && caps.color);
        assert_eq!(caps.glyphs().done, "◇");
    }

    #[test]
    fn test_append_and_plain_are_ascii_without_colour() {
        for mode in [Mode::Append, Mode::Plain] {
            let caps = Caps::new(mode, true, false, 80);
            assert!(!caps.unicode, "{mode:?} must use ASCII");
            assert!(!caps.color, "{mode:?} must not colour");
            assert!(caps.glyphs().spinner.iter().all(|f| f.is_ascii()));
        }
    }

    #[test]
    fn test_width_is_clamped() {
        assert_eq!(Caps::new(Mode::Plain, false, false, 20).width, 60);
        assert_eq!(Caps::new(Mode::Plain, false, false, 300).width, 100);
        assert_eq!(Caps::new(Mode::Plain, false, false, 80).width, 80);
    }

    #[test]
    fn test_ascii_set_is_ascii() {
        let g = &ASCII;
        for s in [
            g.start,
            g.bar,
            g.end,
            g.done,
            g.active,
            g.ok,
            g.warn,
            g.fail,
            g.dot,
            g.arrow,
            g.ellipsis,
            g.on,
            g.off,
            g.top_left,
            g.top_right,
            g.bottom_left,
            g.bottom_right,
            g.horizontal,
            g.vertical,
        ] {
            assert!(s.is_ascii(), "{s:?} is not ASCII");
        }
    }

    #[test]
    fn test_json_and_quiet_open_no_rail() {
        let mut out = OutputConfig {
            format: OutputFormat::Json,
            verbose: false,
            debug: false,
            quiet: false,
            color: false,
        };
        assert!(Caps::detect(&out).is_none());
        out.format = OutputFormat::Human;
        out.quiet = true;
        assert!(Caps::detect(&out).is_none());
    }
}