diff --git a/.gitignore b/.gitignore index ebcf97a..2541355 100644 --- a/.gitignore +++ b/.gitignore @@ -19,9 +19,15 @@ target/ .opencode/ .claude/ .agents/ +AGENTS.md +**/AGENTS.md # Internal docs (not for public repo) docs/ +TODO*.md + +# Examples (local dev only) +examples/ # npm - binaries are downloaded during release npm/bin/pilotty-* diff --git a/README.md b/README.md index c3a0b6e..1f18cad 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,11 @@ pilotty key Alt+F # Send Alt+F pilotty key F1 # Send function key pilotty key Tab # Send Tab pilotty key Escape # Send Escape + +# Key sequences (space-separated keys sent in order) +pilotty key "Ctrl+X m" # Emacs chord: Ctrl+X then m +pilotty key "Escape : w q Enter" # vim :wq sequence +pilotty key "a b c" --delay 50 # Send a, b, c with 50ms delay between ``` ### Interaction @@ -414,6 +419,26 @@ Supported key formats: | Combined | `Ctrl+Alt+C` | | | Special | `Plus` | Literal `+` character | | Aliases | `Return` = `Enter`, `Esc` = `Escape` | | +| **Sequences** | `"Ctrl+X m"`, `"Escape : w q Enter"` | Space-separated keys | + +### Key Sequences + +Send multiple keys in order with optional delay between them: + +```bash +# Emacs-style chords +pilotty key "Ctrl+X Ctrl+S" # Save in Emacs +pilotty key "Ctrl+X m" # Compose mail in Emacs + +# vim command sequences +pilotty key "Escape : w q Enter" # Save and quit vim +pilotty key "g g d G" # Delete entire file in vim + +# With inter-key delay (useful for slow TUIs) +pilotty key "Tab Tab Enter" --delay 100 # Navigate with 100ms between keys +``` + +The `--delay` flag specifies milliseconds between keys (max 10000ms, default 0). ## Contributing diff --git a/crates/pilotty-cli/src/args.rs b/crates/pilotty-cli/src/args.rs index 6988af0..8365014 100644 --- a/crates/pilotty-cli/src/args.rs +++ b/crates/pilotty-cli/src/args.rs @@ -51,7 +51,7 @@ Examples: )] Type(TypeArgs), - /// Send a key or key combination + /// Send a key, key combination, or key sequence #[command(after_long_help = "\ Supported Keys: Navigation: Enter, Tab, Escape, Backspace, Space, Delete, Insert @@ -59,12 +59,18 @@ Supported Keys: Function: F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12 Modifiers: Ctrl+, Alt+ +Key Sequences: + Space-separated keys are sent in order. Useful for chords like Emacs C-x m. + Examples: pilotty key Enter # Press enter pilotty key Ctrl+C # Send interrupt signal pilotty key Alt+F # Alt+F (often opens File menu) - pilotty key F1 # Open help in many TUIs - pilotty key -s editor Escape # Send Escape to specific session")] + pilotty key \"Ctrl+X m\" # Emacs chord: Ctrl+X then m + pilotty key \"Escape : w q Enter\" # vim :wq sequence + pilotty key \"Ctrl+X Ctrl+S\" # Emacs save (two combos) + pilotty key -s editor Escape # Send Escape to specific session + pilotty key \"a b c\" --delay 50 # Send a, b, c with 50ms delay between")] Key(KeyArgs), /// Click at a specific row and column coordinate @@ -164,9 +170,13 @@ pub struct TypeArgs { #[derive(Debug, clap::Args)] pub struct KeyArgs { - /// Key or combo to send (e.g., Enter, Ctrl+C, Alt+F) + /// Key, combo, or sequence to send (e.g., Enter, Ctrl+C, "Ctrl+X m") pub key: String, + /// Delay between keys in a sequence (milliseconds, max 10000) + #[arg(long, default_value_t = 0)] + pub delay: u32, + #[arg(short, long, help = SESSION_HELP)] pub session: Option, } diff --git a/crates/pilotty-cli/src/daemon/server.rs b/crates/pilotty-cli/src/daemon/server.rs index c0fb344..64929ed 100644 --- a/crates/pilotty-cli/src/daemon/server.rs +++ b/crates/pilotty-cli/src/daemon/server.rs @@ -377,6 +377,13 @@ const MAX_REQUEST_SIZE: usize = 1024 * 1024; /// Maximum scroll amount to prevent long-running requests. const MAX_SCROLL_AMOUNT: u32 = 1000; +/// Maximum delay between keys in a sequence (10 seconds). +/// Allows time for slow TUI animations while preventing DoS. +const MAX_KEY_DELAY_MS: u32 = 10_000; + +/// Maximum keys in a sequence to prevent long-running requests. +const MAX_KEY_SEQUENCE_LEN: usize = 32; + /// Read a line with a maximum size limit to prevent memory DoS. /// /// Returns the number of bytes read (0 means EOF). @@ -514,7 +521,11 @@ async fn handle_request( Command::Type { text, session } => handle_type(&request.id, &sessions, text, session).await, - Command::Key { key, session } => handle_key(&request.id, &sessions, key, session).await, + Command::Key { + key, + delay_ms, + session, + } => handle_key(&request.id, &sessions, key, delay_ms, session).await, Command::Click { row, col, session } => { handle_click(&request.id, &sessions, row, col, session).await @@ -818,14 +829,32 @@ async fn handle_type( } } -/// Handle key command - send key or key combo to PTY. +/// Handle key command - send key, key combo, or key sequence to PTY. +/// +/// Supports space-separated key sequences like "Ctrl+X m" for chords. +/// If delay_ms > 0, waits that many milliseconds between each key in a sequence. async fn handle_key( request_id: &str, sessions: &SessionManager, key: String, + delay_ms: u32, session: Option, ) -> Response { - use pilotty_core::input::{key_to_bytes, parse_key_combo}; + use pilotty_core::input::parse_key_sequence; + + // Validate delay_ms to prevent DoS + if delay_ms > MAX_KEY_DELAY_MS { + return Response::error( + request_id, + ApiError::invalid_input_with_suggestion( + format!( + "Key delay {}ms exceeds maximum {}ms", + delay_ms, MAX_KEY_DELAY_MS + ), + "Use a smaller delay (<= 10000ms). For longer waits, use multiple key commands.", + ), + ); + } // Resolve session let session_id = match sessions.resolve_session(session.as_deref()).await { @@ -841,50 +870,61 @@ async fn handle_key( .await .unwrap_or(false); - // Try to parse the key - // Note: We check for combos only if there's a `+` that's not the entire key - // This allows sending literal `+` as a single character - let bytes = if key.len() > 1 && key.contains('+') { - // Key combo like Ctrl+C (but not a literal "+") - parse_key_combo(&key, app_cursor) - } else { - // Named key like Enter, Plus, or single character (including "+") - key_to_bytes(&key, app_cursor).or_else(|| { - // Fall back to single character - if key.len() == 1 { - Some(key.as_bytes().to_vec()) - } else { - None - } - }) + // Parse key sequence (handles single keys, combos, and space-separated sequences) + let sequence = match parse_key_sequence(&key, app_cursor) { + Some(seq) => seq, + None => { + return Response::error( + request_id, + ApiError::invalid_input_with_suggestion( + format!("Invalid key: '{}'", key), + "Use named keys (Enter, Tab, Escape, F1), combos (Ctrl+C, Alt+F), \ + or space-separated sequences (\"Ctrl+X m\"). Run 'pilotty key --help' for examples.", + ), + ); + } }; - match bytes { - Some(bytes) => match sessions.write_to_session(&session_id, &bytes).await { - Ok(()) => { - debug!( - "Sent key '{}' ({} bytes) to session {}", - key, - bytes.len(), - session_id - ); - Response::success( - request_id, - ResponseData::Ok { - message: format!("Sent key: {}", key), - }, - ) - } - Err(e) => Response::error(request_id, e), - }, - None => Response::error( + // Validate sequence length to prevent DoS + if sequence.len() > MAX_KEY_SEQUENCE_LEN { + return Response::error( request_id, - ApiError::invalid_input(format!( - "Unknown key: '{}'. Try named keys like Enter, Tab, Escape, Up, Down, F1, etc. or combos like Ctrl+C", - key - )), - ), + ApiError::invalid_input_with_suggestion( + format!( + "Key sequence has {} keys, maximum is {}", + sequence.len(), + MAX_KEY_SEQUENCE_LEN + ), + "Split long sequences into multiple key commands (max 32 keys).", + ), + ); + } + + // Send each key in the sequence + let key_count = sequence.len(); + for (i, bytes) in sequence.into_iter().enumerate() { + // Apply inter-key delay (but not before the first key) + if i > 0 && delay_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis(u64::from(delay_ms))).await; + } + + if let Err(e) = sessions.write_to_session(&session_id, &bytes).await { + return Response::error(request_id, e); + } } + + debug!( + "Sent {} key(s) '{}' to session {}", + key_count, key, session_id + ); + + let message = if key_count == 1 { + format!("Sent key: {}", key) + } else { + format!("Sent {} keys: {}", key_count, key) + }; + + Response::success(request_id, ResponseData::Ok { message }) } /// Handle click command - click at a specific row/column coordinate. @@ -1700,6 +1740,7 @@ mod tests { id: "key-1".to_string(), command: Command::Key { key: "Enter".to_string(), + delay_ms: 0, session: Some("key-test".to_string()), }, }; @@ -1723,6 +1764,7 @@ mod tests { id: "key-2".to_string(), command: Command::Key { key: "Ctrl+C".to_string(), + delay_ms: 0, session: Some("key-test".to_string()), }, }; @@ -1748,6 +1790,63 @@ mod tests { let _ = std::fs::remove_file(&socket_path); } + #[tokio::test] + async fn test_key_rejects_large_delay() { + let short_id = Uuid::new_v4().simple().to_string(); + let socket_path = std::path::PathBuf::from("/tmp") + .join(format!("pilotty-keydelay-{}.sock", &short_id[..8])); + let pid_path = socket_path.with_extension("pid"); + + let server = DaemonServer::bind_to(socket_path.clone(), pid_path.clone()) + .await + .expect("Failed to bind server"); + + let server_handle = tokio::spawn(async move { + let _ = timeout(Duration::from_secs(2), server.run()).await; + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + + let stream = UnixStream::connect(&socket_path) + .await + .expect("Failed to connect"); + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + + // Try to send a key with delay exceeding MAX_KEY_DELAY_MS (10000) + let request = Request { + id: "key-delay-1".to_string(), + command: Command::Key { + key: "a b".to_string(), + delay_ms: MAX_KEY_DELAY_MS + 1, + session: None, + }, + }; + let request_json = serde_json::to_string(&request).unwrap(); + writer + .write_all(request_json.as_bytes()) + .await + .expect("write"); + writer.write_all(b"\n").await.expect("newline"); + writer.flush().await.expect("flush"); + + let mut response_line = String::new(); + timeout(Duration::from_secs(2), reader.read_line(&mut response_line)) + .await + .expect("timeout") + .expect("read"); + + let response: Response = serde_json::from_str(&response_line).expect("parse response"); + assert!(!response.success); + let error = response.error.expect("error response"); + assert_eq!(error.code, ErrorCode::InvalidInput); + assert!(error.message.contains("exceeds maximum")); + + server_handle.abort(); + let _ = std::fs::remove_file(&socket_path); + let _ = std::fs::remove_file(&pid_path); + } + #[tokio::test] async fn test_click_command() { let temp_dir = std::env::temp_dir(); diff --git a/crates/pilotty-cli/src/main.rs b/crates/pilotty-cli/src/main.rs index 9a83ed3..c05dbc5 100644 --- a/crates/pilotty-cli/src/main.rs +++ b/crates/pilotty-cli/src/main.rs @@ -63,6 +63,7 @@ fn cli_to_command(cli: &Cli) -> Option { }), Commands::Key(args) => Some(Command::Key { key: args.key.clone(), + delay_ms: args.delay, session: args.session.clone(), }), Commands::Click(args) => Some(Command::Click { diff --git a/crates/pilotty-core/src/input.rs b/crates/pilotty-core/src/input.rs index 0eda55a..a3304fe 100644 --- a/crates/pilotty-core/src/input.rs +++ b/crates/pilotty-core/src/input.rs @@ -76,7 +76,9 @@ pub fn encode_text(text: &str) -> Vec { /// via DECCKM (`ESC[?1h`), and arrow keys must use SS3 encoding to work. /// /// Returns the escape sequence for a named key, or None if not recognized. -pub fn key_to_bytes(key: &str, application_cursor: bool) -> Option> { +/// +/// Note: Internal function. Use `parse_key_sequence` for the public API. +fn key_to_bytes(key: &str, application_cursor: bool) -> Option> { // Normalize key name (case insensitive) let key_lower = key.to_lowercase(); let key_str = key_lower.as_str(); @@ -160,7 +162,9 @@ pub fn key_to_bytes(key: &str, application_cursor: bool) -> Option> { /// - Alt+: Escape prefix + key (Alt+F = ESC f) /// - Shift+: Uppercase for letters, otherwise ignored /// - Combinations: Ctrl+Alt+, etc. -pub fn parse_key_combo(combo: &str, application_cursor: bool) -> Option> { +/// +/// Note: Internal function. Use `parse_key_sequence` for the public API. +fn parse_key_combo(combo: &str, application_cursor: bool) -> Option> { let parts: Vec<&str> = combo.split('+').collect(); if parts.is_empty() { @@ -285,6 +289,58 @@ pub fn encode_mouse_click_combined(x: u16, y: u16) -> Vec { result } +/// Parse a key sequence like "Ctrl+X m" into a list of byte sequences. +/// +/// Keys are space-separated. Each key can be: +/// - A combo: `Ctrl+X`, `Alt+F`, `Ctrl+Alt+C` +/// - A named key: `Enter`, `Escape`, `Tab`, `F1`, `Space` +/// - A single character: `a`, `m`, `:` +/// +/// The `application_cursor` parameter affects arrow key encoding. +/// +/// # Examples +/// +/// ``` +/// use pilotty_core::input::parse_key_sequence; +/// +/// // Emacs chord: Ctrl+X then m +/// let seq = parse_key_sequence("Ctrl+X m", false).unwrap(); +/// assert_eq!(seq.len(), 2); +/// +/// // vim :wq +/// let seq = parse_key_sequence("Escape : w q Enter", false).unwrap(); +/// assert_eq!(seq.len(), 5); +/// +/// // Single key still works +/// let seq = parse_key_sequence("Enter", false).unwrap(); +/// assert_eq!(seq.len(), 1); +/// ``` +pub fn parse_key_sequence(sequence: &str, application_cursor: bool) -> Option>> { + let parts: Vec<&str> = sequence.split_whitespace().collect(); + + if parts.is_empty() { + return None; + } + + let mut result = Vec::with_capacity(parts.len()); + for part in parts { + // Try combo first (Ctrl+X), then named key (Enter), then single char + let bytes = parse_key_combo(part, application_cursor) + .or_else(|| key_to_bytes(part, application_cursor)) + .or_else(|| { + // Single character fallback (avoids Vec allocation) + let mut chars = part.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) => Some(c.to_string().into_bytes()), + _ => None, + } + })?; + result.push(bytes); + } + + Some(result) +} + /// Generate scroll wheel sequences. /// /// Scroll up = button 64 (0x40), scroll down = button 65 (0x41) @@ -490,4 +546,89 @@ mod tests { // Button 65 for scroll down assert_eq!(scroll, b"\x1b[<65;11;6M"); } + + #[test] + fn test_parse_key_sequence_single_key() { + // Single key should work (backward compatible) + let seq = parse_key_sequence("Enter", false).unwrap(); + assert_eq!(seq.len(), 1); + assert_eq!(seq[0], b"\r".to_vec()); + } + + #[test] + fn test_parse_key_sequence_single_combo() { + let seq = parse_key_sequence("Ctrl+C", false).unwrap(); + assert_eq!(seq.len(), 1); + assert_eq!(seq[0], vec![0x03]); + } + + #[test] + fn test_parse_key_sequence_emacs_chord() { + // Ctrl+X then m (emacs-style chord) + let seq = parse_key_sequence("Ctrl+X m", false).unwrap(); + assert_eq!(seq.len(), 2); + assert_eq!(seq[0], vec![0x18]); // Ctrl+X + assert_eq!(seq[1], b"m".to_vec()); + } + + #[test] + fn test_parse_key_sequence_vim_wq() { + // vim :wq sequence + let seq = parse_key_sequence("Escape : w q Enter", false).unwrap(); + assert_eq!(seq.len(), 5); + assert_eq!(seq[0], vec![0x1b]); // Escape + assert_eq!(seq[1], b":".to_vec()); + assert_eq!(seq[2], b"w".to_vec()); + assert_eq!(seq[3], b"q".to_vec()); + assert_eq!(seq[4], b"\r".to_vec()); // Enter + } + + #[test] + fn test_parse_key_sequence_emacs_save() { + // Ctrl+X Ctrl+S (emacs save) + let seq = parse_key_sequence("Ctrl+X Ctrl+S", false).unwrap(); + assert_eq!(seq.len(), 2); + assert_eq!(seq[0], vec![0x18]); // Ctrl+X + assert_eq!(seq[1], vec![0x13]); // Ctrl+S + } + + #[test] + fn test_parse_key_sequence_with_space_key() { + // "a Space b" should send 'a', then space, then 'b' + let seq = parse_key_sequence("a Space b", false).unwrap(); + assert_eq!(seq.len(), 3); + assert_eq!(seq[0], b"a".to_vec()); + assert_eq!(seq[1], b" ".to_vec()); // Space is a named key + assert_eq!(seq[2], b"b".to_vec()); + } + + #[test] + fn test_parse_key_sequence_handles_extra_whitespace() { + // Multiple spaces between keys should be handled + let seq = parse_key_sequence("Ctrl+X m", false).unwrap(); + assert_eq!(seq.len(), 2); + assert_eq!(seq[0], vec![0x18]); + assert_eq!(seq[1], b"m".to_vec()); + } + + #[test] + fn test_parse_key_sequence_empty_returns_none() { + assert!(parse_key_sequence("", false).is_none()); + assert!(parse_key_sequence(" ", false).is_none()); + } + + #[test] + fn test_parse_key_sequence_invalid_key_returns_none() { + // "NotAKey" is not a valid single char or named key + assert!(parse_key_sequence("Ctrl+X NotAKey", false).is_none()); + } + + #[test] + fn test_parse_key_sequence_application_cursor_mode() { + // Arrow keys in application cursor mode + let seq = parse_key_sequence("Up Down", true).unwrap(); + assert_eq!(seq.len(), 2); + assert_eq!(seq[0], b"\x1bOA".to_vec()); // SS3 sequence + assert_eq!(seq[1], b"\x1bOB".to_vec()); + } } diff --git a/crates/pilotty-core/src/protocol.rs b/crates/pilotty-core/src/protocol.rs index 42154ea..687e2c9 100644 --- a/crates/pilotty-core/src/protocol.rs +++ b/crates/pilotty-core/src/protocol.rs @@ -39,9 +39,15 @@ pub enum Command { text: String, session: Option, }, - /// Send a key or key combo. + /// Send a key, key combo, or key sequence. + /// + /// For sequences (space-separated keys like "Ctrl+X m"), `delay_ms` specifies + /// the delay between each key. Defaults to 0 (no delay). Maximum is 10000ms. Key { key: String, + /// Delay between keys in a sequence (milliseconds). Defaults to 0, max 10000. + #[serde(default)] + delay_ms: u32, session: Option, }, /// Click at a specific row/column coordinate. diff --git a/npm/README.md b/npm/README.md index a4a28b2..775b05c 100644 --- a/npm/README.md +++ b/npm/README.md @@ -47,6 +47,10 @@ pilotty type "hello world" pilotty key Enter pilotty key Ctrl+C +# Send key sequences (space-separated) +pilotty key "Ctrl+X m" # Emacs chord +pilotty key "Escape : w q Enter" # vim :wq + # Click at specific coordinates (row, col) pilotty click 10 5 diff --git a/skills/pilotty/SKILL.md b/skills/pilotty/SKILL.md index c9efb74..712a1dc 100644 --- a/skills/pilotty/SKILL.md +++ b/skills/pilotty/SKILL.md @@ -84,6 +84,12 @@ pilotty key F1 # Function key pilotty key Alt+F # Alt combination pilotty key Up # Arrow key pilotty key -s myapp Ctrl+S # Key in specific session + +# Key sequences (space-separated, sent in order) +pilotty key "Ctrl+X m" # Emacs chord: Ctrl+X then m +pilotty key "Escape : w q Enter" # vim :wq sequence +pilotty key "a b c" --delay 50 # Send a, b, c with 50ms delay +pilotty key -s myapp "Tab Tab Enter" # Sequence in specific session ``` ### Interaction @@ -117,6 +123,7 @@ pilotty wait-for "~" -s editor # Wait in specific session | `-t, --timeout ` | Timeout for wait-for (default: 30000) | | `-r, --regex` | Treat wait-for pattern as regex | | `--name ` | Session name for spawn command | +| `--delay ` | Delay between keys in a sequence (default: 0, max: 10000) | ### Environment variables @@ -275,15 +282,18 @@ pilotty key -s editor i # 4. Type content pilotty type -s editor "Hello from pilotty!" -# 5. Exit insert mode -pilotty key -s editor Escape +# 5. Exit insert mode and save (using key sequence) +pilotty key -s editor "Escape : w q Enter" + +# 6. Verify session ended +pilotty list-sessions +``` -# 6. Save and quit +Alternative using individual keys: +```bash +pilotty key -s editor Escape pilotty type -s editor ":wq" pilotty key -s editor Enter - -# 7. Verify session ended -pilotty list-sessions ``` ## Example: Dialog checklist interaction diff --git a/skills/pilotty/references/key-input.md b/skills/pilotty/references/key-input.md index fa28635..1313a69 100644 --- a/skills/pilotty/references/key-input.md +++ b/skills/pilotty/references/key-input.md @@ -5,8 +5,10 @@ Complete reference for key combinations supported by `pilotty key`. ## Basic Usage ```bash -pilotty key # Send to default session +pilotty key # Send single key to default session pilotty key -s myapp # Send to specific session +pilotty key "key1 key2 key3" # Send key sequence (space-separated) +pilotty key "key1 key2" --delay 50 # Sequence with 50ms delay between keys ``` ## Named Keys @@ -117,6 +119,51 @@ pilotty key -s myapp # Send to specific session |-----|-------------| | `Plus` | Literal `+` character | +## Key Sequences + +Send multiple keys in order with a single command. Keys are space-separated: + +```bash +# Emacs-style chords +pilotty key "Ctrl+X Ctrl+S" # Save file +pilotty key "Ctrl+X Ctrl+C" # Exit Emacs +pilotty key "Ctrl+X m" # Compose mail + +# vim command sequences +pilotty key "Escape : w q Enter" # Save and quit +pilotty key "Escape : q ! Enter" # Quit without saving +pilotty key "g g d G" # Delete entire file + +# Navigation sequences +pilotty key "Tab Tab Enter" # Tab twice then Enter +pilotty key "Down Down Space" # Move down twice and select +``` + +### Inter-key Delay + +Use `--delay` for TUIs that need time between keys: + +```bash +pilotty key "Tab Tab Enter" --delay 100 # 100ms between each key +pilotty key "F9 Down Enter" --delay 50 # htop kill menu navigation +``` + +| Option | Description | +|--------|-------------| +| `--delay ` | Milliseconds between keys (default: 0, max: 10000) | + +### When to Use Sequences vs Individual Keys + +**Use sequences** for: +- Emacs/vim chords that must be sent together +- Predictable navigation patterns +- Reducing command overhead + +**Use individual keys** when: +- You need to check screen state between keys +- Timing is unpredictable +- Different paths based on UI state + ## Common TUI Patterns ### Dialog/Whiptail @@ -136,6 +183,12 @@ pilotty key Escape # Normal mode pilotty key Ctrl+C # Also exits insert mode pilotty type ":wq" # Command (then Enter) pilotty key Enter + +# Using sequences for common operations +pilotty key "Escape : w q Enter" # Save and quit +pilotty key "Escape : q ! Enter" # Force quit +pilotty key "Escape d d" # Delete line +pilotty key "Escape g g" # Go to top ``` ### Htop @@ -168,6 +221,10 @@ pilotty key Ctrl+X # Exit pilotty key Ctrl+K # Cut line pilotty key Ctrl+U # Paste pilotty key Ctrl+W # Search + +# Using sequences +pilotty key "Ctrl+O Enter" # Save with default filename +pilotty key "Ctrl+X n" # Exit without saving (answer 'n' to save prompt) ``` ### Tmux (default prefix) @@ -179,6 +236,11 @@ pilotty key c # New window pilotty key n # Next window pilotty key p # Previous window pilotty key d # Detach + +# Using sequences for tmux commands +pilotty key "Ctrl+B c" # Prefix + new window +pilotty key "Ctrl+B n" # Prefix + next window +pilotty key "Ctrl+B d" # Prefix + detach ``` ### Readline/Bash