Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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-*
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
18 changes: 14 additions & 4 deletions crates/pilotty-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,26 @@ 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
Arrows: Up, Down, Left, Right, Home, End, PageUp, PageDown
Function: F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12
Modifiers: Ctrl+<key>, Alt+<key>

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
Expand Down Expand Up @@ -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<String>,
}
Expand Down
185 changes: 142 additions & 43 deletions crates/pilotty-cli/src/daemon/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String>,
) -> 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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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()),
},
};
Expand All @@ -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()),
},
};
Expand All @@ -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();
Expand Down
1 change: 1 addition & 0 deletions crates/pilotty-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ fn cli_to_command(cli: &Cli) -> Option<Command> {
}),
Commands::Key(args) => Some(Command::Key {
key: args.key.clone(),
delay_ms: args.delay,
session: args.session.clone(),
}),
Commands::Click(args) => Some(Command::Click {
Expand Down
Loading