Skip to content

Commit 1b02ec2

Browse files
authored
feat: key sequence support (#3)
* feat(key): add key sequence support with inter-key delay - Add parse_key_sequence() for space-separated key chords (e.g., "Ctrl+X m") - Add --delay flag for millisecond delays between keys in sequences - Add delay_ms field to Key protocol command (u32, max 10000ms) - Use invalid_input_with_suggestion() for AI-friendly error messages - Make key_to_bytes/parse_key_combo internal (not public API) - Optimize single-char fallback to avoid Vec<char> allocation Examples: pilotty key "Escape : w q Enter" # vim :wq pilotty key "a b c" --delay 50 # 50ms between keys * docs: add key sequence support to documentation
1 parent a1f657c commit 1b02ec2

10 files changed

Lines changed: 421 additions & 57 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,15 @@ target/
1919
.opencode/
2020
.claude/
2121
.agents/
22+
AGENTS.md
23+
**/AGENTS.md
2224

2325
# Internal docs (not for public repo)
2426
docs/
27+
TODO*.md
28+
29+
# Examples (local dev only)
30+
examples/
2531

2632
# npm - binaries are downloaded during release
2733
npm/bin/pilotty-*

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ pilotty key Alt+F # Send Alt+F
133133
pilotty key F1 # Send function key
134134
pilotty key Tab # Send Tab
135135
pilotty key Escape # Send Escape
136+
137+
# Key sequences (space-separated keys sent in order)
138+
pilotty key "Ctrl+X m" # Emacs chord: Ctrl+X then m
139+
pilotty key "Escape : w q Enter" # vim :wq sequence
140+
pilotty key "a b c" --delay 50 # Send a, b, c with 50ms delay between
136141
```
137142

138143
### Interaction
@@ -414,6 +419,26 @@ Supported key formats:
414419
| Combined | `Ctrl+Alt+C` | |
415420
| Special | `Plus` | Literal `+` character |
416421
| Aliases | `Return` = `Enter`, `Esc` = `Escape` | |
422+
| **Sequences** | `"Ctrl+X m"`, `"Escape : w q Enter"` | Space-separated keys |
423+
424+
### Key Sequences
425+
426+
Send multiple keys in order with optional delay between them:
427+
428+
```bash
429+
# Emacs-style chords
430+
pilotty key "Ctrl+X Ctrl+S" # Save in Emacs
431+
pilotty key "Ctrl+X m" # Compose mail in Emacs
432+
433+
# vim command sequences
434+
pilotty key "Escape : w q Enter" # Save and quit vim
435+
pilotty key "g g d G" # Delete entire file in vim
436+
437+
# With inter-key delay (useful for slow TUIs)
438+
pilotty key "Tab Tab Enter" --delay 100 # Navigate with 100ms between keys
439+
```
440+
441+
The `--delay` flag specifies milliseconds between keys (max 10000ms, default 0).
417442

418443
## Contributing
419444

crates/pilotty-cli/src/args.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,26 @@ Examples:
5151
)]
5252
Type(TypeArgs),
5353

54-
/// Send a key or key combination
54+
/// Send a key, key combination, or key sequence
5555
#[command(after_long_help = "\
5656
Supported Keys:
5757
Navigation: Enter, Tab, Escape, Backspace, Space, Delete, Insert
5858
Arrows: Up, Down, Left, Right, Home, End, PageUp, PageDown
5959
Function: F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12
6060
Modifiers: Ctrl+<key>, Alt+<key>
6161
62+
Key Sequences:
63+
Space-separated keys are sent in order. Useful for chords like Emacs C-x m.
64+
6265
Examples:
6366
pilotty key Enter # Press enter
6467
pilotty key Ctrl+C # Send interrupt signal
6568
pilotty key Alt+F # Alt+F (often opens File menu)
66-
pilotty key F1 # Open help in many TUIs
67-
pilotty key -s editor Escape # Send Escape to specific session")]
69+
pilotty key \"Ctrl+X m\" # Emacs chord: Ctrl+X then m
70+
pilotty key \"Escape : w q Enter\" # vim :wq sequence
71+
pilotty key \"Ctrl+X Ctrl+S\" # Emacs save (two combos)
72+
pilotty key -s editor Escape # Send Escape to specific session
73+
pilotty key \"a b c\" --delay 50 # Send a, b, c with 50ms delay between")]
6874
Key(KeyArgs),
6975

7076
/// Click at a specific row and column coordinate
@@ -164,9 +170,13 @@ pub struct TypeArgs {
164170

165171
#[derive(Debug, clap::Args)]
166172
pub struct KeyArgs {
167-
/// Key or combo to send (e.g., Enter, Ctrl+C, Alt+F)
173+
/// Key, combo, or sequence to send (e.g., Enter, Ctrl+C, "Ctrl+X m")
168174
pub key: String,
169175

176+
/// Delay between keys in a sequence (milliseconds, max 10000)
177+
#[arg(long, default_value_t = 0)]
178+
pub delay: u32,
179+
170180
#[arg(short, long, help = SESSION_HELP)]
171181
pub session: Option<String>,
172182
}

crates/pilotty-cli/src/daemon/server.rs

Lines changed: 142 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,13 @@ const MAX_REQUEST_SIZE: usize = 1024 * 1024;
377377
/// Maximum scroll amount to prevent long-running requests.
378378
const MAX_SCROLL_AMOUNT: u32 = 1000;
379379

380+
/// Maximum delay between keys in a sequence (10 seconds).
381+
/// Allows time for slow TUI animations while preventing DoS.
382+
const MAX_KEY_DELAY_MS: u32 = 10_000;
383+
384+
/// Maximum keys in a sequence to prevent long-running requests.
385+
const MAX_KEY_SEQUENCE_LEN: usize = 32;
386+
380387
/// Read a line with a maximum size limit to prevent memory DoS.
381388
///
382389
/// Returns the number of bytes read (0 means EOF).
@@ -514,7 +521,11 @@ async fn handle_request(
514521

515522
Command::Type { text, session } => handle_type(&request.id, &sessions, text, session).await,
516523

517-
Command::Key { key, session } => handle_key(&request.id, &sessions, key, session).await,
524+
Command::Key {
525+
key,
526+
delay_ms,
527+
session,
528+
} => handle_key(&request.id, &sessions, key, delay_ms, session).await,
518529

519530
Command::Click { row, col, session } => {
520531
handle_click(&request.id, &sessions, row, col, session).await
@@ -818,14 +829,32 @@ async fn handle_type(
818829
}
819830
}
820831

821-
/// Handle key command - send key or key combo to PTY.
832+
/// Handle key command - send key, key combo, or key sequence to PTY.
833+
///
834+
/// Supports space-separated key sequences like "Ctrl+X m" for chords.
835+
/// If delay_ms > 0, waits that many milliseconds between each key in a sequence.
822836
async fn handle_key(
823837
request_id: &str,
824838
sessions: &SessionManager,
825839
key: String,
840+
delay_ms: u32,
826841
session: Option<String>,
827842
) -> Response {
828-
use pilotty_core::input::{key_to_bytes, parse_key_combo};
843+
use pilotty_core::input::parse_key_sequence;
844+
845+
// Validate delay_ms to prevent DoS
846+
if delay_ms > MAX_KEY_DELAY_MS {
847+
return Response::error(
848+
request_id,
849+
ApiError::invalid_input_with_suggestion(
850+
format!(
851+
"Key delay {}ms exceeds maximum {}ms",
852+
delay_ms, MAX_KEY_DELAY_MS
853+
),
854+
"Use a smaller delay (<= 10000ms). For longer waits, use multiple key commands.",
855+
),
856+
);
857+
}
829858

830859
// Resolve session
831860
let session_id = match sessions.resolve_session(session.as_deref()).await {
@@ -841,50 +870,61 @@ async fn handle_key(
841870
.await
842871
.unwrap_or(false);
843872

844-
// Try to parse the key
845-
// Note: We check for combos only if there's a `+` that's not the entire key
846-
// This allows sending literal `+` as a single character
847-
let bytes = if key.len() > 1 && key.contains('+') {
848-
// Key combo like Ctrl+C (but not a literal "+")
849-
parse_key_combo(&key, app_cursor)
850-
} else {
851-
// Named key like Enter, Plus, or single character (including "+")
852-
key_to_bytes(&key, app_cursor).or_else(|| {
853-
// Fall back to single character
854-
if key.len() == 1 {
855-
Some(key.as_bytes().to_vec())
856-
} else {
857-
None
858-
}
859-
})
873+
// Parse key sequence (handles single keys, combos, and space-separated sequences)
874+
let sequence = match parse_key_sequence(&key, app_cursor) {
875+
Some(seq) => seq,
876+
None => {
877+
return Response::error(
878+
request_id,
879+
ApiError::invalid_input_with_suggestion(
880+
format!("Invalid key: '{}'", key),
881+
"Use named keys (Enter, Tab, Escape, F1), combos (Ctrl+C, Alt+F), \
882+
or space-separated sequences (\"Ctrl+X m\"). Run 'pilotty key --help' for examples.",
883+
),
884+
);
885+
}
860886
};
861887

862-
match bytes {
863-
Some(bytes) => match sessions.write_to_session(&session_id, &bytes).await {
864-
Ok(()) => {
865-
debug!(
866-
"Sent key '{}' ({} bytes) to session {}",
867-
key,
868-
bytes.len(),
869-
session_id
870-
);
871-
Response::success(
872-
request_id,
873-
ResponseData::Ok {
874-
message: format!("Sent key: {}", key),
875-
},
876-
)
877-
}
878-
Err(e) => Response::error(request_id, e),
879-
},
880-
None => Response::error(
888+
// Validate sequence length to prevent DoS
889+
if sequence.len() > MAX_KEY_SEQUENCE_LEN {
890+
return Response::error(
881891
request_id,
882-
ApiError::invalid_input(format!(
883-
"Unknown key: '{}'. Try named keys like Enter, Tab, Escape, Up, Down, F1, etc. or combos like Ctrl+C",
884-
key
885-
)),
886-
),
892+
ApiError::invalid_input_with_suggestion(
893+
format!(
894+
"Key sequence has {} keys, maximum is {}",
895+
sequence.len(),
896+
MAX_KEY_SEQUENCE_LEN
897+
),
898+
"Split long sequences into multiple key commands (max 32 keys).",
899+
),
900+
);
901+
}
902+
903+
// Send each key in the sequence
904+
let key_count = sequence.len();
905+
for (i, bytes) in sequence.into_iter().enumerate() {
906+
// Apply inter-key delay (but not before the first key)
907+
if i > 0 && delay_ms > 0 {
908+
tokio::time::sleep(std::time::Duration::from_millis(u64::from(delay_ms))).await;
909+
}
910+
911+
if let Err(e) = sessions.write_to_session(&session_id, &bytes).await {
912+
return Response::error(request_id, e);
913+
}
887914
}
915+
916+
debug!(
917+
"Sent {} key(s) '{}' to session {}",
918+
key_count, key, session_id
919+
);
920+
921+
let message = if key_count == 1 {
922+
format!("Sent key: {}", key)
923+
} else {
924+
format!("Sent {} keys: {}", key_count, key)
925+
};
926+
927+
Response::success(request_id, ResponseData::Ok { message })
888928
}
889929

890930
/// Handle click command - click at a specific row/column coordinate.
@@ -1700,6 +1740,7 @@ mod tests {
17001740
id: "key-1".to_string(),
17011741
command: Command::Key {
17021742
key: "Enter".to_string(),
1743+
delay_ms: 0,
17031744
session: Some("key-test".to_string()),
17041745
},
17051746
};
@@ -1723,6 +1764,7 @@ mod tests {
17231764
id: "key-2".to_string(),
17241765
command: Command::Key {
17251766
key: "Ctrl+C".to_string(),
1767+
delay_ms: 0,
17261768
session: Some("key-test".to_string()),
17271769
},
17281770
};
@@ -1748,6 +1790,63 @@ mod tests {
17481790
let _ = std::fs::remove_file(&socket_path);
17491791
}
17501792

1793+
#[tokio::test]
1794+
async fn test_key_rejects_large_delay() {
1795+
let short_id = Uuid::new_v4().simple().to_string();
1796+
let socket_path = std::path::PathBuf::from("/tmp")
1797+
.join(format!("pilotty-keydelay-{}.sock", &short_id[..8]));
1798+
let pid_path = socket_path.with_extension("pid");
1799+
1800+
let server = DaemonServer::bind_to(socket_path.clone(), pid_path.clone())
1801+
.await
1802+
.expect("Failed to bind server");
1803+
1804+
let server_handle = tokio::spawn(async move {
1805+
let _ = timeout(Duration::from_secs(2), server.run()).await;
1806+
});
1807+
1808+
tokio::time::sleep(Duration::from_millis(50)).await;
1809+
1810+
let stream = UnixStream::connect(&socket_path)
1811+
.await
1812+
.expect("Failed to connect");
1813+
let (reader, mut writer) = stream.into_split();
1814+
let mut reader = BufReader::new(reader);
1815+
1816+
// Try to send a key with delay exceeding MAX_KEY_DELAY_MS (10000)
1817+
let request = Request {
1818+
id: "key-delay-1".to_string(),
1819+
command: Command::Key {
1820+
key: "a b".to_string(),
1821+
delay_ms: MAX_KEY_DELAY_MS + 1,
1822+
session: None,
1823+
},
1824+
};
1825+
let request_json = serde_json::to_string(&request).unwrap();
1826+
writer
1827+
.write_all(request_json.as_bytes())
1828+
.await
1829+
.expect("write");
1830+
writer.write_all(b"\n").await.expect("newline");
1831+
writer.flush().await.expect("flush");
1832+
1833+
let mut response_line = String::new();
1834+
timeout(Duration::from_secs(2), reader.read_line(&mut response_line))
1835+
.await
1836+
.expect("timeout")
1837+
.expect("read");
1838+
1839+
let response: Response = serde_json::from_str(&response_line).expect("parse response");
1840+
assert!(!response.success);
1841+
let error = response.error.expect("error response");
1842+
assert_eq!(error.code, ErrorCode::InvalidInput);
1843+
assert!(error.message.contains("exceeds maximum"));
1844+
1845+
server_handle.abort();
1846+
let _ = std::fs::remove_file(&socket_path);
1847+
let _ = std::fs::remove_file(&pid_path);
1848+
}
1849+
17511850
#[tokio::test]
17521851
async fn test_click_command() {
17531852
let temp_dir = std::env::temp_dir();

crates/pilotty-cli/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ fn cli_to_command(cli: &Cli) -> Option<Command> {
6363
}),
6464
Commands::Key(args) => Some(Command::Key {
6565
key: args.key.clone(),
66+
delay_ms: args.delay,
6667
session: args.session.clone(),
6768
}),
6869
Commands::Click(args) => Some(Command::Click {

0 commit comments

Comments
 (0)