diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-21 16:30:04 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-21 16:30:04 +0300 |
| commit | 3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch) | |
| tree | c457a7cf0af271dea1a412474fad5a33c1b60be8 /src/raw_input.rs | |
| parent | 2818ff492d45d17fd4f9b15478d703c6c664a8c0 (diff) | |
feat: add persistent server-client sessions
Make herdr persistent by default.
Launching herdr now starts or reattaches to a background session
server. Clients can detach and reattach while panes and agent
processes keep running. Session mode now supports multi-client attach,
auto-detect startup, and a thin-client/headless-server split.
This also refactors the large app, ui, input, pane, config,
workspace, and persistence modules into smaller focused submodules,
while preserving behavior and colocating tests with the code they
exercise.
Upgrade notes:
- persistence mode is now the default
- in-app quit detaches the current client instead of stopping the server
- use `herdr server stop` to stop the background session
- use `--no-session` for the old single-process behavior
- default socket paths now live under the config directory
Diffstat (limited to 'src/raw_input.rs')
| -rw-r--r-- | src/raw_input.rs | 239 |
1 files changed, 220 insertions, 19 deletions
diff --git a/src/raw_input.rs b/src/raw_input.rs index 262721c8..a98cf25d 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -2,6 +2,113 @@ use std::io::Read; use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +/// Parse raw terminal input bytes into a list of `RawInputEvent`s. +/// +/// This is used by the headless server to route client input through the +/// same parsing pipeline that the monolithic binary uses for stdin. +/// Incomplete sequences at the end of the buffer are flushed as best-effort +/// (same logic as the live input reader). +#[allow(dead_code)] +pub fn parse_raw_input_bytes(data: &[u8]) -> Vec<RawInputEvent> { + // Delegate to the sync version which actually works. + parse_raw_input_bytes_sync(data) +} + +/// A raw input event paired with the byte range it consumed from the original buffer. +#[cfg(test)] +#[derive(Debug)] +pub struct RawInputEventWithRange { + /// The parsed event. + pub event: RawInputEvent, + /// Byte offset where this event starts in the original buffer. + pub start: usize, + /// Number of bytes this event consumed from the original buffer. + /// For events generated from flushed incomplete bytes, `len` may be 0 + /// (synthetic events that don't map to original bytes). + pub len: usize, +} + +/// Parse raw terminal input bytes into a list of `RawInputEventWithRange`s (synchronous version). +/// +/// Unlike `parse_raw_input_bytes_sync`, this preserves the byte offset for each +/// event, allowing callers to write only the specific bytes for each event +/// instead of the entire input buffer. +#[cfg(test)] +pub fn parse_raw_input_bytes_with_ranges(data: &[u8]) -> Vec<RawInputEventWithRange> { + let mut buffer = data.to_vec(); + let mut events = Vec::new(); + let mut offset = 0usize; + + loop { + let Some((event, consumed)) = extract_one_event(&buffer) else { + break; + }; + buffer.drain(..consumed); + events.push(RawInputEventWithRange { + event, + start: offset, + len: consumed, + }); + offset += consumed; + } + + // Flush remaining incomplete bytes. + if !buffer.is_empty() { + if buffer.as_slice() == [ESC] { + events.push(RawInputEventWithRange { + event: RawInputEvent::Key(TerminalKey::new( + crossterm::event::KeyCode::Esc, + KeyModifiers::empty(), + )), + start: offset, + len: 1, + }); + } else if let Ok(text) = std::str::from_utf8(&buffer) { + if let Some(key) = parse_terminal_key_sequence(text) { + events.push(RawInputEventWithRange { + event: RawInputEvent::Key(key), + start: offset, + len: buffer.len(), + }); + } + } + } + + events +} + +/// Parse raw terminal input bytes into a list of `RawInputEvent`s (synchronous version). +/// +/// Unlike `parse_raw_input_bytes`, this directly extracts events without +/// going through a channel, making it suitable for synchronous use. +pub fn parse_raw_input_bytes_sync(data: &[u8]) -> Vec<RawInputEvent> { + let mut buffer = data.to_vec(); + let mut events = Vec::new(); + + loop { + let Some((event, consumed)) = extract_one_event(&buffer) else { + break; + }; + buffer.drain(..consumed); + events.push(event); + } + + if !buffer.is_empty() { + if buffer.as_slice() == [ESC] { + events.push(RawInputEvent::Key(TerminalKey::new( + crossterm::event::KeyCode::Esc, + KeyModifiers::empty(), + ))); + } else if let Ok(text) = std::str::from_utf8(&buffer) { + if let Some(key) = parse_terminal_key_sequence(text) { + events.push(RawInputEvent::Key(key)); + } + } + } + + events +} + #[cfg(unix)] use std::os::fd::AsRawFd; use tokio::sync::mpsc; @@ -54,30 +161,56 @@ pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> { } fn drain_buffer(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent>) { + for bytes in drain_complete_input_bytes(buffer) { + let Some((event, _consumed)) = extract_one_event(&bytes) else { + continue; + }; + tracing::debug!(raw_bytes = ?bytes, event = ?event, "raw input event parsed"); + let _ = tx.blocking_send(event); + } +} + +pub(crate) fn drain_complete_input_bytes(buffer: &mut Vec<u8>) -> Vec<Vec<u8>> { + let mut chunks = Vec::new(); + loop { - let Some((event, consumed)) = extract_one_event(buffer) else { + let Some((_event, consumed)) = extract_one_event(buffer) else { break; }; - tracing::debug!( - raw_bytes = ?&buffer[..consumed], - event = ?event, - "raw input event parsed" - ); + chunks.push(buffer[..consumed].to_vec()); buffer.drain(..consumed); - let _ = tx.blocking_send(event); } + + chunks } fn flush_incomplete_buffer(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent>) { + if let Some(bytes) = flush_incomplete_input_bytes(buffer) { + if bytes.as_slice() == [ESC] { + let _ = tx.blocking_send(RawInputEvent::Key(TerminalKey::new( + crossterm::event::KeyCode::Esc, + KeyModifiers::empty(), + ))); + return; + } + + let Some((event, _consumed)) = extract_one_event(&bytes) else { + return; + }; + let _ = tx.blocking_send(event); + } +} + +pub(crate) fn flush_incomplete_input_bytes(buffer: &mut Vec<u8>) -> Option<Vec<u8>> { if buffer.is_empty() { - return; + return None; } if buffer.starts_with(BRACKETED_PASTE_START) && find_subsequence(buffer, BRACKETED_PASTE_END).is_none() { tracing::trace!(len = buffer.len(), "waiting for bracketed paste terminator"); - return; + return None; } if buffer.as_slice() == [ESC] { @@ -85,24 +218,18 @@ fn flush_incomplete_buffer(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent bytes = ?buffer, "flushing lone escape after input timeout; if this follows an alt chord or focus switch it may reach the pane as plain esc" ); - let _ = tx.blocking_send(RawInputEvent::Key(TerminalKey::new( - crossterm::event::KeyCode::Esc, - KeyModifiers::empty(), - ))); - buffer.clear(); - return; + return Some(std::mem::take(buffer)); } if let Ok(text) = std::str::from_utf8(buffer) { - if let Some(key) = parse_terminal_key_sequence(text) { - let _ = tx.blocking_send(RawInputEvent::Key(key)); - buffer.clear(); - return; + if parse_terminal_key_sequence(text).is_some() { + return Some(std::mem::take(buffer)); } } tracing::debug!(bytes = ?buffer, "dropping incomplete raw input buffer after timeout"); buffer.clear(); + None } #[cfg(unix)] @@ -790,4 +917,78 @@ mod tests { KeyModifiers::empty(), ); } + + #[test] + fn parse_with_ranges_tracks_byte_offsets() { + use super::parse_raw_input_bytes_with_ranges; + + // Input: Up arrow (3 bytes) + 'a' (1 byte) + Down arrow (3 bytes) + let input = b"\x1b[Aa\x1b[B".to_vec(); + let ranges = parse_raw_input_bytes_with_ranges(&input); + + assert_eq!(ranges.len(), 3, "should parse three events"); + + // Up arrow: \x1b[A at offset 0, length 3 + assert_eq!(ranges[0].start, 0); + assert_eq!(ranges[0].len, 3); + assert!(matches!( + &ranges[0].event, + RawInputEvent::Key(k) if k.code == KeyCode::Up + )); + + // 'a' at offset 3, length 1 + assert_eq!(ranges[1].start, 3); + assert_eq!(ranges[1].len, 1); + assert!(matches!( + &ranges[1].event, + RawInputEvent::Key(k) if k.code == KeyCode::Char('a') + )); + + // Down arrow: \x1b[B at offset 4, length 3 + assert_eq!(ranges[2].start, 4); + assert_eq!(ranges[2].len, 3); + assert!(matches!( + &ranges[2].event, + RawInputEvent::Key(k) if k.code == KeyCode::Down + )); + + // Verify the raw bytes for each event slice correctly. + assert_eq!( + &input[ranges[0].start..ranges[0].start + ranges[0].len], + b"\x1b[A" + ); + assert_eq!( + &input[ranges[1].start..ranges[1].start + ranges[1].len], + b"a" + ); + assert_eq!( + &input[ranges[2].start..ranges[2].start + ranges[2].len], + b"\x1b[B" + ); + } + + #[test] + fn parse_with_ranges_handles_single_event() { + use super::parse_raw_input_bytes_with_ranges; + + let input = b"a".to_vec(); + let ranges = parse_raw_input_bytes_with_ranges(&input); + + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start, 0); + assert_eq!(ranges[0].len, 1); + } + + #[test] + fn parse_with_ranges_handles_mouse_event() { + use super::parse_raw_input_bytes_with_ranges; + + let input = b"\x1b[<0;20;10M".to_vec(); + let ranges = parse_raw_input_bytes_with_ranges(&input); + + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start, 0); + assert_eq!(ranges[0].len, input.len()); + assert!(matches!(&ranges[0].event, RawInputEvent::Mouse(_))); + } } |
