aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-21 16:30:04 +0300
committerGitHub <noreply@github.com>2026-04-21 16:30:04 +0300
commit3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch)
treec457a7cf0af271dea1a412474fad5a33c1b60be8 /src/client
parent2818ff492d45d17fd4f9b15478d703c6c664a8c0 (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/client')
-rw-r--r--src/client/blit.rs793
-rw-r--r--src/client/input.rs135
-rw-r--r--src/client/mod.rs741
3 files changed, 1669 insertions, 0 deletions
diff --git a/src/client/blit.rs b/src/client/blit.rs
new file mode 100644
index 00000000..aa443a7b
--- /dev/null
+++ b/src/client/blit.rs
@@ -0,0 +1,793 @@
+//! Frame blitting — renders FrameData to the terminal using diff-based updates.
+//!
+//! The blitting strategy:
+//! 1. On the first frame, write the entire buffer (full redraw).
+//! 2. On subsequent frames, diff against the last frame and only write
+//! the cells that changed.
+//! 3. Before writing any cells, hide the cursor to avoid stray cursor
+//! artifacts on terminals that render the hardware cursor at intermediate
+//! `CUP` positions during the frame stream.
+//! 4. After writing all changed cells, restore the final cursor visibility
+//! and position from `frame.cursor`.
+//!
+//! Escape sequences used:
+//! - `CSI H` (CUP) — move cursor to (row, col)
+//! - `CSI m` (SGR) — set graphic rendition (colors, bold, etc.)
+//! - `ESC ] 52 ; c ; <base64> BEL` — OSC 52 clipboard write
+//!
+//! The goal is minimal output: skip unchanged cells, batch adjacent changes,
+//! and minimize cursor movement.
+
+use std::cmp;
+use std::io::{self, Write};
+
+use unicode_width::UnicodeWidthStr;
+
+use crate::server::protocol::{CellData, FrameData};
+
+// ---------------------------------------------------------------------------
+// Color → escape sequence
+// ---------------------------------------------------------------------------
+
+/// Converts a packed u32 color to an SGR escape sequence fragment.
+///
+/// Returns a string like `38;5;123` (indexed) or `38;2;255;128;64` (RGB)
+/// or `39` (reset), without the leading `\x1b[` or trailing `m`.
+fn color_to_sgr_fg(val: u32) -> String {
+ match val >> 24 {
+ 0x00 => match val & 0xFF {
+ 0x00 => "39".to_owned(), // Reset
+ 0x01 => "30".to_owned(), // Black
+ 0x02 => "31".to_owned(), // Red
+ 0x03 => "32".to_owned(), // Green
+ 0x04 => "33".to_owned(), // Yellow
+ 0x05 => "34".to_owned(), // Blue
+ 0x06 => "35".to_owned(), // Magenta
+ 0x07 => "36".to_owned(), // Cyan
+ 0x08 => "37".to_owned(), // Gray (light gray)
+ 0x09 => "90".to_owned(), // DarkGray
+ 0x0A => "91".to_owned(), // LightRed
+ 0x0B => "92".to_owned(), // LightGreen
+ 0x0C => "93".to_owned(), // LightYellow
+ 0x0D => "94".to_owned(), // LightBlue
+ 0x0E => "95".to_owned(), // LightMagenta
+ 0x0F => "96".to_owned(), // LightCyan
+ 0x10 => "97".to_owned(), // White
+ _ => "39".to_owned(), // Unknown → Reset
+ },
+ 0x01 => format!("38;5;{}", val & 0xFF), // Indexed
+ 0x02 => {
+ // RGB
+ let r = (val >> 16) & 0xFF;
+ let g = (val >> 8) & 0xFF;
+ let b = val & 0xFF;
+ format!("38;2;{r};{g};{b}")
+ }
+ _ => "39".to_owned(), // Unknown → Reset
+ }
+}
+
+/// Converts a packed u32 color to a background SGR fragment.
+fn color_to_sgr_bg(val: u32) -> String {
+ match val >> 24 {
+ 0x00 => match val & 0xFF {
+ 0x00 => "49".to_owned(), // Reset
+ 0x01 => "40".to_owned(), // Black
+ 0x02 => "41".to_owned(), // Red
+ 0x03 => "42".to_owned(), // Green
+ 0x04 => "43".to_owned(), // Yellow
+ 0x05 => "44".to_owned(), // Blue
+ 0x06 => "45".to_owned(), // Magenta
+ 0x07 => "46".to_owned(), // Cyan
+ 0x08 => "47".to_owned(), // Gray (light gray)
+ 0x09 => "100".to_owned(), // DarkGray
+ 0x0A => "101".to_owned(), // LightRed
+ 0x0B => "102".to_owned(), // LightGreen
+ 0x0C => "103".to_owned(), // LightYellow
+ 0x0D => "104".to_owned(), // LightBlue
+ 0x0E => "105".to_owned(), // LightMagenta
+ 0x0F => "106".to_owned(), // LightCyan
+ 0x10 => "107".to_owned(), // White
+ _ => "49".to_owned(), // Unknown → Reset
+ },
+ 0x01 => format!("48;5;{}", val & 0xFF), // Indexed
+ 0x02 => {
+ let r = (val >> 16) & 0xFF;
+ let g = (val >> 8) & 0xFF;
+ let b = val & 0xFF;
+ format!("48;2;{r};{g};{b}")
+ }
+ _ => "49".to_owned(),
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Modifier → SGR
+// ---------------------------------------------------------------------------
+
+/// Converts a u16 modifier bitmask to SGR escape sequence fragments.
+///
+/// Returns a Vec of SGR parameter strings (e.g., "1" for bold, "3" for italic).
+fn modifier_to_sgr_parts(val: u16) -> Vec<&'static str> {
+ let mut parts = Vec::new();
+
+ // ratatui::Modifier bits (from bitflags)
+ const BOLD: u16 = 1 << 0; // 0x01
+ const DIM: u16 = 1 << 1; // 0x02
+ const ITALIC: u16 = 1 << 2; // 0x04
+ const UNDERLINED: u16 = 1 << 3; // 0x08
+ const SLOW_BLINK: u16 = 1 << 4; // 0x10
+ const RAPID_BLINK: u16 = 1 << 5; // 0x20
+ const REVERSED: u16 = 1 << 6; // 0x40
+ const HIDDEN: u16 = 1 << 7; // 0x80
+ const CROSSED_OUT: u16 = 1 << 8; // 0x100
+
+ if val & BOLD != 0 {
+ parts.push("1");
+ }
+ if val & DIM != 0 {
+ parts.push("2");
+ }
+ if val & ITALIC != 0 {
+ parts.push("3");
+ }
+ if val & UNDERLINED != 0 {
+ parts.push("4");
+ }
+ if val & SLOW_BLINK != 0 {
+ parts.push("5");
+ }
+ if val & RAPID_BLINK != 0 {
+ parts.push("6");
+ }
+ if val & REVERSED != 0 {
+ parts.push("7");
+ }
+ if val & HIDDEN != 0 {
+ parts.push("8");
+ }
+ if val & CROSSED_OUT != 0 {
+ parts.push("9");
+ }
+
+ parts
+}
+
+/// Builds a complete SGR escape sequence for a cell's style.
+fn build_sgr(fg: u32, bg: u32, modifier: u16) -> String {
+ let mut parts = vec!["0".to_owned()];
+ parts.extend(
+ modifier_to_sgr_parts(modifier)
+ .into_iter()
+ .map(str::to_owned),
+ );
+ parts.push(color_to_sgr_fg(fg));
+ parts.push(color_to_sgr_bg(bg));
+ format!("\x1b[{}m", parts.join(";"))
+}
+
+// ---------------------------------------------------------------------------
+// Cell comparison
+// ---------------------------------------------------------------------------
+
+/// Checks if two cells are visually identical.
+#[cfg(test)]
+fn cells_equal(a: &CellData, b: &CellData) -> bool {
+ a.symbol == b.symbol && a.fg == b.fg && a.bg == b.bg && a.modifier == b.modifier
+ // Skip flag is only for ratatui internal use, not visual.
+}
+
+// ---------------------------------------------------------------------------
+// Blitting
+// ---------------------------------------------------------------------------
+
+/// Blits a frame to the terminal, diffing against the previous frame.
+///
+/// On the first frame (no previous), does a full redraw.
+/// On subsequent frames, only writes cells that changed.
+/// After writing cells, positions the cursor.
+pub fn blit_frame(frame: &FrameData, prev: Option<&FrameData>) {
+ let mut stdout = io::stdout();
+ blit_frame_to(&mut stdout, frame, prev);
+}
+
+/// Blits a frame to a writer, diffing against the previous frame.
+fn blit_frame_to(mut writer: impl Write, frame: &FrameData, prev: Option<&FrameData>) {
+ // On first frame or size change, do a full redraw.
+ let full_redraw =
+ prev.is_none() || prev.is_some_and(|p| p.width != frame.width || p.height != frame.height);
+
+ // Hide cursor before any cell writes to avoid stray cursor artifacts
+ // on terminals that render the hardware cursor at intermediate CUP positions.
+ let _ = writer.write_all(b"\x1b[?25l");
+
+ if full_redraw {
+ // Clear the screen and write all cells.
+ let _ = writer.write_all(b"\x1b[2J\x1b[H");
+ write_all_cells(&mut writer, frame);
+ } else {
+ // Diff-based update: only write changed cells.
+ let prev = prev.unwrap();
+ write_changed_cells(&mut writer, frame, prev);
+ }
+
+ // Position the cursor and set visibility.
+ if let Some(cursor) = &frame.cursor {
+ if cursor.visible {
+ // Show cursor (restore visibility if it was hidden on a previous frame).
+ let _ = writer.write_all(b"\x1b[?25h");
+ // CUP: move cursor to (row+1, col+1) — 1-based.
+ let _ = write!(writer, "\x1b[{};{}H", cursor.y + 1, cursor.x + 1);
+ } else {
+ // Hide cursor.
+ let _ = writer.write_all(b"\x1b[?25l");
+ }
+ } else {
+ // No cursor info — hide cursor.
+ let _ = writer.write_all(b"\x1b[?25l");
+ }
+}
+
+/// Writes all cells in the frame (full redraw).
+fn cell_width(cell: &CellData) -> usize {
+ cell.symbol.width()
+}
+
+fn write_all_cells(writer: &mut impl Write, frame: &FrameData) {
+ for row in 0..frame.height {
+ let mut to_skip = 0usize;
+ for col in 0..frame.width {
+ if to_skip > 0 {
+ to_skip -= 1;
+ continue;
+ }
+
+ let idx = (row as usize) * (frame.width as usize) + (col as usize);
+ let cell = &frame.cells[idx];
+
+ if cell.skip {
+ continue;
+ }
+
+ // Move cursor to position (1-based).
+ let _ = write!(writer, "\x1b[{};{}H", row + 1, col + 1);
+
+ // Set style.
+ let sgr = build_sgr(cell.fg, cell.bg, cell.modifier);
+ let _ = writer.write_all(sgr.as_bytes());
+
+ // Write the symbol.
+ let _ = writer.write_all(cell.symbol.as_bytes());
+ to_skip = cell_width(cell).saturating_sub(1);
+ }
+ }
+
+ // Reset style at the end.
+ let _ = writer.write_all(b"\x1b[0m");
+}
+
+fn write_cell(writer: &mut impl Write, row: u16, col: u16, cell: &CellData, last_sgr: &mut String) {
+ if cell.skip {
+ return;
+ }
+
+ let _ = write!(writer, "\x1b[{};{}H", row + 1, col + 1);
+
+ let sgr = build_sgr(cell.fg, cell.bg, cell.modifier);
+ if sgr != *last_sgr {
+ let _ = writer.write_all(sgr.as_bytes());
+ *last_sgr = sgr;
+ }
+
+ let _ = writer.write_all(cell.symbol.as_bytes());
+}
+
+/// Writes only the cells that changed between the previous and current frame.
+fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameData) {
+ let mut last_sgr = String::new(); // Track last SGR to avoid redundant style changes.
+
+ for row in 0..frame.height {
+ let mut invalidated = 0usize;
+ let mut to_skip = 0usize;
+
+ for col in 0..frame.width {
+ let idx = (row as usize) * (frame.width as usize) + (col as usize);
+ let cell = &frame.cells[idx];
+ let prev_cell = &prev.cells[idx];
+
+ if !cell.skip && (cell != prev_cell || invalidated > 0) && to_skip == 0 {
+ write_cell(writer, row, col, cell, &mut last_sgr);
+ }
+
+ to_skip = cell_width(cell).saturating_sub(1);
+ let affected_width = cmp::max(cell_width(cell), cell_width(prev_cell));
+ invalidated = cmp::max(affected_width, invalidated).saturating_sub(1);
+ }
+ }
+
+ // Reset style if we wrote anything.
+ if !last_sgr.is_empty() {
+ let _ = writer.write_all(b"\x1b[0m");
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::server::protocol::{CellData, CursorState};
+
+ const WIDE_GRAPHEME: &str = "💡";
+
+ fn make_cell(symbol: &str, fg: u32, bg: u32, modifier: u16) -> CellData {
+ CellData {
+ symbol: symbol.to_owned(),
+ fg,
+ bg,
+ modifier,
+ skip: false,
+ }
+ }
+
+ fn make_frame(width: u16, height: u16, cells: Vec<CellData>) -> FrameData {
+ FrameData {
+ cells,
+ width,
+ height,
+ cursor: None,
+ }
+ }
+
+ #[test]
+ fn color_to_sgr_fg_named_colors() {
+ assert_eq!(color_to_sgr_fg(0x00_00_00_00), "39"); // Reset
+ assert_eq!(color_to_sgr_fg(0x00_00_00_01), "30"); // Black
+ assert_eq!(color_to_sgr_fg(0x00_00_00_02), "31"); // Red
+ assert_eq!(color_to_sgr_fg(0x00_00_00_10), "97"); // White
+ }
+
+ #[test]
+ fn color_to_sgr_fg_indexed() {
+ assert_eq!(color_to_sgr_fg(0x01_00_00_AB), "38;5;171");
+ }
+
+ #[test]
+ fn color_to_sgr_fg_rgb() {
+ assert_eq!(color_to_sgr_fg(0x02_FF_80_40), "38;2;255;128;64");
+ }
+
+ #[test]
+ fn color_to_sgr_bg_named_colors() {
+ assert_eq!(color_to_sgr_bg(0x00_00_00_00), "49"); // Reset
+ assert_eq!(color_to_sgr_bg(0x00_00_00_01), "40"); // Black
+ assert_eq!(color_to_sgr_bg(0x00_00_00_10), "107"); // White
+ }
+
+ #[test]
+ fn color_to_sgr_bg_rgb() {
+ assert_eq!(color_to_sgr_bg(0x02_FF_80_40), "48;2;255;128;64");
+ }
+
+ #[test]
+ fn modifier_to_sgr_parts_bold() {
+ let parts = modifier_to_sgr_parts(1); // BOLD
+ assert!(parts.contains(&"1"));
+ }
+
+ #[test]
+ fn modifier_to_sgr_parts_italic() {
+ let parts = modifier_to_sgr_parts(4); // ITALIC
+ assert!(parts.contains(&"3"));
+ }
+
+ #[test]
+ fn modifier_to_sgr_parts_empty() {
+ let parts = modifier_to_sgr_parts(0);
+ assert!(parts.is_empty());
+ }
+
+ #[test]
+ fn build_sgr_produces_valid_sequence() {
+ let sgr = build_sgr(0x00_00_00_02, 0x00_00_00_01, 1); // fg=Red, bg=Black, bold
+ assert!(sgr.starts_with("\x1b["));
+ assert!(sgr.ends_with("m"));
+ assert!(sgr.contains("0")); // reset existing style first
+ assert!(sgr.contains("1")); // bold
+ assert!(sgr.contains("31")); // fg red
+ assert!(sgr.contains("40")); // bg black
+ }
+
+ #[test]
+ fn build_sgr_resets_previous_modifiers_when_cell_is_plain() {
+ assert_eq!(build_sgr(0x00_00_00_00, 0x00_00_00_00, 0), "\x1b[0;39;49m");
+ }
+
+ #[test]
+ fn cells_equal_identical() {
+ let a = make_cell("A", 2, 1, 0);
+ let b = make_cell("A", 2, 1, 0);
+ assert!(cells_equal(&a, &b));
+ }
+
+ #[test]
+ fn cells_equal_different_symbol() {
+ let a = make_cell("A", 2, 1, 0);
+ let b = make_cell("B", 2, 1, 0);
+ assert!(!cells_equal(&a, &b));
+ }
+
+ #[test]
+ fn cells_equal_different_color() {
+ let a = make_cell("A", 2, 1, 0);
+ let b = make_cell("A", 3, 1, 0);
+ assert!(!cells_equal(&a, &b));
+ }
+
+ #[test]
+ fn blit_frame_hides_cursor_before_full_redraw_writes() {
+ let frame = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("H", 0, 0, 0),
+ make_cell("i", 0, 0, 0),
+ make_cell("!", 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ ],
+ );
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.starts_with("\x1b[?25l"),
+ "should hide cursor before any cell writes during full redraw"
+ );
+ }
+
+ #[test]
+ fn blit_frame_hides_cursor_before_diff_writes() {
+ let prev = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("H", 0, 0, 0),
+ make_cell("i", 0, 0, 0),
+ make_cell("!", 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ ],
+ );
+
+ let curr = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("X", 0, 0, 0), // Changed
+ make_cell("i", 0, 0, 0), // Same
+ make_cell("!", 0, 0, 0), // Same
+ make_cell(" ", 0, 0, 0), // Same
+ ],
+ );
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.starts_with("\x1b[?25l"),
+ "should hide cursor before any cell writes during diff"
+ );
+ }
+
+ #[test]
+ fn blit_frame_first_frame_produces_output() {
+ let frame = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("H", 0, 0, 0),
+ make_cell("i", 0, 0, 0),
+ make_cell("!", 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ ],
+ );
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ // Full redraw should start with clear screen.
+ assert!(
+ output_str.contains("\x1b[2J"),
+ "full redraw should clear screen"
+ );
+ assert!(
+ output_str.contains('H') || output_str.contains('i'),
+ "should contain cell content"
+ );
+ }
+
+ #[test]
+ fn blit_frame_diff_only_writes_changed_cells() {
+ let prev = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("H", 0, 0, 0),
+ make_cell("i", 0, 0, 0),
+ make_cell("!", 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ ],
+ );
+
+ // Only the first cell changed.
+ let curr = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("X", 0, 0, 0), // Changed
+ make_cell("i", 0, 0, 0), // Same
+ make_cell("!", 0, 0, 0), // Same
+ make_cell(" ", 0, 0, 0), // Same
+ ],
+ );
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+
+ let output_str = String::from_utf8(output).unwrap();
+ // Diff should NOT clear the screen.
+ assert!(
+ !output_str.contains("\x1b[2J"),
+ "diff should not clear screen"
+ );
+ // Should contain the changed cell content.
+ assert!(output_str.contains('X'), "should contain changed cell 'X'");
+ }
+
+ #[test]
+ fn blit_frame_size_change_triggers_full_redraw() {
+ let prev = make_frame(2, 2, vec![make_cell("A", 0, 0, 0); 4]);
+
+ let curr = make_frame(3, 2, vec![make_cell("B", 0, 0, 0); 6]);
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.contains("\x1b[2J"),
+ "size change should trigger full redraw"
+ );
+ }
+
+ #[test]
+ fn blit_frame_positions_cursor() {
+ let frame = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ }),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.contains("\x1b[1;1H"),
+ "should position cursor at (1,1)"
+ );
+ }
+
+ #[test]
+ fn blit_frame_hides_cursor_when_invisible() {
+ let frame = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: false,
+ }),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.contains("\x1b[?25l"),
+ "should hide cursor when invisible"
+ );
+ }
+
+ #[test]
+ fn blit_frame_no_cursor_hides_cursor() {
+ let frame = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: None,
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.contains("\x1b[?25l"),
+ "should hide cursor when no cursor state"
+ );
+ }
+
+ #[test]
+ fn blit_frame_restores_cursor_visibility() {
+ // First frame: cursor hidden.
+ let prev = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: false,
+ }),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &prev, None);
+ assert!(
+ String::from_utf8(output).unwrap().contains("\x1b[?25l"),
+ "first frame should hide cursor"
+ );
+
+ // Second frame: cursor visible — should restore visibility.
+ let curr = FrameData {
+ cells: vec![make_cell("B", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ }),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.contains("\x1b[?25h"),
+ "second frame should restore cursor visibility with ?25h"
+ );
+ assert!(
+ output_str.contains("\x1b[1;1H"),
+ "should position cursor after showing it"
+ );
+ }
+
+ #[test]
+ fn blit_frame_hides_previous_visible_cursor_when_next_frame_has_none() {
+ let prev = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ }),
+ };
+ let curr = FrameData {
+ cells: vec![make_cell("B", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: None,
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+
+ assert!(
+ String::from_utf8(output).unwrap().contains("\x1b[?25l"),
+ "diff redraw should hide a previously visible cursor when the next frame has none"
+ );
+ }
+
+ #[test]
+ fn full_redraw_skips_trailing_cells_covered_by_wide_graphemes() {
+ let frame = FrameData {
+ cells: vec![
+ make_cell(WIDE_GRAPHEME, 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ make_cell("Z", 0, 0, 0),
+ ],
+ width: 3,
+ height: 1,
+ cursor: None,
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+ let output_str = String::from_utf8(output).unwrap();
+
+ assert!(output_str.contains("\x1b[1;1H"));
+ assert!(!output_str.contains("\x1b[1;2H"));
+ assert!(output_str.contains("\x1b[1;3H"));
+ }
+
+ #[test]
+ fn diff_redraw_reveals_cells_hidden_by_previous_wide_graphemes() {
+ let prev = FrameData {
+ cells: vec![
+ make_cell(WIDE_GRAPHEME, 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ make_cell("Z", 0, 0, 0),
+ ],
+ width: 3,
+ height: 1,
+ cursor: None,
+ };
+ let curr = FrameData {
+ cells: vec![
+ make_cell("A", 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ make_cell("Z", 0, 0, 0),
+ ],
+ width: 3,
+ height: 1,
+ cursor: None,
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+ let output_str = String::from_utf8(output).unwrap();
+
+ assert!(output_str.contains("\x1b[1;1H"));
+ assert!(
+ output_str.contains("\x1b[1;2H"),
+ "cells hidden by a previous wide grapheme must be redrawn when they become visible"
+ );
+ }
+
+ #[test]
+ fn diff_redraw_skips_new_trailing_cells_covered_by_wide_graphemes() {
+ let prev = FrameData {
+ cells: vec![
+ make_cell("A", 0, 0, 0),
+ make_cell("B", 0, 0, 0),
+ make_cell("Z", 0, 0, 0),
+ ],
+ width: 3,
+ height: 1,
+ cursor: None,
+ };
+ let curr = FrameData {
+ cells: vec![
+ make_cell(WIDE_GRAPHEME, 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ make_cell("Z", 0, 0, 0),
+ ],
+ width: 3,
+ height: 1,
+ cursor: None,
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+ let output_str = String::from_utf8(output).unwrap();
+
+ assert!(output_str.contains("\x1b[1;1H"));
+ assert!(!output_str.contains("\x1b[1;2H"));
+ }
+}
diff --git a/src/client/input.rs b/src/client/input.rs
new file mode 100644
index 00000000..31e1c1e4
--- /dev/null
+++ b/src/client/input.rs
@@ -0,0 +1,135 @@
+//! Stdin input reading for the thin client.
+//!
+//! Reads raw bytes from stdin and forwards them to the main event loop.
+//! Unlike the monolithic herdr, the thin client does NOT parse the input
+//! into key/mouse/paste events — it just sends the raw bytes to the server
+//! as `ClientMessage::Input`. The server handles parsing.
+//!
+//! This is simpler and more reliable because:
+//! - The server has the same input parsing code
+//! - We avoid duplicating parsing logic in the client
+//! - Raw forwarding preserves all escape sequences faithfully
+
+use std::io::{self, Read};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+
+#[cfg(unix)]
+use std::os::fd::AsRawFd;
+use tokio::sync::mpsc;
+
+use super::ClientLoopEvent;
+
+// ---------------------------------------------------------------------------
+// Stdin reader thread
+// ---------------------------------------------------------------------------
+
+/// Reads raw bytes from stdin and sends them to the main event loop.
+///
+/// This runs on a dedicated thread because stdin reading is blocking.
+/// The main loop receives the raw bytes and forwards them as
+/// `ClientMessage::Input` to the server.
+pub fn stdin_reader_loop(event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>) {
+ let stdin = io::stdin();
+ let mut reader = stdin.lock();
+ let mut scratch = [0u8; 4096];
+ let mut buffer = Vec::new();
+
+ while !should_quit.load(Ordering::Acquire) {
+ match reader.read(&mut scratch) {
+ Ok(0) => break,
+ Ok(n) => {
+ buffer.extend_from_slice(&scratch[..n]);
+
+ for data in crate::raw_input::drain_complete_input_bytes(&mut buffer) {
+ if event_tx
+ .blocking_send(ClientLoopEvent::StdinInput(data))
+ .is_err()
+ {
+ return;
+ }
+ }
+
+ if !buffer.is_empty() && stdin_read_ready(&reader, 10) == Some(false) {
+ if let Some(data) = crate::raw_input::flush_incomplete_input_bytes(&mut buffer)
+ {
+ if event_tx
+ .blocking_send(ClientLoopEvent::StdinInput(data))
+ .is_err()
+ {
+ return;
+ }
+ }
+ }
+ }
+ Err(err) => {
+ if err.kind() == io::ErrorKind::Interrupted {
+ continue;
+ }
+ break;
+ }
+ }
+ }
+}
+
+#[cfg(unix)]
+fn stdin_read_ready<R: AsRawFd>(reader: &R, timeout_ms: i32) -> Option<bool> {
+ poll_read_ready(reader.as_raw_fd(), timeout_ms)
+}
+
+#[cfg(not(unix))]
+fn stdin_read_ready<R>(_reader: &R, _timeout_ms: i32) -> Option<bool> {
+ None
+}
+
+#[cfg(unix)]
+fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option<bool> {
+ #[repr(C)]
+ struct PollFd {
+ fd: i32,
+ events: i16,
+ revents: i16,
+ }
+
+ unsafe extern "C" {
+ fn poll(fds: *mut PollFd, nfds: usize, timeout: i32) -> i32;
+ }
+
+ const POLLIN: i16 = 0x0001;
+
+ let mut pfd = PollFd {
+ fd,
+ events: POLLIN,
+ revents: 0,
+ };
+
+ let result = unsafe { poll(&mut pfd as *mut PollFd, 1, timeout_ms) };
+ if result < 0 {
+ None
+ } else {
+ Some(result > 0)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ // The stdin reader thread is hard to unit test since it reads from actual stdin.
+ // Integration tests will verify the full client→server input flow.
+ // Here we test the event type construction.
+
+ use super::*;
+
+ #[test]
+ fn stdin_input_event_carries_raw_bytes() {
+ let data = vec![0x1b, b'[', b'A']; // Up arrow escape sequence
+ let event = ClientLoopEvent::StdinInput(data.clone());
+ match event {
+ ClientLoopEvent::StdinInput(d) => assert_eq!(d, data),
+ _ => panic!("expected StdinInput event"),
+ }
+ }
+}
diff --git a/src/client/mod.rs b/src/client/mod.rs
new file mode 100644
index 00000000..2476a069
--- /dev/null
+++ b/src/client/mod.rs
@@ -0,0 +1,741 @@
+//! Thin client mode — connects to the server's client socket.
+//!
+//! The client:
+//! - Connects to `herdr-client.sock`, sends Hello with terminal size and protocol version
+//! - Sets up the real terminal (raw mode, mouse capture, keyboard enhancements)
+//! - Receives Frame messages and blits them to the terminal (diff against last frame)
+//! - Reads stdin events (keystrokes, mouse, paste) and sends them as ClientMessage::Input
+//! - Detects terminal resize and sends ClientMessage::Resize
+//! - Restores terminal on exit (normal or error)
+//! - Handles ServerShutdown gracefully (clean exit, informative message to stderr)
+//! - Handles server unreachable (clear error screen, not blank/hang)
+//! - Forwards OSC 52 clipboard writes from server to its own stdout
+//! - Displays sound/toast notifications forwarded from server
+
+mod blit;
+mod input;
+
+use std::io::{self, Write as _};
+use std::os::unix::net::UnixStream;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::time::Duration;
+
+use crossterm::event::{
+ DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
+ KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
+};
+use crossterm::execute;
+use tracing::{debug, info, warn};
+
+use crate::server::headless::client_socket_path;
+use crate::server::protocol::{
+ self, ClientMessage, FrameData, NotifyKind, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION,
+};
+
+// ---------------------------------------------------------------------------
+// Client state
+// ---------------------------------------------------------------------------
+
+/// State tracking for the thin client.
+struct ClientState {
+ /// The last frame we rendered, used for diff-based blitting.
+ last_frame: Option<FrameData>,
+ /// The terminal size we reported to the server in our last Hello/Resize.
+ reported_size: (u16, u16),
+}
+
+// ---------------------------------------------------------------------------
+// Error types
+// ---------------------------------------------------------------------------
+
+/// Errors that can occur during client operation.
+#[derive(Debug)]
+pub enum ClientError {
+ /// Could not connect to the server's client socket.
+ ConnectionFailed(io::Error),
+ /// Server rejected our handshake.
+ HandshakeRejected { version: u32, error: String },
+ /// Server shut down.
+ ServerShutdown { reason: Option<String> },
+ /// Lost connection to the server.
+ ConnectionLost(io::Error),
+ /// Protocol error (framing, deserialization).
+ Protocol(protocol::FramingError),
+}
+
+impl std::fmt::Display for ClientError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ClientError::ConnectionFailed(err) => {
+ write!(f, "failed to connect to server: {err}")?;
+ let path = client_socket_path();
+ write!(
+ f,
+ "\nIs herdr server running? Start it with `herdr server`."
+ )?;
+ write!(f, "\nSocket path: {}", path.display())
+ }
+ ClientError::HandshakeRejected { version, error } => {
+ write!(f, "server rejected handshake (version {version}): {error}")
+ }
+ ClientError::ServerShutdown { reason } => {
+ match reason.as_deref() {
+ Some("detached") => {
+ write!(f, "detached from server")?;
+ write!(f, "\nRun `herdr` to reattach")?;
+ }
+ _ => {
+ write!(f, "server shut down")?;
+ if let Some(reason) = reason {
+ write!(f, ": {reason}")?;
+ }
+ }
+ }
+ Ok(())
+ }
+ ClientError::ConnectionLost(err) => {
+ write!(f, "lost connection to server: {err}")
+ }
+ ClientError::Protocol(err) => {
+ write!(f, "protocol error: {err}")
+ }
+ }
+ }
+}
+
+impl std::error::Error for ClientError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ ClientError::ConnectionFailed(err) => Some(err),
+ ClientError::ConnectionLost(err) => Some(err),
+ ClientError::Protocol(err) => Some(err),
+ _ => None,
+ }
+ }
+}
+
+impl From<protocol::FramingError> for ClientError {
+ fn from(err: protocol::FramingError) -> Self {
+ ClientError::Protocol(err)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Terminal setup / restore
+// ---------------------------------------------------------------------------
+
+/// Sets up the terminal for client mode (raw mode, mouse, keyboard enhancements).
+///
+/// Returns a guard that restores the terminal when dropped.
+fn setup_terminal() -> io::Result<TerminalGuard> {
+ ratatui::init();
+ execute!(
+ io::stdout(),
+ EnableMouseCapture,
+ EnableBracketedPaste,
+ PushKeyboardEnhancementFlags(
+ KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
+ | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
+ | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
+ )
+ )?;
+
+ // tmux doesn't understand kitty keyboard protocol push.
+ // Enable modifyOtherKeys mode 2 for tmux.
+ let in_tmux = std::env::var("TMUX").is_ok();
+ if in_tmux {
+ io::stdout().write_all(b"\x1b[>4;2m")?;
+ io::stdout().flush()?;
+ }
+
+ Ok(TerminalGuard { in_tmux })
+}
+
+/// Guard that restores the terminal when dropped.
+struct TerminalGuard {
+ in_tmux: bool,
+}
+
+impl Drop for TerminalGuard {
+ fn drop(&mut self) {
+ // Reset modifyOtherKeys if we enabled it.
+ if self.in_tmux {
+ let _ = io::stdout().write_all(b"\x1b[>4;0m");
+ let _ = io::stdout().flush();
+ }
+
+ let _ = execute!(
+ io::stdout(),
+ PopKeyboardEnhancementFlags,
+ DisableBracketedPaste,
+ DisableMouseCapture
+ );
+ ratatui::restore();
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Handshake
+// ---------------------------------------------------------------------------
+
+/// Performs the client→server handshake.
+///
+/// Sends Hello with the terminal size and protocol version, reads the Welcome
+/// response. Returns Ok(()) on success, or an error if the server rejects us.
+fn do_handshake(stream: &mut UnixStream, cols: u16, rows: u16) -> Result<(), ClientError> {
+ // Send Hello.
+ let hello = ClientMessage::Hello {
+ version: PROTOCOL_VERSION,
+ cols,
+ rows,
+ };
+ protocol::write_message(stream, &hello).map_err(|e| {
+ ClientError::ConnectionFailed(io::Error::new(io::ErrorKind::Other, e.to_string()))
+ })?;
+
+ // Read Welcome.
+ stream
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .map_err(ClientError::ConnectionFailed)?;
+ let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?;
+ stream
+ .set_read_timeout(None)
+ .map_err(ClientError::ConnectionFailed)?;
+
+ match welcome {
+ ServerMessage::Welcome { version, error } => {
+ if let Some(error) = error {
+ return Err(ClientError::HandshakeRejected { version, error });
+ }
+ info!(version, "handshake succeeded");
+ Ok(())
+ }
+ _ => Err(ClientError::Protocol(protocol::FramingError::Io(
+ io::Error::new(io::ErrorKind::InvalidData, "expected Welcome message"),
+ ))),
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Client event loop
+// ---------------------------------------------------------------------------
+
+/// Internal events for the client event loop.
+enum ClientLoopEvent {
+ /// Raw input bytes from stdin.
+ StdinInput(Vec<u8>),
+ /// Terminal resize detected.
+ Resize(u16, u16),
+ /// Server message received.
+ ServerMessage(ServerMessage),
+ /// Server reader thread exited (connection lost).
+ ServerDisconnected,
+ /// Timer tick.
+ Timer,
+}
+
+/// Runs the thin client: connects to the server, performs the handshake,
+/// and enters the main event loop.
+///
+/// This is the entry point called from `main.rs` when running in client mode.
+pub fn run_client() -> io::Result<()> {
+ init_logging();
+
+ let loaded_config = crate::config::Config::load();
+ let sound_config = loaded_config.config.ui.sound;
+
+ let socket_path = client_socket_path();
+ info!(path = %socket_path.display(), "connecting to server");
+
+ // Try to connect to the server.
+ let mut stream = match UnixStream::connect(&socket_path) {
+ Ok(s) => s,
+ Err(err) => {
+ // Server unreachable — show clear error and exit.
+ let client_err = ClientError::ConnectionFailed(err);
+ eprintln!("herdr: {client_err}");
+ std::process::exit(1);
+ }
+ };
+
+ // Get the terminal size before handshake (before raw mode).
+ let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
+
+ // Perform handshake while the stream is still in blocking mode.
+ if let Err(err) = do_handshake(&mut stream, cols, rows) {
+ eprintln!("herdr: {err}");
+ std::process::exit(1);
+ }
+
+ // Now set up the terminal (raw mode, mouse, keyboard enhancements).
+ // This must happen AFTER the handshake succeeds, so we don't leave
+ // the terminal in raw mode if the server rejects us.
+ let _guard = setup_terminal().map_err(|err| {
+ eprintln!("herdr: failed to set up terminal: {err}");
+ err
+ })?;
+
+ // Install a panic hook to restore the terminal on panic (same as monolithic).
+ let in_tmux = std::env::var("TMUX").is_ok();
+ let original_hook = std::panic::take_hook();
+ std::panic::set_hook(Box::new(move |info| {
+ if in_tmux {
+ let _ = io::stdout().write_all(b"\x1b[>4;0m");
+ }
+ let _ = execute!(
+ io::stdout(),
+ PopKeyboardEnhancementFlags,
+ DisableBracketedPaste,
+ DisableMouseCapture
+ );
+ ratatui::restore();
+ original_hook(info);
+ }));
+
+ // Create the tokio runtime.
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
+
+ let should_quit = Arc::new(AtomicBool::new(false));
+
+ // Install Ctrl+C handler.
+ let quit_flag = should_quit.clone();
+ let _ = ctrlc::set_handler(move || {
+ quit_flag.store(true, Ordering::Release);
+ });
+
+ let result =
+ rt.block_on(async { run_client_loop(stream, cols, rows, should_quit, sound_config).await });
+
+ // Restore the terminal before printing any final status message.
+ drop(_guard);
+
+ if let Err(err) = result {
+ eprintln!("herdr: {err}");
+ rt.shutdown_timeout(Duration::from_millis(100));
+ info!("herdr client exiting");
+
+ if matches!(
+ err,
+ ClientError::ServerShutdown {
+ reason: Some(reason)
+ } if reason == "detached"
+ ) {
+ return Ok(());
+ }
+
+ std::process::exit(1);
+ }
+
+ rt.shutdown_timeout(Duration::from_millis(100));
+ info!("herdr client exiting");
+ Ok(())
+}
+
+/// The main client event loop.
+///
+/// Uses a threaded architecture:
+/// - stdin reader thread → sends raw input bytes to main loop
+/// - resize poller thread → sends resize events to main loop
+/// - server reader thread → reads ServerMessages and sends to main loop
+/// - main loop: coordinates input, output, and server communication
+async fn run_client_loop(
+ stream: UnixStream,
+ cols: u16,
+ rows: u16,
+ should_quit: Arc<AtomicBool>,
+ sound_config: crate::config::SoundConfig,
+) -> Result<(), ClientError> {
+ let mut state = ClientState {
+ last_frame: None,
+ reported_size: (cols, rows),
+ };
+
+ // Channel for events from the stdin, resize, and server reader threads.
+ let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256);
+
+ // Spawn the stdin reader thread.
+ let stdin_quit = should_quit.clone();
+ let stdin_tx = event_tx.clone();
+ std::thread::spawn(move || {
+ input::stdin_reader_loop(stdin_tx, &stdin_quit);
+ });
+
+ query_host_terminal_theme();
+
+ // Spawn the resize poller thread.
+ let resize_quit = should_quit.clone();
+ let resize_tx = event_tx.clone();
+ std::thread::spawn(move || {
+ resize_poll_loop(resize_tx, cols, rows, &resize_quit);
+ });
+
+ // Spawn the server reader thread (blocking reads from the socket).
+ // Clone the stream's file descriptor so we can read from a blocking stream.
+ let server_read_quit = should_quit.clone();
+ let server_read_tx = event_tx.clone();
+ let read_stream = stream.try_clone().map_err(ClientError::ConnectionFailed)?;
+ std::thread::spawn(move || {
+ server_reader_thread(read_stream, server_read_tx, &server_read_quit);
+ });
+
+ // Use the original stream for writing (blocking is fine since we write
+ // from the async loop).
+ let mut write_stream = stream;
+ write_stream
+ .set_nonblocking(false)
+ .map_err(ClientError::ConnectionFailed)?;
+
+ // Main event loop.
+ while !should_quit.load(Ordering::Acquire) {
+ let event = tokio::select! {
+ ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer),
+ _ = tokio::time::sleep(Duration::from_millis(100)) => ClientLoopEvent::Timer,
+ };
+
+ match event {
+ ClientLoopEvent::StdinInput(data) => {
+ let msg = ClientMessage::Input { data };
+ if let Err(e) = write_to_server(&mut write_stream, &msg) {
+ return Err(ClientError::ConnectionLost(e));
+ }
+ }
+ ClientLoopEvent::Resize(new_cols, new_rows) => {
+ state.reported_size = (new_cols, new_rows);
+ let msg = ClientMessage::Resize {
+ cols: new_cols,
+ rows: new_rows,
+ };
+ if let Err(e) = write_to_server(&mut write_stream, &msg) {
+ return Err(ClientError::ConnectionLost(e));
+ }
+ }
+ ClientLoopEvent::ServerMessage(msg) => match msg {
+ ServerMessage::Frame(frame_data) => {
+ blit::blit_frame(&frame_data, state.last_frame.as_ref());
+ state.last_frame = Some(frame_data);
+ let _ = io::stdout().flush();
+ }
+ ServerMessage::ServerShutdown { reason } => {
+ return Err(ClientError::ServerShutdown { reason });
+ }
+ ServerMessage::Notify { kind, message } => {
+ handle_notify(kind, &message, &sound_config);
+ }
+ ServerMessage::Clipboard { data } => {
+ forward_clipboard(&data);
+ let _ = io::stdout().flush();
+ }
+ ServerMessage::Welcome { .. } => {
+ debug!("received unexpected Welcome in main loop");
+ }
+ },
+ ClientLoopEvent::ServerDisconnected => {
+ return Err(ClientError::ConnectionLost(io::Error::new(
+ io::ErrorKind::UnexpectedEof,
+ "server closed connection",
+ )));
+ }
+ ClientLoopEvent::Timer => {
+ // Check if we should quit.
+ }
+ }
+ }
+
+ // Clean exit (Ctrl+C). Send Detach before closing.
+ let detach = ClientMessage::Detach;
+ let _ = write_to_server(&mut write_stream, &detach);
+ let _ = io::stdout().flush();
+
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// Server reader thread
+// ---------------------------------------------------------------------------
+
+/// Blocking thread that reads ServerMessages from the server and sends them
+/// to the main event loop.
+fn server_reader_thread(
+ mut stream: UnixStream,
+ event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>,
+ should_quit: &Arc<AtomicBool>,
+) {
+ // Ensure the read stream is in blocking mode to avoid WouldBlock errors
+ // from read_exact inside read_message. The stream should already be
+ // blocking after handshake, but we enforce it here as a safety measure.
+ if stream.set_nonblocking(false).is_err() {
+ // If we can't set blocking mode, the stream is likely broken.
+ let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected);
+ return;
+ }
+
+ loop {
+ if should_quit.load(Ordering::Acquire) {
+ break;
+ }
+
+ match protocol::read_message(&mut stream, MAX_FRAME_SIZE) {
+ Ok(msg) => {
+ if event_tx
+ .blocking_send(ClientLoopEvent::ServerMessage(msg))
+ .is_err()
+ {
+ break; // Main loop gone.
+ }
+ }
+ Err(protocol::FramingError::UnexpectedEof) => {
+ // Server closed connection.
+ let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected);
+ break;
+ }
+ Err(protocol::FramingError::Io(err)) if err.kind() == io::ErrorKind::WouldBlock => {
+ // Should not happen with blocking mode, but handle gracefully
+ // in case the stream was set nonblocking by another clone.
+ std::thread::sleep(Duration::from_millis(1));
+ continue;
+ }
+ Err(err) => {
+ warn!(err = %err, "server read error");
+ let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected);
+ break;
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Write helper
+// ---------------------------------------------------------------------------
+
+/// Writes a message to the server stream (blocking).
+fn write_to_server(stream: &mut UnixStream, msg: &ClientMessage) -> io::Result<()> {
+ protocol::write_message(stream, msg)
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))
+}
+
+// ---------------------------------------------------------------------------
+// Notifications
+// ---------------------------------------------------------------------------
+
+fn handle_notify(kind: NotifyKind, message: &str, sound_config: &crate::config::SoundConfig) {
+ match kind {
+ NotifyKind::Sound => {
+ let Some(sound) = sound_from_notify_message(message) else {
+ warn!(
+ message = message,
+ "received unknown sound notification from server"
+ );
+ return;
+ };
+ if sound_config.enabled {
+ crate::sound::play(sound, sound_config);
+ }
+ }
+ NotifyKind::Toast => {
+ debug!(message = message, "received toast notification from server");
+ }
+ }
+}
+
+fn sound_from_notify_message(message: &str) -> Option<crate::sound::Sound> {
+ match message {
+ "agent done" => Some(crate::sound::Sound::Done),
+ "agent attention" => Some(crate::sound::Sound::Request),
+ _ => None,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Clipboard forwarding
+// ---------------------------------------------------------------------------
+
+/// Decode a clipboard payload forwarded by the server.
+fn decode_clipboard_payload(data: &str) -> Option<Vec<u8>> {
+ use base64::Engine;
+ base64::engine::general_purpose::STANDARD.decode(data).ok()
+}
+
+/// Forwards a clipboard write from the server to the local client clipboard.
+fn forward_clipboard(data: &str) {
+ let Some(bytes) = decode_clipboard_payload(data) else {
+ warn!("received invalid clipboard payload from server");
+ return;
+ };
+
+ crate::selection::write_osc52_bytes(&bytes);
+}
+
+// ---------------------------------------------------------------------------
+// Resize polling
+// ---------------------------------------------------------------------------
+
+/// Polls the terminal size and sends resize events when it changes.
+fn resize_poll_loop(
+ resize_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>,
+ initial_cols: u16,
+ initial_rows: u16,
+ should_quit: &Arc<AtomicBool>,
+) {
+ let mut last_size = (initial_cols, initial_rows);
+ while !should_quit.load(Ordering::Acquire) {
+ std::thread::sleep(Duration::from_millis(100));
+ let new_size = crossterm::terminal::size().unwrap_or(last_size);
+ if new_size != last_size {
+ last_size = new_size;
+ if resize_tx
+ .blocking_send(ClientLoopEvent::Resize(new_size.0, new_size.1))
+ .is_err()
+ {
+ break; // Main loop gone.
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Logging
+// ---------------------------------------------------------------------------
+
+/// Initialize logging for the client process.
+fn query_host_terminal_theme() {
+ let _ = write_host_terminal_theme_query(io::stdout());
+}
+
+fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> {
+ writer.write_all(crate::terminal_theme::HOST_COLOR_QUERY_SEQUENCE.as_bytes())?;
+ writer.flush()
+}
+
+fn init_logging() {
+ crate::logging::init_file_logging("herdr-client.log");
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn write_host_terminal_theme_query_emits_osc_queries() {
+ let mut output = Vec::new();
+ write_host_terminal_theme_query(&mut output).unwrap();
+ assert_eq!(
+ output,
+ crate::terminal_theme::HOST_COLOR_QUERY_SEQUENCE.as_bytes()
+ );
+ }
+
+ #[test]
+ fn client_error_display_connection_failed() {
+ let err = ClientError::ConnectionFailed(io::Error::new(
+ io::ErrorKind::ConnectionRefused,
+ "connection refused",
+ ));
+ let msg = err.to_string();
+ assert!(
+ msg.contains("failed to connect to server"),
+ "should mention connection failure: {msg}"
+ );
+ assert!(
+ msg.contains("herdr server"),
+ "should suggest starting server: {msg}"
+ );
+ }
+
+ #[test]
+ fn client_error_display_handshake_rejected() {
+ let err = ClientError::HandshakeRejected {
+ version: 1,
+ error: "incompatible".into(),
+ };
+ let msg = err.to_string();
+ assert!(
+ msg.contains("rejected handshake"),
+ "should mention rejection: {msg}"
+ );
+ assert!(msg.contains("incompatible"), "should include error: {msg}");
+ }
+
+ #[test]
+ fn client_error_display_server_shutdown() {
+ let err = ClientError::ServerShutdown {
+ reason: Some("maintenance".into()),
+ };
+ let msg = err.to_string();
+ assert!(
+ msg.contains("server shut down"),
+ "should mention shutdown: {msg}"
+ );
+ assert!(msg.contains("maintenance"), "should include reason: {msg}");
+ }
+
+ #[test]
+ fn client_error_display_server_shutdown_no_reason() {
+ let err = ClientError::ServerShutdown { reason: None };
+ let msg = err.to_string();
+ assert!(
+ msg.contains("server shut down"),
+ "should mention shutdown: {msg}"
+ );
+ }
+
+ #[test]
+ fn client_error_display_connection_lost() {
+ let err =
+ ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe"));
+ let msg = err.to_string();
+ assert!(
+ msg.contains("lost connection to server"),
+ "should mention lost connection: {msg}"
+ );
+ }
+
+ #[test]
+ fn sound_from_notify_message_maps_done() {
+ assert_eq!(
+ sound_from_notify_message("agent done"),
+ Some(crate::sound::Sound::Done)
+ );
+ }
+
+ #[test]
+ fn sound_from_notify_message_maps_attention() {
+ assert_eq!(
+ sound_from_notify_message("agent attention"),
+ Some(crate::sound::Sound::Request)
+ );
+ }
+
+ #[test]
+ fn sound_from_notify_message_rejects_unknown_payloads() {
+ assert_eq!(sound_from_notify_message("toast"), None);
+ }
+
+ #[test]
+ fn decode_clipboard_payload_decodes_base64() {
+ assert_eq!(decode_clipboard_payload("dGVzdA=="), Some(b"test".to_vec()));
+ }
+
+ #[test]
+ fn decode_clipboard_payload_rejects_invalid_base64() {
+ assert_eq!(decode_clipboard_payload("not-base64!!!"), None);
+ }
+
+ #[test]
+ fn forward_clipboard_uses_local_clipboard_path() {
+ unsafe {
+ std::env::set_var("SSH_CONNECTION", "1 2 3 4");
+ }
+ forward_clipboard("dGVzdA==");
+ unsafe {
+ std::env::remove_var("SSH_CONNECTION");
+ }
+ }
+}