scriptty 0.1.0

A PTY scripting engine for automating interactive terminal sessions
Documentation
//! [`KeyPress`] command — sends a single key (with optional modifiers) to the PTY.
//!
//! Script syntax: `key [Ctrl+][Alt+][Shift+]<key>`

use crate::command::{Context, ScripttyCommand};
use anyhow::{Result, anyhow};
use async_trait::async_trait;

/// Sends a single key press (with optional modifiers) to the PTY.
///
/// Script syntax: `key [Ctrl+][Alt+][Shift+]<key>`
///
/// Modifiers may appear in any order before the key name. The generated byte
/// sequence follows xterm conventions.
///
/// # Examples
///
/// ```text
/// key Enter
/// key Ctrl+C
/// key Shift+Tab
/// key Alt+Left
/// key Ctrl+Alt+Delete
/// ```
pub struct KeyPress {
    pub bytes: Vec<u8>,
}

impl KeyPress {
    pub const NAME: &'static str = "key";
}

/// Compute the xterm modifier code for a combination of modifiers.
///
/// `n = shift | (alt<<1) | (ctrl<<2)`. Returns `None` for no modifiers,
/// `Some(n + 1)` otherwise (xterm convention: Shift=2, Alt=3, Ctrl=5).
fn modifier_code(ctrl: bool, alt: bool, shift: bool) -> Option<u8> {
    let n = (shift as u8) | ((alt as u8) << 1) | ((ctrl as u8) << 2);
    if n == 0 { None } else { Some(n + 1) }
}

/// Build an arrow / Home / End key sequence.
///
/// No mod: `ESC [ {final}` — with mod: `ESC [ 1 ; {m} {final}`
fn arrow_seq(final_byte: u8, ctrl: bool, alt: bool, shift: bool) -> Vec<u8> {
    match modifier_code(ctrl, alt, shift) {
        None => vec![0x1b, b'[', final_byte],
        Some(m) => {
            let mut v = format!("\x1b[1;{}", m).into_bytes();
            v.push(final_byte);
            v
        }
    }
}

/// Build a tilde-style sequence (Insert, Delete, PageUp, PageDown, F5–F12).
///
/// No mod: `ESC [ {n} ~` — with mod: `ESC [ {n} ; {m} ~`
fn tilde_seq(n: u8, ctrl: bool, alt: bool, shift: bool) -> Vec<u8> {
    match modifier_code(ctrl, alt, shift) {
        None => format!("\x1b[{}~", n).into_bytes(),
        Some(m) => format!("\x1b[{};{}~", n, m).into_bytes(),
    }
}

/// Build an F1–F4 sequence.
///
/// No mod: `ESC O {final}` — with mod: `ESC [ 1 ; {m} {final}`
fn f1f4_seq(final_byte: u8, ctrl: bool, alt: bool, shift: bool) -> Vec<u8> {
    match modifier_code(ctrl, alt, shift) {
        None => vec![0x1b, b'O', final_byte],
        Some(m) => {
            let mut v = format!("\x1b[1;{}", m).into_bytes();
            v.push(final_byte);
            v
        }
    }
}

/// Map a key name and modifier flags to the corresponding byte sequence.
fn key_to_bytes(key: &str, ctrl: bool, alt: bool, shift: bool) -> Result<Vec<u8>> {
    match key {
        "Enter" => {
            if alt {
                Ok(vec![0x1b, b'\r'])
            } else {
                Ok(vec![b'\r'])
            }
        }
        "Backspace" => {
            if ctrl {
                Ok(vec![0x08])
            } else if alt {
                Ok(vec![0x1b, 0x7f])
            } else {
                Ok(vec![0x7f])
            }
        }
        "Tab" => {
            if shift {
                Ok(vec![0x1b, b'[', b'Z'])
            } else if alt {
                Ok(vec![0x1b, b'\t'])
            } else {
                Ok(vec![b'\t'])
            }
        }
        "Escape" | "Esc" => {
            if alt {
                Ok(vec![0x1b, 0x1b])
            } else {
                Ok(vec![0x1b])
            }
        }
        "Space" => {
            if ctrl {
                Ok(vec![0x00])
            } else if alt {
                Ok(vec![0x1b, b' '])
            } else {
                Ok(vec![b' '])
            }
        }
        "Up" => Ok(arrow_seq(b'A', ctrl, alt, shift)),
        "Down" => Ok(arrow_seq(b'B', ctrl, alt, shift)),
        "Right" => Ok(arrow_seq(b'C', ctrl, alt, shift)),
        "Left" => Ok(arrow_seq(b'D', ctrl, alt, shift)),
        "Home" => Ok(arrow_seq(b'H', ctrl, alt, shift)),
        "End" => Ok(arrow_seq(b'F', ctrl, alt, shift)),
        "Insert" => Ok(tilde_seq(2, ctrl, alt, shift)),
        "Delete" | "Del" => Ok(tilde_seq(3, ctrl, alt, shift)),
        "PageUp" => Ok(tilde_seq(5, ctrl, alt, shift)),
        "PageDown" => Ok(tilde_seq(6, ctrl, alt, shift)),
        "F1" => Ok(f1f4_seq(b'P', ctrl, alt, shift)),
        "F2" => Ok(f1f4_seq(b'Q', ctrl, alt, shift)),
        "F3" => Ok(f1f4_seq(b'R', ctrl, alt, shift)),
        "F4" => Ok(f1f4_seq(b'S', ctrl, alt, shift)),
        "F5" => Ok(tilde_seq(15, ctrl, alt, shift)),
        "F6" => Ok(tilde_seq(17, ctrl, alt, shift)),
        "F7" => Ok(tilde_seq(18, ctrl, alt, shift)),
        "F8" => Ok(tilde_seq(19, ctrl, alt, shift)),
        "F9" => Ok(tilde_seq(20, ctrl, alt, shift)),
        "F10" => Ok(tilde_seq(21, ctrl, alt, shift)),
        "F11" => Ok(tilde_seq(23, ctrl, alt, shift)),
        "F12" => Ok(tilde_seq(24, ctrl, alt, shift)),
        _ => {
            // Single ASCII character
            let mut chars = key.chars();
            if let Some(ch) = chars.next()
                && chars.next().is_none()
                && ch.is_ascii()
            {
                let byte = ch as u8;
                if ctrl && ch.is_ascii_alphabetic() {
                    return Ok(vec![ch.to_ascii_lowercase() as u8 - b'a' + 1]);
                } else if alt {
                    return Ok(vec![0x1b, byte]);
                } else {
                    return Ok(vec![byte]);
                }
            }
            Err(anyhow!("Unknown key: {}", key))
        }
    }
}

#[async_trait(?Send)]
impl ScripttyCommand for KeyPress {
    fn name(&self) -> &'static str {
        Self::NAME
    }

    fn parse(args: &str) -> Result<Self> {
        let mut token = args.trim();
        let mut ctrl = false;
        let mut alt = false;
        let mut shift = false;

        loop {
            if let Some(rest) = token.strip_prefix("Ctrl+") {
                ctrl = true;
                token = rest;
            } else if let Some(rest) = token.strip_prefix("Alt+") {
                alt = true;
                token = rest;
            } else if let Some(rest) = token.strip_prefix("Shift+") {
                shift = true;
                token = rest;
            } else {
                break;
            }
        }

        if token.is_empty() {
            return Err(anyhow!("key command requires a key name"));
        }

        let bytes = key_to_bytes(token, ctrl, alt, shift)?;
        Ok(Self { bytes })
    }

    async fn execute(&self, ctx: &mut Context) -> Result<()> {
        ctx.write_to_pty(&self.bytes)?;
        Ok(())
    }
}

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

    fn parse(s: &str) -> KeyPress {
        KeyPress::parse(s).unwrap()
    }

    #[test]
    fn test_enter() {
        assert_eq!(parse("Enter").bytes, b"\r");
    }

    #[test]
    fn test_ctrl_c() {
        assert_eq!(parse("Ctrl+C").bytes, b"\x03");
    }

    #[test]
    fn test_ctrl_d() {
        assert_eq!(parse("Ctrl+D").bytes, b"\x04");
    }

    #[test]
    fn test_shift_tab() {
        assert_eq!(parse("Shift+Tab").bytes, b"\x1b[Z");
    }

    #[test]
    fn test_up() {
        assert_eq!(parse("Up").bytes, b"\x1b[A");
    }

    #[test]
    fn test_ctrl_up() {
        assert_eq!(parse("Ctrl+Up").bytes, b"\x1b[1;5A");
    }

    #[test]
    fn test_alt_left() {
        assert_eq!(parse("Alt+Left").bytes, b"\x1b[1;3D");
    }

    #[test]
    fn test_page_up() {
        assert_eq!(parse("PageUp").bytes, b"\x1b[5~");
    }

    #[test]
    fn test_ctrl_page_down() {
        assert_eq!(parse("Ctrl+PageDown").bytes, b"\x1b[6;5~");
    }

    #[test]
    fn test_f1() {
        assert_eq!(parse("F1").bytes, b"\x1bOP");
    }

    #[test]
    fn test_ctrl_f5() {
        assert_eq!(parse("Ctrl+F5").bytes, b"\x1b[15;5~");
    }

    #[test]
    fn test_alt_enter() {
        assert_eq!(parse("Alt+Enter").bytes, b"\x1b\r");
    }

    #[test]
    fn test_backspace() {
        assert_eq!(parse("Backspace").bytes, b"\x7f");
    }

    #[test]
    fn test_delete() {
        assert_eq!(parse("Delete").bytes, b"\x1b[3~");
    }

    #[test]
    fn test_home() {
        assert_eq!(parse("Home").bytes, b"\x1b[H");
    }

    #[test]
    fn test_ctrl_home() {
        assert_eq!(parse("Ctrl+Home").bytes, b"\x1b[1;5H");
    }

    #[test]
    fn test_ctrl_alt_left() {
        // Ctrl+Alt: ctrl<<2 | alt<<1 = 4|2 = 6 → code = 7
        assert_eq!(parse("Ctrl+Alt+Left").bytes, b"\x1b[1;7D");
    }

    #[test]
    fn test_unknown_key() {
        assert!(KeyPress::parse("UnknownKey").is_err());
    }

    #[test]
    fn test_empty_key() {
        assert!(KeyPress::parse("").is_err());
    }
}