Phase 2: Channel integration and CLI flags¶
Overview¶
Wire the Phase 1 translator into the inputs channel as a
cooperative, non-blocking paste state machine. Add CLI flags
(--enable-paste-as-keystrokes, --paste-text,
--paste-char-delay-ms) and plumb them through the full call
chain from config.rs to InputsChannel. In headless mode,
trigger the paste once after the connection is established.
Emit ChannelEvent::PasteCompleted when the sequence
finishes.
Parent plan¶
Design¶
The cooperative paste problem¶
The inputs channel event loop (inputs.rs:87-200) uses a
two-arm tokio::select!: one arm reads from the SPICE
server, the other drains InputEvents from the UI. If a
paste blocked this loop for the duration of the sequence
(4096 chars × 16 ms = ~65 seconds at maximum), the channel
could not process server messages or real-time mouse/keyboard
input.
The solution is a timer-driven paste state machine added
as a conditional third arm in the select! loop. When a
paste is active, a tokio::time::sleep_until future fires on
schedule, the handler sends one sub-step worth of key events,
advances the state, and yields back to the loop. Between
firings, the other two arms run normally.
Per-character event sequence¶
For each character in the translated paste:
- If
shiftis true: sendKeyDown(0x2A)(Left Shift) - Send
KeyDown(press) - Sleep half the inter-character delay
- Send
KeyUp(release) - If
shiftis true: sendKeyUp(0xAA)(Left Shift) - Sleep the remaining half of the delay
The half-delay between press and release models realistic key timing. A bootloader or BIOS that polls key state once per frame could miss a press/release pair sent in the same TCP segment.
This maps to a two-phase sub-step per character:
PressPhase: send shift-down (if needed) + key-down → sleep half delay
ReleasePhase: send key-up + shift-up (if needed) → sleep half delay
Modifier state save/restore¶
The master plan (Q4) requires releasing held modifiers before
the paste and restoring them afterward. The trigger gesture
(Ctrl+Alt+V in Phase 3, or the CLI flag) means Ctrl and
Alt are likely held when the paste starts. Typing characters
with Ctrl held would produce control sequences, not text.
The InputsChannel does not currently track modifier state.
Phase 2 adds modifier tracking by observing KeyDown/KeyUp
events as they flow through handle_input_event:
- Left Ctrl (
0x1D): set/clearctrl_held - Left Shift (
0x2A): set/clearshift_held - Left Alt (
0x38): set/clearalt_held
At paste start:
1. Save (ctrl_held, shift_held, alt_held) into PasteState.
2. Send KeyUp for each modifier that was held.
3. Clear the tracking flags.
At paste end (after all characters typed):
1. Re-send KeyDown for each modifier that was saved.
2. Restore the tracking flags.
The 4096-character cap¶
A PASTE_MAX_CHARS constant (4096) in inputs.rs enforces
the hard cap from Q5. When PasteText is received, if the
text exceeds the cap, it is truncated and a warn! line is
emitted citing the requested vs actual character counts. The
truncated text is still typed — it is not an error.
PasteText validation and error handling¶
When PasteText arrives in handle_input_event:
- If
!self.enable_paste: log awarn!and return (gate). - If a paste is already in progress: log a
warn!and return (no queueing). - Truncate to
PASTE_MAX_CHARSif needed (warn). - Call
translate_paste(). OnPasteError::Unrepresentable, log anerror!with the count and sample, emitChannelEvent::PasteFailed(see below), and return. - If translation succeeds, save/release modifiers and
initialise the
PasteState.
PasteFailed event¶
In addition to PasteCompleted, add a PasteFailed { reason:
String } variant to ChannelEvent. This surfaces translation
errors to the GUI (Phase 3 will show an error dialog) and to
the headless event loop (which will log the error and — per
Q6 — should cause a non-zero exit for scripted use).
CLI flags¶
| Flag | Type | Default | Description |
|---|---|---|---|
--enable-paste-as-keystrokes |
bool | false | Master gate for the feature |
--paste-text TEXT |
Option\<String> | None | String to type in headless mode |
--paste-char-delay-ms N |
u32 | 16 | Inter-character delay in ms |
Passing --paste-text implies --enable-paste-as-keystrokes
(the effective enable flag is args.enable_paste_as_keystrokes
|| args.paste_text.is_some()).
Headless paste trigger¶
In app.rs::run_headless(), if paste_text is Some, clone
input_tx before moving it into the cadence task, then spawn
a paste trigger task that:
- Waits 1 second (for the inputs channel to connect).
- Sends
InputEvent::PasteText { text, char_delay_ms }viainput_tx.send().await(blocking send — will succeed once the receiver starts consuming).
This follows the cadence-task precedent at app.rs:2912.
Headless non-zero exit on PasteFailed¶
In the headless event loop, handle PasteFailed by setting
a flag and breaking out of the event loop. After the loop,
if the flag is set, return Err(...) so main exits with
a non-zero code. This satisfies Q6's requirement that
headless scripted use signals failure to the test harness.
Current code state and exact locations¶
Files and line numbers¶
| File | What | Lines |
|---|---|---|
channels/mod.rs |
InputEvent enum |
196-216 |
channels/mod.rs |
ChannelEvent enum |
22-194 |
channels/inputs.rs |
InputsChannel struct |
30-48 |
channels/inputs.rs |
Constructor | 49-77 |
channels/inputs.rs |
run() event loop |
80-204 |
channels/inputs.rs |
handle_input_event() |
336-522 |
channels/inputs.rs |
translate_paste() (Phase 1) |
854-895 |
channels/inputs.rs |
PASTE_MAX_CHARS |
(new) |
config.rs |
Args struct |
12-80 |
main.rs |
run_headless() |
163-186 |
main.rs |
run_gui() |
188-224 |
app.rs |
RyllApp struct |
194-332 |
app.rs |
RyllApp::new() |
382-517 |
app.rs |
run_connection() |
2589-2826 |
app.rs |
run_headless() |
2828-3010 |
app.rs |
process_events() |
582-899 |
Modifier scancodes¶
| Modifier | Base | KeyDown | KeyUp |
|---|---|---|---|
| Left Ctrl | 0x1D | 0x1D | 0x9D |
| Left Shift | 0x2A | 0x2A | 0xAA |
| Left Alt | 0x38 | 0x38 | 0xB8 |
Implementation steps¶
Step 1: Add new event variants to channels/mod.rs¶
Add PasteText to InputEvent (after MouseUp):
/// Paste a string as synthetic keystrokes (US-QWERTY).
PasteText { text: String, char_delay_ms: u32 },
Add PasteCompleted and PasteFailed to ChannelEvent
(after the Latency variant, around line 155):
/// Paste-as-keystrokes sequence completed.
PasteCompleted { chars: usize, elapsed_ms: u64 },
/// Paste-as-keystrokes failed (unrepresentable characters).
PasteFailed { reason: String },
Step 2: Add CLI flags to config.rs¶
Add three fields to the Args struct (after the pedantic_dir
field, around line 78):
/// Enable paste-as-keystrokes fallback for guests without vdagent
#[arg(long)]
pub enable_paste_as_keystrokes: bool,
/// String to type as keystrokes in headless mode (implies --enable-paste-as-keystrokes)
#[arg(long = "paste-text")]
pub paste_text: Option<String>,
/// Inter-character delay for paste-as-keystrokes in milliseconds
#[arg(long = "paste-char-delay-ms", default_value_t = 16)]
pub paste_char_delay_ms: u32,
Step 3: Add paste state machine to channels/inputs.rs¶
3a: Add constants and types¶
After the existing use block, add:
/// Maximum characters for a single paste-as-keystrokes sequence.
const PASTE_MAX_CHARS: usize = 4096;
Before or after PasteKey/PasteError, add:
/// Sub-step within a single character of a paste sequence.
#[derive(Debug, Clone, Copy)]
enum PasteSubStep {
/// Send shift-down (if needed) + key-down, then wait half the delay.
Press,
/// Send key-up + shift-up (if needed), then wait the remaining half.
Release,
}
/// State for an in-progress paste-as-keystrokes sequence.
#[derive(Debug)]
struct PasteState {
keys: Vec<PasteKey>,
index: usize,
sub_step: PasteSubStep,
half_delay: Duration,
start: Instant,
next_fire: Instant,
/// Modifier state saved at paste start, restored at paste end.
saved_ctrl: bool,
saved_shift: bool,
saved_alt: bool,
}
3b: Add fields to InputsChannel struct¶
enable_paste: bool,
paste_char_delay_ms: u32,
ctrl_held: bool,
shift_held: bool,
alt_held: bool,
paste_state: Option<PasteState>,
3c: Update constructor¶
Add enable_paste: bool and paste_char_delay_ms: u32
parameters to InputsChannel::new(). Initialise the new
fields (ctrl_held, shift_held, alt_held all false;
paste_state is None).
3d: Track modifier state in handle_input_event¶
In the KeyDown arm (around line 340), after recording the
event, add:
match scancode {
0x1D => self.ctrl_held = true,
0x2A => self.shift_held = true,
0x38 => self.alt_held = true,
_ => {}
}
In the KeyUp arm (around line 362), after recording the
event, add:
match scancode {
0x9D => self.ctrl_held = false,
0xAA => self.shift_held = false,
0xB8 => self.alt_held = false,
_ => {}
}
3e: Add PasteText match arm¶
Add a new arm in handle_input_event for PasteText:
InputEvent::PasteText { text, char_delay_ms } => {
if !self.enable_paste {
warn!("inputs: paste-as-keystrokes not enabled, ignoring");
return Ok(());
}
if self.paste_state.is_some() {
warn!("inputs: paste already in progress, ignoring");
return Ok(());
}
// Enforce character cap
let mut text = text;
if text.chars().count() > PASTE_MAX_CHARS {
let original_len = text.chars().count();
text = text.chars().take(PASTE_MAX_CHARS).collect();
warn!(
"inputs: paste truncated from {} to {} characters",
original_len, PASTE_MAX_CHARS
);
}
// Translate
let keys = match translate_paste(&text) {
Ok(k) => k,
Err(PasteError::Unrepresentable { count, sample }) => {
let sample_str: String = sample.iter()
.map(|c| format!("U+{:04X}", *c as u32))
.collect::<Vec<_>>()
.join(", ");
let reason = format!(
"cannot paste: {} unrepresentable character(s): {}",
count, sample_str
);
error!("inputs: {}", reason);
self.event_tx
.send(ChannelEvent::PasteFailed { reason })
.await
.ok();
self.repaint_notify.notify_one();
return Ok(());
}
};
if keys.is_empty() {
self.event_tx
.send(ChannelEvent::PasteCompleted {
chars: 0,
elapsed_ms: 0,
})
.await
.ok();
self.repaint_notify.notify_one();
return Ok(());
}
let delay = Duration::from_millis(char_delay_ms as u64);
info!(
"inputs: starting paste of {} characters (delay={}ms)",
keys.len(),
char_delay_ms
);
// Save and release held modifiers
let saved_ctrl = self.ctrl_held;
let saved_shift = self.shift_held;
let saved_alt = self.alt_held;
if self.ctrl_held {
self.handle_input_event(InputEvent::KeyUp(0x9D)).await?;
}
if self.shift_held {
self.handle_input_event(InputEvent::KeyUp(0xAA)).await?;
}
if self.alt_held {
self.handle_input_event(InputEvent::KeyUp(0xB8)).await?;
}
let now = Instant::now();
self.paste_state = Some(PasteState {
keys,
index: 0,
sub_step: PasteSubStep::Press,
half_delay: delay / 2,
start: now,
next_fire: now, // fire immediately
saved_ctrl,
saved_shift,
saved_alt,
});
}
Note: the modifier release calls self.handle_input_event
recursively — this reuses the existing KeyUp arm which
sends on the wire and updates the tracking flags. This is
safe because the KeyUp arm does not recurse.
3f: Add advance_paste method¶
/// Advance the paste state machine by one sub-step.
async fn advance_paste(&mut self) -> Result<()> {
let state = self.paste_state.as_mut().unwrap();
let key = state.keys[state.index];
match state.sub_step {
PasteSubStep::Press => {
if key.shift {
self.send_key_down(0x2A).await?;
}
self.send_key_down(key.press).await?;
let state = self.paste_state.as_mut().unwrap();
state.sub_step = PasteSubStep::Release;
state.next_fire = Instant::now() + state.half_delay;
}
PasteSubStep::Release => {
self.send_key_up(key.release).await?;
if key.shift {
self.send_key_up(0xAA).await?;
}
let state = self.paste_state.as_mut().unwrap();
state.index += 1;
if state.index >= state.keys.len() {
// Paste complete
let chars = state.keys.len();
let elapsed_ms = state.start.elapsed().as_millis() as u64;
// Restore modifiers
let saved_ctrl = state.saved_ctrl;
let saved_shift = state.saved_shift;
let saved_alt = state.saved_alt;
self.paste_state = None;
if saved_ctrl {
self.send_key_down(0x1D).await?;
self.ctrl_held = true;
}
if saved_shift {
self.send_key_down(0x2A).await?;
self.shift_held = true;
}
if saved_alt {
self.send_key_down(0x38).await?;
self.alt_held = true;
}
info!(
"inputs: paste complete: {} chars in {}ms",
chars, elapsed_ms
);
self.event_tx
.send(ChannelEvent::PasteCompleted {
chars,
elapsed_ms,
})
.await
.ok();
self.repaint_notify.notify_one();
} else {
state.sub_step = PasteSubStep::Press;
state.next_fire =
Instant::now() + state.half_delay;
}
}
}
Ok(())
}
The advance_paste method needs small helper methods to
send key events without going through handle_input_event
(to avoid triggering the event recording and modifier
tracking for synthetic paste events):
async fn send_key_down(&mut self, scancode: u32) -> Result<()> {
let mut payload = Vec::new();
KeyEvent { scancode }.write(&mut payload)?;
let msg = make_message(inputs_client::KEY_DOWN, &payload);
self.send_with_log(inputs_client::KEY_DOWN, &msg).await
}
async fn send_key_up(&mut self, scancode: u32) -> Result<()> {
let mut payload = Vec::new();
KeyEvent { scancode }.write(&mut payload)?;
let msg = make_message(inputs_client::KEY_UP, &payload);
self.send_with_log(inputs_client::KEY_UP, &msg).await
}
3g: Add third arm to select! loop¶
In run(), add a conditional third arm to the select!
block at line 120. The arm must be placed inside the
existing select! block, as a peer to the read and input
arms:
// Paste state machine: fire when the next step is due.
_ = async {
if let Some(ref state) = self.paste_state {
tokio::time::sleep_until(
tokio::time::Instant::from_std(state.next_fire)
).await;
} else {
// No active paste — park this future indefinitely.
std::future::pending::<()>().await;
}
} => {
self.advance_paste().await?;
}
Borrow-checker note: The existing select! macro borrows
self fields into local variables before the macro call
(lines 89-94). The paste arm borrows self.paste_state
immutably in the future (for next_fire), then calls
self.advance_paste() mutably in the handler. This should
work because select! drops the losing futures before
executing the winning handler. If the borrow checker
complains, extract next_fire into a local Option<Instant>
before the select! and use that instead:
let paste_next = self.paste_state.as_ref().map(|s| s.next_fire);
tokio::select! {
// ... existing arms ...
_ = async {
match paste_next {
Some(t) => tokio::time::sleep_until(
tokio::time::Instant::from_std(t)
).await,
None => std::future::pending::<()>().await,
}
} => {
self.advance_paste().await?;
}
}
3h: Remove #[allow(dead_code)] from Phase 1 items¶
Remove the #[allow(dead_code)] attributes from PasteKey,
PasteError, char_to_scancode, and translate_paste — they
are now used by the PasteText handler.
Step 4: Plumb CLI flags through the call chain¶
This step threads the new config through every function signature in the chain. The changes are mechanical but numerous.
4a: main.rs::run_headless()¶
Add parameters. Extract from args:
fn run_headless(
config: Config,
args: &Args,
virtual_disks: Vec<VirtualDiskConfig>,
share_dir: Option<ShareDirConfig>,
capture: Option<Arc<CaptureSession>>,
pedantic_config: Option<PedanticConfig>,
) -> Result<()> {
Pass to app::run_headless():
app::run_headless(
config,
args.cadence,
args.paste_text.clone(),
args.paste_char_delay_ms,
args.enable_paste_as_keystrokes || args.paste_text.is_some(),
virtual_disks,
share_dir,
capture,
args.monitors,
pedantic_config,
)
Actually, a cleaner approach: since run_headless already
takes args: &Args, just pass the whole &Args reference
instead of extracting individual fields (cadence is already
extracted this way). But the async function in app.rs needs
owned values, and Args doesn't implement Clone. So
extract the three new fields alongside cadence and
monitors.
4b: main.rs::run_gui()¶
Similarly, extract the paste fields and pass to
RyllApp::new().
4c: app.rs::run_headless()¶
Add parameters to the signature:
pub async fn run_headless(
config: Config,
cadence: bool,
paste_text: Option<String>,
paste_char_delay_ms: u32,
enable_paste: bool,
virtual_disks: Vec<VirtualDiskConfig>,
share_dir: Option<ShareDirConfig>,
capture: Option<Arc<CaptureSession>>,
monitors: u8,
pedantic_config: Option<PedanticConfig>,
) -> Result<()>
Pass enable_paste and paste_char_delay_ms through to
run_connection().
Add paste trigger task after the cadence task block
(around line 2923). Clone input_tx before the cadence
block so both tasks can use it:
let paste_input_tx = input_tx.clone();
// ... existing cadence_handle block uses input_tx ...
let paste_handle = if let Some(text) = paste_text {
let delay_ms = paste_char_delay_ms;
Some(tokio::spawn(async move {
// Wait for the inputs channel to be ready.
tokio::time::sleep(Duration::from_secs(1)).await;
let _ = paste_input_tx
.send(InputEvent::PasteText {
text,
char_delay_ms: delay_ms,
})
.await;
}))
} else {
None
};
Add PasteCompleted and PasteFailed handling in the
headless event loop (inside the match event block):
ChannelEvent::PasteCompleted { chars, elapsed_ms } => {
info!(
"headless: paste complete: {} chars in {}ms",
chars, elapsed_ms
);
}
ChannelEvent::PasteFailed { reason } => {
error!("headless: paste failed: {}", reason);
paste_failed = true;
}
Add a paste_failed flag (initialised to false before
the event loop). After the loop, if paste_failed is true,
return an error:
Abort the paste handle in the cleanup section (alongside cadence):
4d: app.rs::RyllApp::new()¶
Add enable_paste: bool and paste_char_delay_ms: u32
parameters. Store them on the RyllApp struct (new fields).
Pass them through to run_connection().
The stored fields will be used in Phase 3 for the GUI button (gating visibility and providing the delay value).
4e: app.rs::run_connection()¶
Add enable_paste: bool and paste_char_delay_ms: u32
parameters. Pass them to InputsChannel::new() at line
2726.
4f: app.rs::process_events()¶
Add match arms for PasteCompleted and PasteFailed in the
GUI event processing. Use the bug_status_message pattern
for transient display:
ChannelEvent::PasteCompleted { chars, elapsed_ms } => {
info!(
"app: paste complete: {} chars in {}ms",
chars, elapsed_ms
);
self.bug_status_message = Some((
format!("Pasted {} chars ({}ms)", chars, elapsed_ms),
Instant::now(),
));
}
ChannelEvent::PasteFailed { reason } => {
error!("app: paste failed: {}", reason);
self.bug_status_message = Some((
format!("Paste failed: {}", reason),
Instant::now(),
));
}
Note: reusing bug_status_message is expedient for Phase 2.
Phase 3 may introduce a separate paste_status_message or
the notifications plan may absorb both. For now, the
transient message slot serves both bug reports and paste
status.
Step 5: Run validation¶
Fix any rustfmt, clippy, or test failures.
Files to modify¶
| File | Changes |
|---|---|
ryll/src/channels/mod.rs |
Add PasteText to InputEvent; add PasteCompleted and PasteFailed to ChannelEvent |
ryll/src/channels/inputs.rs |
Add paste state machine, modifier tracking, PASTE_MAX_CHARS, PasteState, advance_paste, send_key_down/up helpers, third select! arm, PasteText handler; remove #[allow(dead_code)] from Phase 1 items |
ryll/src/config.rs |
Add three CLI flags to Args |
ryll/src/main.rs |
Thread paste config through run_headless and run_gui |
ryll/src/app.rs |
Add params to run_headless, RyllApp::new, run_connection; add fields to RyllApp struct; add paste trigger task in headless; handle PasteCompleted/PasteFailed in process_events and headless event loop |
Step-level guidance¶
| Step | Effort | Model | Isolation | Brief |
|---|---|---|---|---|
| 1 | low | sonnet | none | Add three new enum variants to channels/mod.rs |
| 2 | low | sonnet | none | Add three CLI flags to config.rs |
| 3 | high | opus | none | Implement the paste state machine in inputs.rs — the state types, modifier tracking, PasteText handler, advance_paste, helper methods, and the third select! arm. This is the load-bearing step and requires careful attention to the borrow checker and the select! macro's borrowing rules. |
| 4 | medium | sonnet | none | Plumb CLI flags through the full call chain: main.rs → app.rs (headless + GUI) → run_connection → InputsChannel::new. Add paste trigger task in headless. Handle PasteCompleted/PasteFailed in both GUI and headless event loops. |
| 5 | low | haiku | none | Run pre-commit run --all-files and make test. Fix issues. |
Recommended execution: Steps 1-4 as a single sub-agent invocation (opus, high effort) since the changes are interdependent — step 3 depends on the types from step 1, and step 4 depends on both. Running them as a single agent avoids partial compilation states.
Risks and mitigations¶
-
Borrow checker in
select!: The paste arm borrowsself.paste_stateimmutably in the future, then calls&mut selfin the handler. Extractnext_fireinto a local variable before theselect!to avoid this. -
Recursive
handle_input_event: ThePasteTextarm callshandle_input_event(KeyUp(...))to release modifiers. This is safe becauseKeyUpdoes not recurse. Alternatively, callsend_key_updirectly instead. -
advance_pastere-borrowsself.paste_state: After callingself.send_key_down()(which borrows&mut self), re-borrowself.paste_state.as_mut()to update the state. This is fine because the send methods don't touchpaste_state. -
Headless exit code: The
paste_failedflag must be checked after the event loop exits for any reason (Ctrl-C, disconnect, or normal completion). If paste failed and then the connection disconnected, the error should still propagate. -
Cadence + paste coexistence: Both tasks need
input_tx. Clone it before moving into the cadence task. In practice,--cadenceand--paste-textare unlikely to be combined, but the code must handle it.
Success criteria¶
--paste-text "hello"in headless mode types "hello" into the guest as keystrokes, one per 16ms (default delay).--paste-text "café"in headless mode logs an error and exits non-zero (non-ASCII rejection).--paste-textwith a >4096 character string truncates and warns.--paste-char-delay-ms 50increases the inter-character delay.--paste-textimplies--enable-paste-as-keystrokes(no need to pass both).PasteCompletedis emitted and logged in both GUI and headless.PasteFailedis emitted, logged, and causes non-zero exit in headless.- The inputs channel remains responsive to real-time mouse/keyboard input during a paste.
- Modifier state is saved and restored around the paste.
pre-commit run --all-filespasses.cargo test --workspacepasses.
Sub-agent brief¶
Effort: high | Model: opus | Isolation: none
Implement Steps 1-5 of this plan. The critical piece is
Step 3 — the cooperative paste state machine in inputs.rs.
Read this plan thoroughly before starting. Key files:
channels/mod.rs, channels/inputs.rs, config.rs,
main.rs, app.rs. The plan provides exact code snippets
for the new types, the select! arm, and the plumbing
chain. Adapt them to fit the actual code structure you find
when reading the files — line numbers are approximate.