mermaid-cli 0.7.1

Open-source AI pair programmer with agentic capabilities. Local-first with Ollama, native tool calling, and beautiful TUI.
Documentation
#![cfg(unix)]

use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use portable_pty::{CommandBuilder, PtySize, native_pty_system};

const EXIT_MARKER: &str = "__MERMAID_EXITED__:";
const INJECTED_MOUSE_REPORT: &[u8] = b"\x1b[<35;24;54M";

#[test]
fn ctrl_c_from_empty_tui_exits_and_restores_terminal_modes() {
    let binary = env!("CARGO_BIN_EXE_mermaid");
    let sandbox = test_sandbox("mermaid-pty-exit");
    let home = sandbox.join("home");
    let config = sandbox.join("config");
    let project = sandbox.join("project");
    std::fs::create_dir_all(&home).expect("create test home");
    std::fs::create_dir_all(&config).expect("create test config");
    std::fs::create_dir_all(&project).expect("create test project");

    let pty_system = native_pty_system();
    let pair = pty_system
        .openpty(PtySize {
            rows: 30,
            cols: 100,
            pixel_width: 0,
            pixel_height: 0,
        })
        .expect("open pty");

    let output = Arc::new(Mutex::new(Vec::new()));
    let output_for_reader = Arc::clone(&output);
    let mut reader = pair.master.try_clone_reader().expect("clone pty reader");
    let (reader_done_tx, reader_done_rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let mut buf = [0_u8; 4096];
        loop {
            match reader.read(&mut buf) {
                Ok(0) => break,
                Ok(n) => output_for_reader
                    .lock()
                    .expect("output lock")
                    .extend(&buf[..n]),
                Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
                Err(_) => break,
            }
        }
        let _ = reader_done_tx.send(());
    });

    let script = format!(
        "stty sane; {} --model anthropic/pty-exit-test; status=$?; printf '\\n{}%s\\n' \"$status\"; stty -a",
        shell_quote(binary),
        EXIT_MARKER,
    );
    let mut cmd = CommandBuilder::new("bash");
    cmd.args(["--noprofile", "--norc", "-lc", &script]);
    cmd.cwd(project.as_os_str());
    cmd.env("HOME", home.as_os_str());
    cmd.env("XDG_CONFIG_HOME", config.as_os_str());
    cmd.env("NO_COLOR", "1");
    cmd.env("RUST_BACKTRACE", "0");

    let mut child = pair.slave.spawn_command(cmd).expect("spawn shell in pty");
    drop(pair.slave);

    assert!(
        wait_for_output(
            &output,
            |bytes| bytes.windows(8).any(|w| w == b"\x1b[?1049h"),
            Duration::from_secs(3)
        ),
        "Mermaid did not enter the alternate screen. Output:\n{}",
        output_text(&output)
    );

    let mut writer = pair.master.take_writer().expect("take pty writer");
    writer
        .write_all(INJECTED_MOUSE_REPORT)
        .expect("write injected mouse report");
    writer.flush().expect("flush mouse report");
    std::thread::sleep(Duration::from_millis(50));
    writer.write_all(&[0x03]).expect("write Ctrl+C");
    writer.flush().expect("flush Ctrl+C");

    let status = wait_for_child(&mut child, Duration::from_secs(5))
        .unwrap_or_else(|| {
            let _ = child.kill();
            panic!(
                "Mermaid did not exit after one Ctrl+C. Output:\n{}",
                output_text(&output)
            );
        })
        .expect("wait for child");
    drop(writer);

    let _ = reader_done_rx.recv_timeout(Duration::from_secs(2));
    let bytes = output.lock().expect("output lock").clone();
    let text = String::from_utf8_lossy(&bytes);

    assert!(
        status.success(),
        "shell wrapper exited unsuccessfully: {:?}\n{}",
        status,
        text
    );
    assert!(
        text.contains(&format!("{}0", EXIT_MARKER)),
        "Mermaid did not report a clean exit. Output:\n{}",
        text
    );

    for cleanup in [
        b"\x1b[?1000l".as_slice(),
        b"\x1b[?1002l".as_slice(),
        b"\x1b[?1003l".as_slice(),
        b"\x1b[?1006l".as_slice(),
        b"\x1b[?2004l".as_slice(),
        b"\x1b[?1049l".as_slice(),
    ] {
        assert!(
            bytes.windows(cleanup.len()).any(|window| window == cleanup),
            "missing cleanup sequence {:?}. Output:\n{}",
            String::from_utf8_lossy(cleanup),
            text
        );
    }

    assert!(
        !bytes
            .windows(INJECTED_MOUSE_REPORT.len())
            .any(|window| window == INJECTED_MOUSE_REPORT),
        "injected SGR mouse report leaked back into shell output:\n{}",
        text
    );

    let after_marker = text
        .split_once(EXIT_MARKER)
        .map(|(_, after)| after)
        .unwrap_or_default();
    assert!(
        terminal_mode_token_present(after_marker, "icanon")
            && terminal_mode_token_present(after_marker, "echo")
            && !terminal_mode_token_present(after_marker, "-icanon")
            && !terminal_mode_token_present(after_marker, "-echo"),
        "terminal did not return to canonical echo mode after Mermaid exit:\n{}",
        text
    );
}

fn wait_for_output(
    output: &Arc<Mutex<Vec<u8>>>,
    predicate: impl Fn(&[u8]) -> bool,
    timeout: Duration,
) -> bool {
    let deadline = Instant::now() + timeout;
    while Instant::now() < deadline {
        if predicate(&output.lock().expect("output lock")) {
            return true;
        }
        std::thread::sleep(Duration::from_millis(20));
    }
    false
}

fn wait_for_child(
    child: &mut Box<dyn portable_pty::Child + Send + Sync>,
    timeout: Duration,
) -> Option<std::io::Result<portable_pty::ExitStatus>> {
    let deadline = Instant::now() + timeout;
    while Instant::now() < deadline {
        match child.try_wait() {
            Ok(Some(status)) => return Some(Ok(status)),
            Ok(None) => std::thread::sleep(Duration::from_millis(20)),
            Err(error) => return Some(Err(error)),
        }
    }
    None
}

fn terminal_mode_token_present(stty: &str, token: &str) -> bool {
    stty.split(|c: char| c.is_whitespace() || c == ';')
        .any(|part| part == token)
}

fn output_text(output: &Arc<Mutex<Vec<u8>>>) -> String {
    String::from_utf8_lossy(&output.lock().expect("output lock")).into_owned()
}

fn test_sandbox(prefix: &str) -> std::path::PathBuf {
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system time before epoch")
        .as_nanos();
    std::env::temp_dir().join(format!("{}-{}-{}", prefix, std::process::id(), nonce))
}

fn shell_quote(value: &str) -> String {
    format!("'{}'", value.replace('\'', "'\\''"))
}