hey 0.1.0

Minimal terminal AI coding agent: kernel loop + MCP/Skills self-evolution
Documentation
//! Ctrl-C 双击退出后终端模式恢复测试(PTY 模拟真实终端)。
//!
//! 关键:测试 REPL 模式(不是 --print),因为 rustyline 在 readline 时
//! 启用 bracketed paste + raw mode。只有 REPL 才会触发终端状态变更。
//!
//! 运行:`cargo test --test ctrl_c_pty -- --nocapture`

use std::process::Command;

fn hey_bin() -> String {
    for p in &["target/release/hey", "target/debug/hey"] {
        if let Ok(abs) = std::fs::canonicalize(p) {
            return abs.to_string_lossy().into_owned();
        }
    }
    panic!("hey binary not found; run cargo build first");
}

/// 通用 PTY 测试:启动 hey REPL → 发 prompt → 按指定模式发 Ctrl-C → 验证。
fn run_pty_test(bin: &str, port: u16, ctrl_c_pattern: &str) -> (i32, bool, String) {
    let script = format!(
        r#"
import pty, os, sys, time, termios, fcntl, socket, threading, tempfile, select

PORT = {port}
BIN = r"{bin}"
PATTERN = "{pattern}"

tmp_home = tempfile.mkdtemp(prefix='hey_test_')
os.environ['HOME'] = tmp_home
os.environ['XDG_CONFIG_HOME'] = tmp_home + '/.config'

def hanging_server(port, ready):
    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind(('127.0.0.1', port))
    srv.listen(1)
    ready.set()
    conn, _ = srv.accept()
    time.sleep(60)

ready = threading.Event()
threading.Thread(target=hanging_server, args=(PORT, ready), daemon=True).start()
ready.wait(timeout=2)

master, slave = pty.openpty()
pid = os.fork()
if pid == 0:
    os.setsid()
    os.dup2(slave, 0); os.dup2(slave, 1); os.dup2(slave, 2)
    os.close(master); os.close(slave)
    fcntl.ioctl(0, termios.TIOCSCTTY, 0)
    os.chdir('/tmp')
    os.execv(BIN, ['hey',
        '--base-url', 'http://127.0.0.1:' + str(PORT) + '/v1',
        '--model', 'test', '--api-key', 'dummy',
        '--no-mcp', '--no-skills'])
    os._exit(127)
else:
    os.close(slave)
    time.sleep(1.0)
    os.write(master, b'hi\r')
    time.sleep(1.0)

    if PATTERN == 'quick':
        # 快速双击(10ms 间隔,都在流式窗口内被信号 handler 捕获)
        os.write(master, b'\x03')
        time.sleep(0.01)
        os.write(master, b'\x03')
    elif PATTERN == 'slow':
        # 慢速:1次取消流式 → 等 prompt → 2次在 prompt 退出
        os.write(master, b'\x03')
        time.sleep(1.0)
        os.write(master, b'\x03')
        time.sleep(0.3)
        os.write(master, b'\x03')

    _, status = os.waitpid(pid, 0)
    exit_code = os.WEXITSTATUS(status) if os.WIFEXITED(status) else -1

    output = b''
    try:
        while True:
            output += os.read(master, 4096)
    except OSError:
        pass

    term_ok = False
    try:
        attrs = termios.tcgetattr(master)
        lflag = attrs[3]
        term_ok = bool(lflag & termios.ISIG) and bool(lflag & termios.ICANON) and bool(lflag & termios.ECHO)
        print('termios: ISIG=%s ICANON=%s ECHO=%s' % (bool(lflag & termios.ISIG), bool(lflag & termios.ICANON), bool(lflag & termios.ECHO)))
    except Exception as e:
        print('tcgetattr: %s' % e)
    os.close(master)

    print('exit_code=%d' % exit_code)
    print('output_tail=%r' % output[-200:])
    print('TERM_OK=%s' % term_ok)
    sys.exit(0)
"#,
        port = port,
        bin = bin,
        pattern = ctrl_c_pattern,
    );

    let output = Command::new("python3")
        .arg("-c")
        .arg(&script)
        .output()
        .expect("failed to run python3");

    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    let exit_code = output.status.code().unwrap_or(-1);

    let term_ok = stdout.contains("TERM_OK=True");
    (exit_code, term_ok, stdout)
}

/// 快速双击 Ctrl-C(流式期间两次):退出码 130 + 终端恢复。
#[test]
fn quick_double_ctrl_c_exits_130() {
    let bin = hey_bin();
    let port = 18900 + (std::process::id() % 1000) as u16;
    let (_, term_ok, stdout) = run_pty_test(&bin, port, "quick");

    println!("{stdout}");

    // Python 脚本内部 sys.exit(0),所以 cargo 看到的 exit code 是 0
    // 我们从 stdout 解析 hey 的 exit code
    assert!(
        stdout.contains("exit_code=130"),
        "expected exit 130: {stdout}"
    );
    assert!(term_ok, "terminal not restored: {stdout}");
}

/// 慢速 Ctrl-C(流式取消 + prompt 双击退出):终端恢复。
#[test]
fn slow_ctrl_c_restores_terminal() {
    let bin = hey_bin();
    let port = 19000 + (std::process::id() % 1000) as u16;
    let (_hey_exit, term_ok, stdout) = run_pty_test(&bin, port, "slow");

    println!("{stdout}");

    // 慢速退出码是 0(通过 local counter break → 正常返回)
    assert!(
        stdout.contains("exit_code=0") || stdout.contains("exit_code=130"),
        "unexpected exit: {stdout}"
    );
    assert!(term_ok, "terminal not restored: {stdout}");
}