diff options
Diffstat (limited to 'src/client')
49 files changed, 28878 insertions, 2802 deletions
diff --git a/src/client/attach.rs b/src/client/attach.rs new file mode 100644 index 00000000..e385775c --- /dev/null +++ b/src/client/attach.rs @@ -0,0 +1,582 @@ +//! Direct terminal attach input parsing and semantic actions. + +#[cfg(unix)] +use std::io; + +#[cfg(unix)] +use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; + +#[cfg(unix)] +use super::write_to_server; +#[cfg(unix)] +use crate::ipc::LocalStream; +#[cfg(unix)] +use crate::protocol::{AttachScrollDirection, AttachScrollSource, ClientMessage}; + +#[derive(Debug, Default)] +#[cfg(windows)] +pub(super) struct AttachEscapeState; + +#[derive(Debug, Default)] +#[cfg(unix)] +pub(super) struct AttachEscapeState { + pending_prefix: Option<Vec<u8>>, +} + +#[derive(Debug)] +#[cfg(unix)] +pub(super) enum AttachInputAction { + Forward(Vec<u8>), + ForwardPair(Vec<u8>, Vec<u8>), + Semantic(AttachSemanticAction), + ForwardThenSemantic(Vec<u8>, AttachSemanticAction), + Detach, + None, +} + +#[derive(Debug)] +#[cfg(unix)] +pub(super) enum AttachSemanticAction { + Scroll { + source: AttachScrollSource, + direction: AttachScrollDirection, + lines: u16, + column: Option<u16>, + row: Option<u16>, + modifiers: u8, + }, + Mouse { + kind: crate::protocol::ClientMouseKind, + position: crate::protocol::ClientMousePosition, + modifiers: u8, + }, + Ignore, +} + +impl AttachEscapeState { + #[cfg(unix)] + pub(super) fn filter_input( + &mut self, + data: Vec<u8>, + viewport_rows: u16, + mouse_scroll_lines: usize, + ) -> AttachInputAction { + const PREFIX: u8 = 0x02; // Ctrl+B + + if crate::raw_input::is_complete_text_bracketed_paste(&data) { + return if let Some(prefix) = self.pending_prefix.take() { + AttachInputAction::ForwardPair(prefix, data) + } else { + AttachInputAction::Forward(data) + }; + } + + if let Some(key) = single_attach_key(&data) { + let is_prefix = key.code == crossterm::event::KeyCode::Char('b') + && key.modifiers == crossterm::event::KeyModifiers::CONTROL; + let is_quit = key.code == crossterm::event::KeyCode::Char('q') + && key.modifiers.is_empty() + && key.kind == crossterm::event::KeyEventKind::Press; + + if let Some(mut prefix) = self.pending_prefix.take() { + if is_prefix && key.kind != crossterm::event::KeyEventKind::Press { + prefix.extend(data); + self.pending_prefix = Some(prefix); + return AttachInputAction::None; + } + if is_quit { + return AttachInputAction::Detach; + } + if is_prefix { + return AttachInputAction::Forward(data); + } + if let Some(action) = attach_scroll_action(&data, viewport_rows, mouse_scroll_lines) + { + return AttachInputAction::ForwardThenSemantic(prefix, action); + } + prefix.extend(data); + return AttachInputAction::Forward(prefix); + } + + if is_prefix && key.kind == crossterm::event::KeyEventKind::Press { + self.pending_prefix = Some(data); + return AttachInputAction::None; + } + } + + if let Some(action) = attach_scroll_action(&data, viewport_rows, mouse_scroll_lines) { + return if let Some(prefix) = self.pending_prefix.take() { + AttachInputAction::ForwardThenSemantic(prefix, action) + } else { + AttachInputAction::Semantic(action) + }; + } + + // The host framer normally supplies one complete event. Preserve the legacy + // byte path for coalesced plain input used by older terminals. + let mut output = Vec::with_capacity(data.len()); + for byte in data { + if let Some(mut prefix) = self.pending_prefix.take() { + match byte { + b'q' => return AttachInputAction::Detach, + PREFIX => output.extend(prefix), + other => { + prefix.push(other); + output.extend(prefix); + } + } + continue; + } + + if byte == PREFIX { + self.pending_prefix = Some(vec![PREFIX]); + } else { + output.push(byte); + } + } + + if output.is_empty() { + AttachInputAction::None + } else if let Some(action) = + attach_scroll_action(&output, viewport_rows, mouse_scroll_lines) + { + AttachInputAction::Semantic(action) + } else { + AttachInputAction::Forward(output) + } + } + + #[cfg(unix)] + pub(super) fn take_pending_prefix(&mut self) -> Option<Vec<u8>> { + self.pending_prefix.take() + } +} + +#[cfg(unix)] +fn single_attach_key(data: &[u8]) -> Option<crate::input::TerminalKey> { + let mut events = crate::raw_input::parse_raw_input_bytes_sync(data); + if events.len() != 1 { + return None; + } + match events.pop()? { + crate::raw_input::RawInputEvent::Key(key) => Some(key), + _ => None, + } +} + +#[cfg(unix)] +pub(super) fn direct_attach_pixel_mouse( + data: &[u8], + geometry: crate::input::mouse::HostGeometry, +) -> Option<( + crate::protocol::ClientMouseKind, + crate::protocol::ClientMousePosition, + u8, +)> { + let (x, y) = crate::input::mouse::parse_report(data)?; + let (column, row) = geometry.cell(x, y)?; + let cell_report = crate::input::mouse::report_at_cell(data, column, row)?; + let mut events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report); + if events.len() != 1 { + return None; + } + let crate::raw_input::RawInputEvent::Mouse(mouse) = events.pop()? else { + return None; + }; + Some(( + crate::protocol::ClientMouseKind::from_crossterm(mouse.kind)?, + crate::protocol::ClientMousePosition::Pixels { x, y, column, row }, + mouse.modifiers.bits(), + )) +} + +#[cfg(unix)] +fn attach_scroll_action( + data: &[u8], + viewport_rows: u16, + mouse_scroll_lines: usize, +) -> Option<AttachSemanticAction> { + let mut events = crate::raw_input::parse_raw_input_bytes_sync(data); + if events.len() != 1 { + return None; + } + + match events.pop()? { + crate::raw_input::RawInputEvent::Mouse(mouse) => match mouse.kind { + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { + let direction = if mouse.kind == MouseEventKind::ScrollUp { + AttachScrollDirection::Up + } else { + AttachScrollDirection::Down + }; + Some(AttachSemanticAction::Scroll { + source: AttachScrollSource::Wheel, + direction, + lines: mouse_scroll_lines.max(1).min(u16::MAX as usize) as u16, + column: Some(mouse.column), + row: Some(mouse.row), + modifiers: mouse.modifiers.bits(), + }) + } + kind => Some(AttachSemanticAction::Mouse { + kind: crate::protocol::ClientMouseKind::from_crossterm(kind)?, + position: crate::protocol::ClientMousePosition::Cell { + column: mouse.column, + row: mouse.row, + }, + modifiers: mouse.modifiers.bits(), + }), + }, + crate::raw_input::RawInputEvent::Key(key) + if key.modifiers.is_empty() + && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => + { + let direction = match key.code { + KeyCode::PageUp => AttachScrollDirection::Up, + KeyCode::PageDown => AttachScrollDirection::Down, + _ => return None, + }; + Some(AttachSemanticAction::Scroll { + source: AttachScrollSource::PageKey { + input: data.to_vec(), + }, + direction, + lines: viewport_rows.saturating_sub(1).max(1), + column: None, + row: None, + modifiers: KeyModifiers::empty().bits(), + }) + } + crate::raw_input::RawInputEvent::Key(key) + if key.modifiers.is_empty() + && key.kind == KeyEventKind::Release + && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) => + { + Some(AttachSemanticAction::Ignore) + } + _ => None, + } +} + +#[cfg(unix)] +pub(super) fn write_attach_semantic_action( + stream: &mut LocalStream, + action: AttachSemanticAction, +) -> io::Result<()> { + let message = match action { + AttachSemanticAction::Scroll { + source, + direction, + lines, + column, + row, + modifiers, + } => ClientMessage::AttachScroll { + source, + direction, + lines, + column, + row, + modifiers, + }, + AttachSemanticAction::Mouse { + kind, + position, + modifiers, + } => ClientMessage::AttachMouse { + kind, + position, + geometry: None, + modifiers, + lines: 1, + }, + AttachSemanticAction::Ignore => return Ok(()), + }; + write_to_server(stream, &message) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::protocol::{AttachScrollDirection, AttachScrollSource}; + + #[cfg(unix)] + #[test] + fn attach_escape_detaches_on_prefix_q() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(vec![b'q'], 24, 3), + AttachInputAction::Detach + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_sends_literal_prefix_on_double_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + match escape.filter_input(vec![0x02], 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02]), + other => panic!("expected forwarded prefix, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_detaches_on_kitty_encoded_prefix_q() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[98;5u".to_vec(), 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(b"\x1b[98;5:3u".to_vec(), 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(b"\x1b[113u".to_vec(), 24, 3), + AttachInputAction::Detach + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_detaches_on_modify_other_keys_encoded_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[27;5;98~".to_vec(), 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(b"q".to_vec(), 24, 3), + AttachInputAction::Detach + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_forwards_kitty_encoded_literal_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[98;5u".to_vec(), 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(b"\x1b[98;5:3u".to_vec(), 24, 3), + AttachInputAction::None + )); + match escape.filter_input(b"\x1b[98;5u".to_vec(), 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[98;5u"), + other => panic!("expected Kitty-encoded prefix, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_does_not_interpret_bracketed_paste_contents() { + let mut escape = AttachEscapeState::default(); + let paste = b"\x1b[200~one\x02q\ntwo\x1b[201~".to_vec(); + + match escape.filter_input(paste.clone(), 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, paste), + other => panic!("expected opaque paste, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_flushes_pending_prefix_before_bracketed_paste() { + let mut escape = AttachEscapeState::default(); + let paste = b"\x1b[200~one\ntwo\x1b[201~".to_vec(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + + assert!(matches!( + escape.filter_input(paste.clone(), 24, 3), + AttachInputAction::ForwardPair(prefix, bytes) + if prefix == vec![0x02] && bytes == paste + )); + assert!(matches!( + escape.filter_input(vec![b'q'], 24, 3), + AttachInputAction::Forward(bytes) if bytes == b"q" + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_forwards_prefix_before_non_escape_key() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![b'a', 0x02], 24, 3), + AttachInputAction::Forward(bytes) if bytes == b"a" + )); + match escape.filter_input(vec![b'x'], 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02, b'x']), + other => panic!("expected forwarded bytes, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_turns_wheel_into_scroll_action() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[<64;11;6M".to_vec(), 24, 7) { + AttachInputAction::Semantic(AttachSemanticAction::Scroll { + source, + direction, + lines, + column, + row, + .. + }) => { + assert_eq!(source, AttachScrollSource::Wheel); + assert_eq!(direction, AttachScrollDirection::Up); + assert_eq!(lines, 7); + assert_eq!(column, Some(10)); + assert_eq!(row, Some(5)); + } + other => panic!("expected scroll action, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_routes_non_wheel_mouse_reports_semantically() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7), + AttachInputAction::Semantic(AttachSemanticAction::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left + ), + position: crate::protocol::ClientMousePosition::Cell { column: 10, row: 5 }, + modifiers: 0, + }) + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_flushes_pending_prefix_before_cell_mouse() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + + assert!(matches!( + escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7), + AttachInputAction::ForwardThenSemantic( + prefix, + AttachSemanticAction::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left + ), + position: crate::protocol::ClientMousePosition::Cell { + column: 10, + row: 5 + }, + modifiers: 0, + } + ) if prefix == vec![0x02] + )); + } + + #[cfg(unix)] + #[test] + fn direct_attach_pixel_mouse_keeps_pixels_and_semantic_kind() { + let geometry = crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap(); + let (kind, position, modifiers) = + direct_attach_pixel_mouse(b"\x1b[<0;21;22M", geometry).expect("pixel mouse"); + + assert_eq!( + kind, + crate::protocol::ClientMouseKind::Down(crate::protocol::ClientMouseButton::Left) + ); + assert_eq!( + position, + crate::protocol::ClientMousePosition::Pixels { + x: 21, + y: 22, + column: 2, + row: 1, + } + ); + assert_eq!(modifiers, 0); + } + + #[cfg(unix)] + #[test] + fn pixel_mouse_flushes_pending_attach_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + + assert_eq!(escape.take_pending_prefix(), Some(vec![0x02])); + assert_eq!(escape.take_pending_prefix(), None); + } + + #[cfg(unix)] + #[test] + fn attach_escape_turns_plain_page_keys_into_scroll_actions() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[5~".to_vec(), 12, 3) { + AttachInputAction::Semantic(AttachSemanticAction::Scroll { + source, + direction, + lines, + .. + }) => { + assert_eq!( + source, + AttachScrollSource::PageKey { + input: b"\x1b[5~".to_vec() + } + ); + assert_eq!(direction, AttachScrollDirection::Up); + assert_eq!(lines, 11); + } + other => panic!("expected page-up scroll action, got {other:?}"), + } + + match escape.filter_input(b"\x1b[6~".to_vec(), 12, 3) { + AttachInputAction::Semantic(AttachSemanticAction::Scroll { + source, + direction, + lines, + .. + }) => { + assert_eq!( + source, + AttachScrollSource::PageKey { + input: b"\x1b[6~".to_vec() + } + ); + assert_eq!(direction, AttachScrollDirection::Down); + assert_eq!(lines, 11); + } + other => panic!("expected page-down scroll action, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_forwards_modified_page_key() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[5;5~".to_vec(), 12, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[5;5~"), + other => panic!("expected modified page key to forward, got {other:?}"), + } + } +} diff --git a/src/client/clipboard_images.rs b/src/client/clipboard_images.rs new file mode 100644 index 00000000..c1c49cbc --- /dev/null +++ b/src/client/clipboard_images.rs @@ -0,0 +1,248 @@ +use std::path::PathBuf; + +use tracing::{info, warn}; + +use crate::ipc::LocalStream; +#[cfg(windows)] +use crate::protocol::ClientInputEvent; +use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD; +use crate::protocol::{ClientClipboardImageTarget, ClientMessage}; + +use super::{is_remote_client_process, write_to_server, ClientError}; + +pub(super) fn write_remote_image_to_server( + stream: &mut LocalStream, + target: ClientClipboardImageTarget, + image: crate::platform::ClipboardImage, + source: &'static str, +) -> Result<(), ClientError> { + if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD { + warn!( + bytes = image.bytes.len(), + max = MAX_CLIPBOARD_IMAGE_PAYLOAD, + source, + "local image is too large to bridge" + ); + return Ok(()); + } + + info!( + bytes = image.bytes.len(), + extension = image.extension, + source, + "bridging local image to remote server" + ); + write_to_server( + stream, + &ClientMessage::ClipboardImage { + target, + extension: image.extension.to_owned(), + data: image.bytes, + }, + ) + .map_err(ClientError::ConnectionLost) +} + +pub(super) fn client_remote_image_paste_key( + config: &crate::config::Config, +) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> { + if !is_remote_client_process() { + return None; + } + + match config.remote_image_paste_key() { + Ok(key) => key, + Err(diagnostic) => { + warn!(diagnostic = %diagnostic, "local remote image paste key config diagnostic"); + None + } + } +} + +#[cfg(unix)] +pub(super) fn should_bridge_clipboard_image_paste( + data: &[u8], + is_remote_client: bool, + remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, +) -> bool { + if data == b"\x1b[200~\x1b[201~" { + return is_remote_client; + } + + let Some(remote_image_paste_key) = remote_image_paste_key else { + return false; + }; + + let events = crate::raw_input::parse_raw_input_bytes_sync(data); + matches!( + events.as_slice(), + [crate::raw_input::RawInputEvent::Key(key)] + if key.kind == crossterm::event::KeyEventKind::Press + && crate::config::terminal_key_matches_combo(key, remote_image_paste_key) + ) +} + +#[cfg(windows)] +pub(super) fn should_bridge_clipboard_image_events( + events: &[ClientInputEvent], + is_remote_client: bool, + remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, +) -> bool { + if !is_remote_client { + return false; + } + if matches!(events, [ClientInputEvent::Paste { text }] if text.is_empty()) { + return true; + } + + let Some(remote_image_paste_key) = remote_image_paste_key else { + return false; + }; + matches!( + events, + [event] + if matches!( + event.to_raw_input_event(), + crate::raw_input::RawInputEvent::Key(key) + if key.kind == crossterm::event::KeyEventKind::Press + && crate::config::terminal_key_matches_combo( + &key, + remote_image_paste_key, + ) + ) + ) +} + +#[cfg(unix)] +pub(super) fn read_image_file_from_terminal_drop( + data: &[u8], + is_remote_client: bool, +) -> Option<crate::platform::ClipboardImage> { + let (path, extension) = image_path_from_terminal_drop(data, is_remote_client)?; + read_image_file(path, extension) +} + +#[cfg(windows)] +pub(super) fn read_image_file_from_client_events( + events: &[ClientInputEvent], + is_remote_client: bool, +) -> Option<crate::platform::ClipboardImage> { + let [ClientInputEvent::Paste { text }] = events else { + return None; + }; + let text = normalized_terminal_drop_text(text)?; + let (path, extension) = + image_path_from_drop_text(strip_matching_path_quotes(text), is_remote_client)?; + read_image_file(path, extension) +} + +fn read_image_file( + path: PathBuf, + extension: &'static str, +) -> Option<crate::platform::ClipboardImage> { + let metadata = std::fs::metadata(&path).ok()?; + if !metadata.is_file() { + return None; + } + + let file = std::fs::File::open(&path).ok()?; + let bytes = + match crate::platform::read_limited_reader(file, MAX_CLIPBOARD_IMAGE_PAYLOAD).ok()? { + crate::platform::LimitedRead::Complete(bytes) => bytes, + crate::platform::LimitedRead::Empty => return None, + crate::platform::LimitedRead::Oversized => { + warn!( + max = MAX_CLIPBOARD_IMAGE_PAYLOAD, + "local image file drop is too large to bridge" + ); + return None; + } + }; + + Some(crate::platform::ClipboardImage { bytes, extension }) +} + +#[cfg(unix)] +pub(super) fn image_path_from_terminal_drop( + data: &[u8], + is_remote_client: bool, +) -> Option<(PathBuf, &'static str)> { + let bytes = bracketed_paste_payload(data).unwrap_or(data); + let text = std::str::from_utf8(bytes).ok()?; + let text = normalized_terminal_drop_text(text)?; + let text = unescape_terminal_drop_path(strip_matching_path_quotes(text)); + image_path_from_drop_text(&text, is_remote_client) +} + +fn normalized_terminal_drop_text(text: &str) -> Option<&str> { + let text = text.trim_end_matches(['\r', '\n']); + (!text.is_empty() && !text.contains(['\r', '\n'])).then_some(text) +} + +fn image_path_from_drop_text( + text: &str, + is_remote_client: bool, +) -> Option<(PathBuf, &'static str)> { + if !is_remote_client { + return None; + } + let path = PathBuf::from(text); + if !path.is_absolute() { + return None; + } + let extension = recognized_image_extension(path.extension()?.to_str()?)?; + Some((path, extension)) +} + +#[cfg(unix)] +fn bracketed_paste_payload(data: &[u8]) -> Option<&[u8]> { + const START: &[u8] = b"\x1b[200~"; + const END: &[u8] = b"\x1b[201~"; + data.strip_prefix(START)?.strip_suffix(END) +} + +fn strip_matching_path_quotes(text: &str) -> &str { + if text.len() < 2 { + return text; + } + + let bytes = text.as_bytes(); + match (bytes.first(), bytes.last()) { + (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"')) => &text[1..text.len() - 1], + _ => text, + } +} + +#[cfg(unix)] +fn unescape_terminal_drop_path(text: &str) -> String { + let mut unescaped = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(ch) = chars.next() { + if ch == '\\' { + if let Some(escaped) = chars.next() { + unescaped.push(escaped); + } else { + unescaped.push(ch); + } + } else { + unescaped.push(ch); + } + } + unescaped +} + +fn recognized_image_extension(extension: &str) -> Option<&'static str> { + if extension.eq_ignore_ascii_case("png") { + Some("png") + } else if extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg") { + Some("jpg") + } else if extension.eq_ignore_ascii_case("gif") { + Some("gif") + } else if extension.eq_ignore_ascii_case("webp") { + Some("webp") + } else if extension.eq_ignore_ascii_case("bmp") { + Some("bmp") + } else { + None + } +} diff --git a/src/client/endpoint_commands.rs b/src/client/endpoint_commands.rs new file mode 100644 index 00000000..decb8522 --- /dev/null +++ b/src/client/endpoint_commands.rs @@ -0,0 +1,224 @@ +use std::collections::VecDeque; +use std::io; + +use crate::api::client::ApiClientError; +use crate::api::schema::{Request, ResponseResult}; +use crate::ipc::LocalStream; +use crate::protocol::ClientMessage; + +use super::shell::ClientShellEndpointError; + +struct InFlightCommand { + boot_id: String, + request_id: String, + response: Vec<u8>, +} + +pub(super) struct EndpointCommandResult { + pub(super) boot_id: String, + pub(super) request_id: String, + pub(super) result: Result<ResponseResult, ClientShellEndpointError>, +} + +#[derive(Default)] +pub(super) struct EndpointCommands { + queued: VecDeque<(String, Box<Request>)>, + in_flight: Option<InFlightCommand>, +} + +impl EndpointCommands { + pub(super) fn enqueue(&mut self, boot_id: String, request: Box<Request>) { + self.queued.push_back((boot_id, request)); + } + + pub(super) fn send_next(&mut self, stream: &mut LocalStream) -> io::Result<()> { + if self.in_flight.is_some() { + return Ok(()); + } + let Some((boot_id, request)) = self.queued.pop_front() else { + return Ok(()); + }; + let request_id = request.id.clone(); + let request = serde_json::to_string(&request) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + super::write_to_server( + stream, + &ClientMessage::ClientShellEndpointRequest { + boot_id: boot_id.clone(), + request, + }, + )?; + self.in_flight = Some(InFlightCommand { + boot_id, + request_id, + response: Vec::new(), + }); + Ok(()) + } + + pub(super) fn receive_chunk( + &mut self, + response_boot_id: &str, + response_request_id: &str, + final_chunk: bool, + data: Vec<u8>, + ) -> io::Result<Option<EndpointCommandResult>> { + let Some(in_flight) = self.in_flight.as_mut() else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "endpoint response arrived without an in-flight command", + )); + }; + if response_boot_id != in_flight.boot_id || response_request_id != in_flight.request_id { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "endpoint response correlation did not match the in-flight command", + )); + } + in_flight.response.extend(data); + if !final_chunk { + return Ok(None); + } + + let in_flight = self.in_flight.take().expect("checked in-flight command"); + let response = String::from_utf8(in_flight.response) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let result = parse_response(&in_flight.request_id, &response); + Ok(Some(EndpointCommandResult { + boot_id: in_flight.boot_id, + request_id: in_flight.request_id, + result, + })) + } +} + +fn parse_response( + expected_id: &str, + response: &str, +) -> Result<ResponseResult, ClientShellEndpointError> { + let value = serde_json::from_str(response).map_err(|error| ClientShellEndpointError { + code: None, + message: format!("invalid endpoint response: {error}"), + })?; + match crate::api::client::parse_response_value(value) { + Ok(response) if response.id == expected_id => Ok(response.result), + Ok(response) => Err(ClientShellEndpointError { + code: None, + message: format!( + "endpoint response id {:?} did not match {expected_id:?}", + response.id + ), + }), + Err(ApiClientError::ErrorResponse(response)) if response.id == expected_id => { + Err(ClientShellEndpointError { + code: Some(response.error.code), + message: response.error.message, + }) + } + Err(ApiClientError::ErrorResponse(response)) => Err(ClientShellEndpointError { + code: None, + message: format!( + "endpoint error id {:?} did not match {expected_id:?}", + response.id + ), + }), + Err(error) => Err(ClientShellEndpointError { + code: None, + message: error.to_string(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::schema::{ResponseResult, SuccessResponse}; + + fn commands_with_in_flight() -> EndpointCommands { + EndpointCommands { + in_flight: Some(InFlightCommand { + boot_id: "boot-a".into(), + request_id: "request-a".into(), + response: Vec::new(), + }), + ..EndpointCommands::default() + } + } + + #[test] + fn chunked_response_completion_is_correlated_and_clears_the_lane() { + let mut commands = commands_with_in_flight(); + let response = serde_json::to_string(&SuccessResponse { + id: "request-a".into(), + result: ResponseResult::Ok {}, + }) + .unwrap(); + let split = response.len() / 2; + + assert!(commands + .receive_chunk( + "boot-a", + "request-a", + false, + response.as_bytes()[..split].to_vec(), + ) + .unwrap() + .is_none()); + let completed = commands + .receive_chunk( + "boot-a", + "request-a", + true, + response.as_bytes()[split..].to_vec(), + ) + .unwrap() + .unwrap(); + + assert_eq!(completed.boot_id, "boot-a"); + assert_eq!(completed.request_id, "request-a"); + assert!(matches!(completed.result, Ok(ResponseResult::Ok {}))); + assert!(commands.in_flight.is_none()); + } + + #[test] + fn large_selection_response_reassembles_without_truncation() { + let mut commands = commands_with_in_flight(); + let selection = "selected".repeat(160_000); + let response = serde_json::to_vec(&SuccessResponse { + id: "request-a".into(), + result: ResponseResult::PaneSelection { + pane_id: "w1:p1".into(), + text: selection.clone(), + }, + }) + .unwrap(); + let chunk_count = response.len().div_ceil(128 * 1024); + let mut completed = None; + for (index, chunk) in response.chunks(128 * 1024).enumerate() { + completed = commands + .receive_chunk( + "boot-a", + "request-a", + index + 1 == chunk_count, + chunk.to_vec(), + ) + .unwrap(); + } + + assert!(matches!( + completed.expect("final selection response").result, + Ok(ResponseResult::PaneSelection { text, .. }) if text == selection + )); + } + + #[test] + fn response_from_another_boot_is_rejected() { + let mut commands = commands_with_in_flight(); + + let Err(error) = commands.receive_chunk("boot-b", "request-a", true, b"{}".to_vec()) else { + panic!("mismatched boot should fail"); + }; + + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } +} diff --git a/src/client/errors.rs b/src/client/errors.rs new file mode 100644 index 00000000..460ba21f --- /dev/null +++ b/src/client/errors.rs @@ -0,0 +1,99 @@ +use std::io; + +use crate::protocol; +use crate::server::socket_paths::client_socket_path; + +/// 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") => { + if let Ok(reattach_command) = + std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR) + { + write!(f, "detached from remote server")?; + write!(f, "\nRun `{reattach_command}` to reattach")?; + } else { + write!(f, "detached from server")?; + write!( + f, + "\nRun `{}` to reattach", + crate::session::local_attach_command() + )?; + } + } + _ => { + write!(f, "server shut down")?; + if let Some(reason) = reason { + write!(f, ": {reason}")?; + } + } + } + Ok(()) + } + ClientError::ConnectionLost(err) => { + if let Ok(reattach_command) = std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR) + { + write!(f, "lost connection to remote Herdr: {err}")?; + write!(f, "\nIf the remote server survived the SSH or network drop, its panes may still be running.")?; + write!(f, "\nRun `{reattach_command}` to reattach") + } else { + 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 { + match err { + protocol::FramingError::UnexpectedEof => ClientError::ConnectionLost(io::Error::new( + io::ErrorKind::UnexpectedEof, + "server closed connection", + )), + protocol::FramingError::Io(err) => ClientError::ConnectionLost(err), + err => ClientError::Protocol(err), + } + } +} diff --git a/src/client/frame_output.rs b/src/client/frame_output.rs new file mode 100644 index 00000000..5d12c78c --- /dev/null +++ b/src/client/frame_output.rs @@ -0,0 +1,97 @@ +use std::collections::HashSet; +use std::io; +use std::sync::{Mutex, OnceLock}; + +use crate::protocol::render_ansi; + +static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new(); + +pub(super) fn write_encoded_frame_with_graphics( + mut writer: impl io::Write, + encoded: &[u8], + graphics: &[u8], +) -> io::Result<()> { + if graphics.is_empty() { + return writer.write_all(encoded); + } + + let insertion = render_ansi::final_sync_output_end(encoded).unwrap_or(encoded.len()); + + writer.write_all(&encoded[..insertion])?; + record_received_kitty_graphics(graphics); + writer.write_all(b"\x1b7")?; + writer.write_all(graphics)?; + writer.write_all(b"\x1b8")?; + writer.write_all(&encoded[insertion..]) +} + +pub(super) fn contains_kitty_graphics_bytes(bytes: &[u8]) -> bool { + bytes.windows(3).any(|window| window == b"\x1b_G") +} + +pub(super) fn record_received_kitty_graphics(bytes: &[u8]) { + let ids = kitty_graphics_image_ids(bytes); + if ids.is_empty() { + return; + } + let set = RECEIVED_KITTY_GRAPHICS_IDS.get_or_init(|| Mutex::new(HashSet::new())); + if let Ok(mut set) = set.lock() { + set.extend(ids); + } +} + +pub(super) fn clear_received_kitty_graphics(mut writer: impl io::Write) -> io::Result<()> { + let Some(set) = RECEIVED_KITTY_GRAPHICS_IDS.get() else { + return Ok(()); + }; + let Ok(mut set) = set.lock() else { + return Ok(()); + }; + for id in set.drain() { + write!(writer, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\")?; + } + writer.flush() +} + +pub(super) fn kitty_graphics_image_ids(bytes: &[u8]) -> Vec<u32> { + let mut ids = Vec::new(); + let mut index = 0usize; + while let Some(start) = find_subslice(&bytes[index..], b"\x1b_G") { + let command_start = index + start + 3; + let Some(end) = find_subslice(&bytes[command_start..], b"\x1b\\") else { + break; + }; + let command = &bytes[command_start..command_start + end]; + if let Some(id) = kitty_graphics_command_image_id(command) { + ids.push(id); + } + index = command_start + end + 2; + } + ids +} + +fn kitty_graphics_command_image_id(command: &[u8]) -> Option<u32> { + let header_end = command + .iter() + .position(|byte| *byte == b';') + .unwrap_or(command.len()); + for part in command[..header_end].split(|byte| *byte == b',') { + let Some(value) = part.strip_prefix(b"i=") else { + continue; + }; + let text = std::str::from_utf8(value).ok()?; + if let Ok(id) = text.parse::<u32>() { + return Some(id); + } + } + None +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> { + if needle.is_empty() || needle.len() > haystack.len() { + return None; + } + haystack + .windows(needle.len()) + .position(|window| window == needle) +} diff --git a/src/client/handshake.rs b/src/client/handshake.rs new file mode 100644 index 00000000..2140516e --- /dev/null +++ b/src/client/handshake.rs @@ -0,0 +1,189 @@ +use std::io; +#[cfg(unix)] +use std::io::IsTerminal as _; +use std::time::Duration; + +use interprocess::local_socket::traits::Stream as _; +#[cfg(windows)] +use tracing::debug; +use tracing::info; + +use crate::ipc::LocalStream; +use crate::protocol::{ + self, ClientMessage, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION, +}; + +#[cfg(unix)] +use super::terminal_setup::is_ssh_session; +use super::{shell, ClientError}; + +/// Time to wait for the server's Welcome reply during the handshake. +/// +/// A local client talks to an already-connected server, so 5s is plenty. The +/// remote bridge client (`herdr --remote`) sits behind a fresh per-attach ssh +/// connection whose cold-connect (TCP + key exchange + auth) happens inside this +/// window; on a high-latency link that easily exceeds 5s, so it gets a far +/// larger budget. See issue #753. +pub(super) const LOCAL_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(5); +pub(super) const REMOTE_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(60); + +pub(super) fn is_remote_client_process() -> bool { + std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok() +} + +pub(super) fn client_shell_keybinding_source() -> shell::ClientShellKeybindingSource { + match std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR) + .ok() + .as_deref() + { + Some("server") => shell::ClientShellKeybindingSource::Endpoint, + Some(_) => shell::ClientShellKeybindingSource::RemoteLocal, + None => shell::ClientShellKeybindingSource::Local, + } +} + +pub(super) fn handshake_read_timeout() -> Duration { + if is_remote_client_process() { + return REMOTE_HANDSHAKE_READ_TIMEOUT; + } + LOCAL_HANDSHAKE_READ_TIMEOUT +} + +#[cfg(any(unix, test))] +pub(super) fn direct_graphics_profile_values( + term_program: &str, + term: &str, + kitty_window: bool, + blocked_transport: bool, + terminals: bool, +) -> bool { + let supported = term_program.eq_ignore_ascii_case("ghostty") + || term_program.eq_ignore_ascii_case("wezterm") + || matches!(term, "xterm-ghostty" | "xterm-kitty" | "xterm-wezterm") + || kitty_window; + supported && !blocked_transport && terminals +} + +#[cfg(unix)] +fn direct_graphics_profile_allowed() -> bool { + let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); + let term = std::env::var("TERM").unwrap_or_default(); + direct_graphics_profile_values( + &term_program, + &term, + std::env::var_os("KITTY_WINDOW_ID").is_some(), + is_remote_client_process() + || is_ssh_session() + || std::env::var_os("TMUX").is_some() + || std::env::var_os("STY").is_some(), + io::stdin().is_terminal() && io::stdout().is_terminal(), + ) +} + +#[cfg(not(unix))] +fn direct_graphics_profile_allowed() -> bool { + false +} + +#[cfg(windows)] +fn set_handshake_recv_timeout( + stream: &LocalStream, + timeout: Option<Duration>, + context: &'static str, +) -> Result<(), ClientError> { + match stream.set_recv_timeout(timeout) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::Unsupported => { + debug!(err = %err, context, "client socket receive timeout unavailable"); + Ok(()) + } + Err(err) => Err(ClientError::ConnectionFailed(err)), + } +} + +#[cfg(not(windows))] +fn set_handshake_recv_timeout( + stream: &LocalStream, + timeout: Option<Duration>, + _context: &'static str, +) -> Result<(), ClientError> { + stream + .set_recv_timeout(timeout) + .map_err(ClientError::ConnectionFailed) +} + +/// Performs the client→server handshake. +/// +/// Sends TerminalHello (or ClientShellHello) with the terminal size and protocol +/// version, then reads the Welcome response. +pub(super) fn do_handshake( + stream: &mut LocalStream, + cols: u16, + rows: u16, + cell_width_px: u32, + cell_height_px: u32, + exact_cell_size: bool, + shell_surface_size: Option<crate::protocol::ClientSurfaceSize>, + endpoint_keybindings: bool, + mouse_capture: bool, +) -> Result<RenderEncoding, ClientError> { + stream + .set_nonblocking(false) + .map_err(ClientError::ConnectionFailed)?; + + let hello = if let Some(surface_size) = shell_surface_size { + ClientMessage::ClientShellHello { + version: PROTOCOL_VERSION, + cell_width_px, + cell_height_px, + surface_size, + pixel_mouse: exact_cell_size && cfg!(unix), + direct_graphics: exact_cell_size + && cell_width_px > 0 + && cell_height_px > 0 + && direct_graphics_profile_allowed(), + endpoint_keybindings, + mouse_capture, + } + } else { + ClientMessage::TerminalHello { + version: PROTOCOL_VERSION, + cols, + rows, + cell_width_px, + cell_height_px, + pixel_mouse: exact_cell_size && cfg!(unix), + } + }; + protocol::write_message(stream, &hello) + .map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?; + + set_handshake_recv_timeout( + stream, + Some(handshake_read_timeout()), + "client handshake read timeout unavailable", + )?; + let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?; + set_handshake_recv_timeout( + stream, + None, + "failed to clear client handshake read timeout", + )?; + + match welcome { + ServerMessage::Welcome { + version, + encoding, + error, + } => { + if let Some(error) = error { + return Err(ClientError::HandshakeRejected { version, error }); + } + info!(version, ?encoding, "handshake succeeded"); + Ok(encoding) + } + _ => Err(ClientError::Protocol(protocol::FramingError::Io( + io::Error::new(io::ErrorKind::InvalidData, "expected Welcome message"), + ))), + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs index 53e818d7..12efa8e3 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,7 +1,7 @@ //! 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 +//! - Connects to `herdr-client.sock`, sends TerminalHello 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 @@ -12,45 +12,105 @@ //! - Forwards OSC 52 clipboard writes from server to its own stdout //! - Displays sound/toast notifications forwarded from server +mod attach; +mod clipboard_images; #[cfg(unix)] mod direct_graphics; +mod endpoint_commands; +mod errors; +mod frame_output; +mod handshake; mod input; +mod notifications; +mod shell; +mod terminal_geometry; +mod terminal_sessions; +mod terminal_setup; +mod timer; -use std::collections::HashSet; +#[cfg(test)] +pub(crate) use shell::{ClientShellConfig, ClientShellState}; +pub use terminal_sessions::{run_terminal_session_control, run_terminal_session_observe}; + +#[cfg(not(windows))] +use terminal_geometry::query_host_terminal_appearance; +#[cfg(test)] +use terminal_geometry::{ + cell_size_fallback, ioctl_cell_size, pack_cell_size, resize_report_required, + should_query_host_cell_size, write_host_cell_size_query, write_host_terminal_appearance_query, + write_host_terminal_theme_query, +}; +use terminal_geometry::{ + host_cell_size_query_required, initial_terminal_geometry, query_host_cell_size, + query_host_terminal_theme, resize_poll_loop, should_query_host_terminal_theme, +}; #[cfg(unix)] -use std::io::IsTerminal as _; -use std::io::{self, BufRead, Write as _}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::Duration; +use terminal_geometry::{reported_cell_size_from_events, store_reported_cell_size}; +use terminal_setup::{ + effective_mouse_capture, effective_sgr_pixel_mouse, set_mouse_capture, + setup_direct_attach_terminal, setup_terminal, should_draw_host_cursor, +}; +#[cfg(windows)] +use terminal_setup::{ + enable_windows_virtual_terminal_input, is_ssh_session, windows_vti_input_backend_enabled, +}; +#[cfg(test)] +use terminal_setup::{ + should_enable_host_color_scheme_reports, windows_virtual_terminal_input_mode, + write_host_color_scheme_report_mode, write_terminal_restore_postlude, +}; -use base64::Engine; -use crossterm::event::{ - DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, - EnableFocusChange, EnableMouseCapture, +#[cfg(unix)] +use attach::direct_attach_pixel_mouse; +use attach::AttachEscapeState; +#[cfg(unix)] +use attach::{write_attach_semantic_action, AttachInputAction}; +use clipboard_images::{client_remote_image_paste_key, write_remote_image_to_server}; +#[cfg(windows)] +use clipboard_images::{read_image_file_from_client_events, should_bridge_clipboard_image_events}; +#[cfg(unix)] +use clipboard_images::{read_image_file_from_terminal_drop, should_bridge_clipboard_image_paste}; +pub use errors::ClientError; +#[cfg(test)] +use frame_output::{clear_received_kitty_graphics, kitty_graphics_image_ids}; +use frame_output::{ + contains_kitty_graphics_bytes, record_received_kitty_graphics, + write_encoded_frame_with_graphics, +}; +use handshake::{client_shell_keybinding_source, do_handshake, is_remote_client_process}; +#[cfg(test)] +use handshake::{ + direct_graphics_profile_values, handshake_read_timeout, REMOTE_HANDSHAKE_READ_TIMEOUT, }; +use notifications::{handle_notify, handle_shell_notification_effects}; +#[cfg(test)] +use notifications::{handle_notify_with_notifiers, sound_from_notify_message}; +#[cfg(test)] +use terminal_sessions::terminal_control_command_from_json; + #[cfg(unix)] -use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; -#[cfg(not(windows))] -use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}; -use crossterm::execute; -use crossterm::terminal::{DisableLineWrap, EnableLineWrap}; +use std::collections::HashMap; +use std::io::{self, Write as _}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +#[cfg(unix)] +use std::sync::Mutex; +use std::time::Duration; + use interprocess::local_socket::traits::Stream as _; use interprocess::TryClone as _; use tracing::{debug, info, warn}; use crate::ipc::LocalStream; use crate::protocol::render_ansi; -use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD; use crate::protocol::{ - self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode, - ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, - MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, ClientMessage, FrameData, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, + MAX_GRAPHICS_FRAME_SIZE, }; +#[cfg(test)] +use crate::protocol::{AttachScrollDirection, AttachScrollSource, NotifyKind}; use crate::server::socket_paths::client_socket_path; -static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new(); - // --------------------------------------------------------------------------- // Client state // --------------------------------------------------------------------------- @@ -61,8 +121,11 @@ struct ClientLoopConfig { redraw_on_focus_gained: bool, host_cursor: crate::config::HostCursorModeConfig, kitty_graphics_enabled: bool, + pixel_geometry_enabled: bool, + pixel_geometry_fallback: bool, mouse_capture_active: bool, remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, + shell_config: Option<shell::ClientShellConfig>, } /// State tracking for the thin client. @@ -71,21 +134,42 @@ struct ClientState { blit_encoder: render_ansi::BlitEncoder, /// Whether host mouse capture is currently active. mouse_capture_active: bool, - /// Whether the host terminal currently reports all keys as Kitty sequences. + /// Last mouse-capture demand published by the endpoint. + endpoint_mouse_capture_requested: bool, + /// Last exact-pixel mouse demand published by the endpoint. + endpoint_sgr_pixels_requested: bool, + /// Client-local direct-attach preference, combined with child mouse demand. + direct_mouse_capture_preference: bool, + /// Client-owned shell mouse-capture preference. + shell_mouse_capture_preference: bool, + /// Host keyboard protocol state currently owned for a direct terminal attach. + direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState, + /// Focused endpoint pane/popup demand for Kitty report-all input. + pane_keyboard_report_all: bool, + /// Whether the client-owned shell currently enabled host report-all input. keyboard_report_all_active: bool, - /// The terminal size we reported to the server in our last Hello/Resize. + /// The terminal size we reported to the server in our last handshake/Resize. reported_size: (u16, u16), + /// Last exact host cell size used by client-rendered surfaces. + reported_cell_size: (u32, u32), /// Client-local sound playback config, refreshed on server request. sound_config: crate::config::SoundConfig, /// Whether this client may write Kitty graphics bytes to its host terminal. kitty_graphics_enabled: bool, + /// Whether resize reports inspect host pixel geometry. + pixel_geometry_enabled: bool, + /// Whether the latest host pixel geometry is exact enough for pixel mouse input. + pixel_geometry_exact: bool, /// One bounded matcher, inactive unless a direct transmission is armed. #[cfg(unix)] direct_graphics_response: Arc<Mutex<direct_graphics::ResponseMatcher>>, /// One server-retired direct transfer to suppress if it was still queued. #[cfg(unix)] retired_direct_graphics: Option<(u64, u32)>, - /// Direct attach prefix escape state. None for full-app clients. + /// ClientShell assets waiting for the host terminal's direct-upload response. + #[cfg(unix)] + pending_surface_graphics: HashMap<u64, crate::protocol::SurfaceGraphicsAssetKey>, + /// Direct attach prefix escape state. None for ClientShell connections. attach_escape: Option<AttachEscapeState>, /// Rows scrolled for one direct-attach wheel notch. #[cfg(unix)] @@ -98,142 +182,22 @@ struct ClientState { repaint_pending: bool, /// Whether this client draws the cursor into frame cells instead of using the host cursor. draw_host_cursor: bool, + /// Browser opener processes launched by client-owned link activation. + detached_process_children: Vec<std::process::Child>, + /// Experimental client-owned shell state. + shell: Option<shell::ClientShellState>, } -#[derive(Debug, Default)] -#[cfg(windows)] -struct AttachEscapeState; - -#[derive(Debug, Default)] -#[cfg(unix)] -struct AttachEscapeState { - pending_prefix: bool, -} - -#[derive(Debug)] -#[cfg(unix)] -enum AttachInputAction { - Forward(Vec<u8>), - ForwardAfterPendingPrefix(Vec<u8>), - Scroll { - source: AttachScrollSource, - direction: AttachScrollDirection, - lines: u16, - column: Option<u16>, - row: Option<u16>, - modifiers: u8, - }, - Detach, - None, -} - -impl AttachEscapeState { - #[cfg(unix)] - fn filter_input( - &mut self, - data: Vec<u8>, - viewport_rows: u16, - mouse_scroll_lines: usize, - ) -> AttachInputAction { - const PREFIX: u8 = 0x02; // Ctrl+B - - if crate::raw_input::is_complete_text_bracketed_paste(&data) { - return if std::mem::take(&mut self.pending_prefix) { - AttachInputAction::ForwardAfterPendingPrefix(data) - } else { - AttachInputAction::Forward(data) - }; - } - - let mut output = Vec::with_capacity(data.len()); - for byte in data { - if self.pending_prefix { - self.pending_prefix = false; - match byte { - b'q' => return AttachInputAction::Detach, - PREFIX => output.push(PREFIX), - other => { - output.push(PREFIX); - output.push(other); - } - } - continue; - } - - if byte == PREFIX { - self.pending_prefix = true; - } else { - output.push(byte); - } - } - - if output.is_empty() { - AttachInputAction::None - } else if let Some(action) = - attach_scroll_action(&output, viewport_rows, mouse_scroll_lines) - { - action - } else { - AttachInputAction::Forward(output) - } - } -} - -#[cfg(unix)] -fn attach_scroll_action( - data: &[u8], - viewport_rows: u16, - mouse_scroll_lines: usize, -) -> Option<AttachInputAction> { - let mut events = crate::raw_input::parse_raw_input_bytes_sync(data); - if events.len() != 1 { - return None; - } - - match events.pop()? { - crate::raw_input::RawInputEvent::Mouse(mouse) => { - let direction = match mouse.kind { - MouseEventKind::ScrollUp => AttachScrollDirection::Up, - MouseEventKind::ScrollDown => AttachScrollDirection::Down, - _ => return Some(AttachInputAction::None), - }; - Some(AttachInputAction::Scroll { - source: AttachScrollSource::Wheel, - direction, - lines: mouse_scroll_lines.max(1).min(u16::MAX as usize) as u16, - column: Some(mouse.column), - row: Some(mouse.row), - modifiers: mouse.modifiers.bits(), - }) - } - crate::raw_input::RawInputEvent::Key(key) - if key.modifiers.is_empty() - && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => - { - let direction = match key.code { - KeyCode::PageUp => AttachScrollDirection::Up, - KeyCode::PageDown => AttachScrollDirection::Down, - _ => return None, - }; - Some(AttachInputAction::Scroll { - source: AttachScrollSource::PageKey { - input: data.to_vec(), - }, - direction, - lines: viewport_rows.saturating_sub(1).max(1), - column: None, - row: None, - modifiers: KeyModifiers::empty().bits(), - }) - } - crate::raw_input::RawInputEvent::Key(key) - if key.modifiers.is_empty() - && key.kind == KeyEventKind::Release - && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) => - { - Some(AttachInputAction::None) +impl Drop for ClientState { + fn drop(&mut self) { + if self.attach_escape.is_some() { + let _ = crate::terminal_modes::set_direct_host_keyboard_protocol( + &mut io::stdout(), + &mut self.direct_keyboard_protocol, + 0, + 0, + ); } - _ => None, } } @@ -241,666 +205,81 @@ impl ClientState { fn request_repaint(&mut self) { self.repaint_pending = true; } -} - -// --------------------------------------------------------------------------- -// 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") => { - if let Ok(reattach_command) = - std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR) - { - write!(f, "detached from remote server")?; - write!(f, "\nRun `{reattach_command}` to reattach")?; - } else { - write!(f, "detached from server")?; - write!( - f, - "\nRun `{}` to reattach", - crate::session::local_attach_command() - )?; - } - } - _ => { - write!(f, "server shut down")?; - if let Some(reason) = reason { - write!(f, ": {reason}")?; - } - } - } - Ok(()) - } - ClientError::ConnectionLost(err) => { - if let Ok(reattach_command) = std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR) - { - write!(f, "lost connection to remote Herdr: {err}")?; - write!(f, "\nIf the remote server survived the SSH or network drop, its panes may still be running.")?; - write!(f, "\nRun `{reattach_command}` to reattach") - } else { - 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, + fn present_graphics(&mut self, graphics: &[u8]) { + if graphics.is_empty() || !self.kitty_graphics_enabled { + return; } + let mut stdout = io::stdout(); + let _ = write_encoded_frame_with_graphics(&mut stdout, &[], graphics); + let _ = stdout.flush(); } -} -impl From<protocol::FramingError> for ClientError { - fn from(err: protocol::FramingError) -> Self { - match err { - protocol::FramingError::UnexpectedEof => ClientError::ConnectionLost(io::Error::new( - io::ErrorKind::UnexpectedEof, - "server closed connection", - )), - protocol::FramingError::Io(err) => ClientError::ConnectionLost(err), - err => ClientError::Protocol(err), + fn present_surface_patch( + &mut self, + patch: shell::ClientComposedSurfacePatch, + ) -> io::Result<bool> { + if self.repaint_pending { + crate::render_prof::event("client_surface_patch.fallback.repaint"); + return Ok(false); } - } -} - -// --------------------------------------------------------------------------- -// Terminal setup / restore -// --------------------------------------------------------------------------- - -/// Sets up the terminal for client mode (raw mode, optional mouse, keyboard enhancements). -/// -/// Returns a guard that restores the terminal when dropped. -fn setup_terminal(mouse_capture: bool) -> io::Result<TerminalGuard> { - setup_terminal_with_capabilities(true, mouse_capture) -} - -/// Sets up a direct attach terminal. -/// -/// Direct attach forwards stdin to the attached PTY. When configured, mouse -/// capture lets wheel events drive the attached viewport or reach child -/// programs that requested mouse input. -fn setup_direct_attach_terminal(mouse_capture: bool) -> io::Result<TerminalGuard> { - setup_terminal_with_capabilities(false, mouse_capture) -} - -fn setup_terminal_with_capabilities( - enable_client_protocols: bool, - mouse_capture: bool, -) -> io::Result<TerminalGuard> { - ratatui::init(); - crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; - let host_color_scheme_reports = - should_enable_host_color_scheme_reports(enable_client_protocols); - - #[cfg(windows)] - let windows_ssh_session = is_ssh_session(); - #[cfg(windows)] - let mut windows_virtual_terminal_input = - if windows_vti_input_backend_enabled() && windows_ssh_session { - enable_windows_virtual_terminal_input() + let rows = if self.draw_host_cursor { + let Some(rows) = self + .blit_encoder + .patch_rows_with_drawn_cursor(&patch.rows, patch.cursor.as_ref()) + else { + crate::render_prof::event("client_surface_patch.fallback.drawn_cursor"); + return Ok(false); + }; + rows } else { - WindowsVirtualTerminalInputSetup::default() + patch.rows }; - - if enable_client_protocols { - set_mouse_capture(mouse_capture, false)?; - execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?; - if host_color_scheme_reports { - write_host_color_scheme_report_mode(&mut io::stdout(), true)?; - } - push_keyboard_enhancement_flags()?; - } else { - if should_query_host_terminal_theme() { - write_host_color_scheme_report_mode(&mut io::stdout(), false)?; - } - set_mouse_capture(mouse_capture, false)?; - execute!(io::stdout(), EnableBracketedPaste)?; - } - - #[cfg(windows)] - if enable_client_protocols && windows_vti_input_backend_enabled() && !windows_ssh_session { - windows_virtual_terminal_input = enable_windows_virtual_terminal_input(); - } - - #[cfg(windows)] - if enable_client_protocols - && windows_vti_input_backend_enabled() - && windows_virtual_terminal_input.active - && windows_win32_input_mode_enabled() - { - if let Err(err) = enable_windows_win32_input_mode(&mut io::stdout()) { - if let Some(mode) = windows_virtual_terminal_input.restore_mode { - restore_windows_input_mode_value(mode); - } - return Err(err); - } - } - - let modify_other_keys_mode = enable_client_protocols - .then(crate::input::host_modify_other_keys_mode) - .flatten(); - if let Some(mode) = modify_other_keys_mode { - io::stdout().write_all(mode.set_sequence())?; - io::stdout().flush()?; - } - - execute!(io::stdout(), DisableLineWrap)?; - - Ok(TerminalGuard { - reset_modify_other_keys: modify_other_keys_mode.is_some(), - reset_host_color_scheme_reports: host_color_scheme_reports, - restored: false, - #[cfg(windows)] - restore_windows_input_mode: windows_virtual_terminal_input.restore_mode, - }) -} - -fn should_enable_host_color_scheme_reports(enable_client_protocols: bool) -> bool { - enable_client_protocols && should_query_host_terminal_theme() -} - -/// Guard that restores the terminal when dropped. -struct TerminalGuard { - reset_modify_other_keys: bool, - reset_host_color_scheme_reports: bool, - restored: bool, - #[cfg(windows)] - restore_windows_input_mode: Option<u32>, -} - -fn write_host_color_scheme_report_mode( - writer: &mut impl io::Write, - enabled: bool, -) -> io::Result<()> { - let sequence = if enabled { - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE - } else { - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE - }; - writer.write_all(sequence.as_bytes())?; - writer.flush() -} - -fn write_terminal_restore_postlude( - writer: &mut impl io::Write, - reset_host_color_scheme_reports: bool, -) -> io::Result<()> { - if reset_host_color_scheme_reports { - writer.write_all( - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), - )?; - } - // Restore a visible cursor and reset DECSCUSR back to the terminal default. - writer.write_all(b"\x1b[?25h\x1b[0 q")?; - writer.flush() -} - -fn should_draw_host_cursor(mode: crate::config::HostCursorModeConfig) -> bool { - match mode { - crate::config::HostCursorModeConfig::Auto => { - crate::platform::should_draw_host_cursor_by_default() - } - crate::config::HostCursorModeConfig::Native => false, - crate::config::HostCursorModeConfig::Drawn => true, - } -} - -#[cfg(windows)] -#[derive(Default)] -struct WindowsVirtualTerminalInputSetup { - active: bool, - restore_mode: Option<u32>, -} - -#[cfg(windows)] -fn enable_windows_virtual_terminal_input() -> WindowsVirtualTerminalInputSetup { - use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::System::Console::{ - GetConsoleMode, GetStdHandle, SetConsoleMode, ENABLE_VIRTUAL_TERMINAL_INPUT, - STD_INPUT_HANDLE, - }; - - let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; - if handle.is_null() || handle == INVALID_HANDLE_VALUE { - tracing::warn!("failed to get Windows console input handle for VT input"); - return WindowsVirtualTerminalInputSetup::default(); - } - - let mut mode = 0; - if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { - tracing::warn!("failed to read Windows console input mode for VT input"); - return WindowsVirtualTerminalInputSetup::default(); - } - - let desired = windows_virtual_terminal_input_mode(mode); - if desired == mode { - return WindowsVirtualTerminalInputSetup { - active: true, - restore_mode: None, + let encode_started = crate::render_prof::timer(); + let Some(encoded) = + self.blit_encoder + .encode_patch(&rows, patch.cursor.clone(), self.draw_host_cursor) + else { + crate::render_prof::event("client_surface_patch.fallback.encode"); + return Ok(false); }; + crate::render_prof::duration_since("client_surface_patch.encode", encode_started); + let write_started = crate::render_prof::timer(); + let mut stdout = io::stdout(); + stdout.write_all(&encoded.bytes)?; + stdout.flush()?; + crate::render_prof::duration_since("client_surface_patch.write", write_started); + let committed = self.blit_encoder.commit_patch(&rows, patch.cursor, encoded); + crate::render_prof::event(if committed { + "client_surface_patch.success" + } else { + "client_surface_patch.fallback.commit" + }); + Ok(committed) } - if unsafe { SetConsoleMode(handle, desired) } == 0 { - tracing::warn!("failed to enable Windows virtual terminal input"); - return WindowsVirtualTerminalInputSetup::default(); - } - - let mut applied = 0; - if unsafe { GetConsoleMode(handle, &mut applied) } == 0 { - tracing::warn!("failed to verify Windows virtual terminal input mode"); - let _ = unsafe { SetConsoleMode(handle, mode) }; - return WindowsVirtualTerminalInputSetup::default(); - } - if applied & ENABLE_VIRTUAL_TERMINAL_INPUT == 0 { - tracing::warn!("Windows virtual terminal input bit did not stick"); - let _ = unsafe { SetConsoleMode(handle, mode) }; - return WindowsVirtualTerminalInputSetup::default(); - } - - WindowsVirtualTerminalInputSetup { - active: true, - restore_mode: Some(mode), - } -} - -#[cfg(windows)] -fn windows_vti_input_backend_enabled() -> bool { - std::env::var("HERDR_WINDOWS_INPUT_BACKEND") - .map(|backend| !backend.eq_ignore_ascii_case("crossterm")) - .unwrap_or(true) -} - -#[cfg(any(windows, test))] -fn windows_virtual_terminal_input_mode(mode: u32) -> u32 { - mode | 0x0200 -} - -#[cfg(windows)] -fn restore_windows_input_mode_value(mode: u32) { - use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::System::Console::{GetStdHandle, SetConsoleMode, STD_INPUT_HANDLE}; - - let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; - if handle.is_null() || handle == INVALID_HANDLE_VALUE { - return; - } - if unsafe { SetConsoleMode(handle, mode) } == 0 { - tracing::warn!("failed to restore Windows console input mode"); - } -} - -fn set_mouse_capture(enabled: bool, sgr_pixels: bool) -> io::Result<()> { - crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; - #[cfg(windows)] - if is_ssh_session() && windows_vti_input_backend_enabled() { - return crate::terminal_modes::set_windows_ssh_mouse_reporting( - &mut io::stdout(), - enabled, - sgr_pixels, - ); - } - if enabled { - execute!(io::stdout(), EnableMouseCapture)?; - if sgr_pixels { - io::stdout().write_all(b"\x1b[?1016h")?; - io::stdout().flush()?; - } - Ok(()) - } else { - match execute!(io::stdout(), DisableMouseCapture) { - Ok(()) => Ok(()), - #[cfg(windows)] - Err(err) if err.to_string() == "Initial console modes not set" => Ok(()), - Err(err) => Err(err), - } - } -} - -fn restore_terminal_state( - reset_modify_other_keys: bool, - reset_host_color_scheme_reports: bool, - #[cfg(windows)] restore_windows_input_mode: Option<u32>, -) -> io::Result<()> { - let _ = clear_received_kitty_graphics(&mut io::stdout()); - - // Reset modifyOtherKeys if we enabled it. - if reset_modify_other_keys { - let _ = io::stdout().write_all(b"\x1b[>4;0m"); - let _ = io::stdout().flush(); - } - - let _ = pop_keyboard_enhancement_flags(); - - let _ = execute!( - io::stdout(), - EnableLineWrap, - DisableFocusChange, - DisableBracketedPaste - ); - let _ = set_mouse_capture(false, false); - #[cfg(windows)] - if let Some(mode) = restore_windows_input_mode { - restore_windows_input_mode_value(mode); - } - - let restore_result = ratatui::try_restore(); - let postlude_result = - write_terminal_restore_postlude(&mut io::stdout(), reset_host_color_scheme_reports); - - #[cfg(windows)] - if windows_vti_input_backend_enabled() && windows_win32_input_mode_enabled() { - let _ = disable_windows_win32_input_mode(&mut io::stdout()); - } - - restore_result.and(postlude_result) -} - -#[cfg(not(windows))] -fn push_keyboard_enhancement_flags() -> io::Result<()> { - execute!( - io::stdout(), - PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags()) - ) -} - -#[cfg(windows)] -fn push_keyboard_enhancement_flags() -> io::Result<()> { - Ok(()) -} - -#[cfg(not(windows))] -fn pop_keyboard_enhancement_flags() -> io::Result<()> { - execute!(io::stdout(), PopKeyboardEnhancementFlags) -} - -#[cfg(windows)] -fn pop_keyboard_enhancement_flags() -> io::Result<()> { - Ok(()) -} - -#[cfg(windows)] -fn windows_win32_input_mode_enabled() -> bool { - std::env::var("HERDR_WINDOWS_INPUT_PROBE") - .map(|probe| probe.eq_ignore_ascii_case("win32")) - .unwrap_or(true) -} - -#[cfg(windows)] -fn enable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> { - writer.write_all(b"\x1b[?9001h")?; - writer.flush() -} - -#[cfg(windows)] -fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> { - writer.write_all(b"\x1b[?9001l")?; - writer.flush() -} - -impl TerminalGuard { - fn restore(mut self) -> io::Result<()> { - self.restored = true; - restore_terminal_state( - self.reset_modify_other_keys, - self.reset_host_color_scheme_reports, - #[cfg(windows)] - self.restore_windows_input_mode, - ) - } -} - -impl Drop for TerminalGuard { - fn drop(&mut self) { - if !self.restored { - let _ = restore_terminal_state( - self.reset_modify_other_keys, - self.reset_host_color_scheme_reports, - #[cfg(windows)] - self.restore_windows_input_mode, - ); - } - } -} - -// --------------------------------------------------------------------------- -// Handshake -// --------------------------------------------------------------------------- - -fn requested_render_encoding() -> RenderEncoding { - match std::env::var("HERDR_RENDER_ENCODING").ok().as_deref() { - Some("terminal-ansi" | "terminal_ansi" | "ansi") => RenderEncoding::TerminalAnsi, - _ => RenderEncoding::SemanticFrame, - } -} - -fn is_remote_client_process() -> bool { - std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok() -} - -fn is_ssh_session() -> bool { - std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some() -} - -/// Time to wait for the server's Welcome reply during the handshake. -/// -/// A local client talks to an already-connected server, so 5s is plenty. The -/// remote bridge client (`herdr --remote`) sits behind a fresh per-attach ssh -/// connection whose cold-connect (TCP + key exchange + auth) happens inside this -/// window; on a high-latency link that easily exceeds 5s, so it gets a far -/// larger budget. See issue #753. -const LOCAL_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(5); -const REMOTE_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(60); - -fn handshake_read_timeout() -> Duration { - if is_remote_client_process() { - return REMOTE_HANDSHAKE_READ_TIMEOUT; - } - LOCAL_HANDSHAKE_READ_TIMEOUT -} - -#[cfg(any(unix, test))] -fn direct_graphics_profile_values( - term_program: &str, - term: &str, - kitty_window: bool, - blocked_transport: bool, - terminals: bool, -) -> bool { - let supported = term_program.eq_ignore_ascii_case("ghostty") - || term_program.eq_ignore_ascii_case("wezterm") - || matches!(term, "xterm-ghostty" | "xterm-kitty" | "xterm-wezterm") - || kitty_window; - supported && !blocked_transport && terminals -} - -#[cfg(unix)] -fn direct_graphics_profile_allowed(direct_attach: bool) -> bool { - let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); - let term = std::env::var("TERM").unwrap_or_default(); - direct_graphics_profile_values( - &term_program, - &term, - std::env::var_os("KITTY_WINDOW_ID").is_some(), - direct_attach - || is_remote_client_process() - || is_ssh_session() - || std::env::var_os("TMUX").is_some() - || std::env::var_os("STY").is_some(), - io::stdin().is_terminal() && io::stdout().is_terminal(), - ) -} - -#[cfg(not(unix))] -fn direct_graphics_profile_allowed(_direct_attach: bool) -> bool { - false -} - -fn requested_keybindings() -> ClientKeybindings { - match std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR) - .ok() - .as_deref() - { - Some("local") => crate::config::Config::load() - .config - .local_keybindings_profile_toml() - .map(|keys_toml| ClientKeybindings::Local { keys_toml }) - .unwrap_or(ClientKeybindings::Server), - _ => ClientKeybindings::Server, - } -} - -#[cfg(windows)] -fn set_handshake_recv_timeout( - stream: &LocalStream, - timeout: Option<Duration>, - context: &'static str, -) -> Result<(), ClientError> { - match stream.set_recv_timeout(timeout) { - Ok(()) => Ok(()), - Err(err) if err.kind() == io::ErrorKind::Unsupported => { - debug!(err = %err, context, "client socket receive timeout unavailable"); - Ok(()) - } - Err(err) => Err(ClientError::ConnectionFailed(err)), - } -} - -#[cfg(not(windows))] -fn set_handshake_recv_timeout( - stream: &LocalStream, - timeout: Option<Duration>, - _context: &'static str, -) -> Result<(), ClientError> { - stream - .set_recv_timeout(timeout) - .map_err(ClientError::ConnectionFailed) -} - -fn client_launch_mode( - direct_attach_requested: bool, - exact_cell_size: bool, - cell_width_px: u32, - cell_height_px: u32, -) -> ClientLaunchMode { - if direct_attach_requested { - ClientLaunchMode::TerminalAttach - } else if exact_cell_size - && cell_width_px > 0 - && cell_height_px > 0 - && direct_graphics_profile_allowed(false) - { - ClientLaunchMode::AppDirectGraphics - } else { - ClientLaunchMode::App - } -} - -/// 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 LocalStream, - cols: u16, - rows: u16, - cell_width_px: u32, - cell_height_px: u32, - exact_cell_size: bool, - requested_encoding: RenderEncoding, - direct_attach_requested: bool, -) -> Result<RenderEncoding, ClientError> { - stream - .set_nonblocking(false) - .map_err(ClientError::ConnectionFailed)?; - - // Send Hello. - let hello = ClientMessage::Hello { - version: PROTOCOL_VERSION, - cols, - rows, - cell_width_px, - cell_height_px, - requested_encoding, - keybindings: requested_keybindings(), - launch_mode: client_launch_mode( - direct_attach_requested, - exact_cell_size, - cell_width_px, - cell_height_px, - ), - }; - protocol::write_message(stream, &hello) - .map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?; - - // Read Welcome. - set_handshake_recv_timeout( - stream, - Some(handshake_read_timeout()), - "client handshake read timeout unavailable", - )?; - let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?; - set_handshake_recv_timeout( - stream, - None, - "failed to clear client handshake read timeout", - )?; - - match welcome { - ServerMessage::Welcome { - version, - encoding, - error, - } => { - if let Some(error) = error { - return Err(ClientError::HandshakeRejected { version, error }); - } - info!(version, ?encoding, "handshake succeeded"); - Ok(encoding) - } - _ => Err(ClientError::Protocol(protocol::FramingError::Io( - io::Error::new(io::ErrorKind::InvalidData, "expected Welcome message"), - ))), + fn present_frame(&mut self, frame_data: FrameData) { + let frame_data = if self.draw_host_cursor { + render_ansi::frame_with_drawn_cursor(frame_data) + } else { + frame_data + }; + let encoded = if self.draw_host_cursor { + self.blit_encoder + .encode_with_suppressed_visible_cursor(&frame_data, self.repaint_pending) + } else { + self.blit_encoder.encode(&frame_data, self.repaint_pending) + }; + let mut stdout = io::stdout(); + let graphics = if self.kitty_graphics_enabled { + frame_data.graphics.as_slice() + } else { + &[] + }; + let _ = write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics); + let _ = stdout.flush(); + self.blit_encoder.commit(frame_data, encoded); + self.repaint_pending = false; } } @@ -921,10 +300,10 @@ enum ClientLoopEvent { /// Structured input events from platforms without Unix-style stdin bytes. #[cfg(windows)] StdinEvents(Vec<crate::protocol::ClientInputEvent>), - /// Terminal resize detected. - Resize(u16, u16, u32, u32), + /// Terminal resize detected, including current exact-pixel eligibility. + Resize(u16, u16, u32, u32, bool), /// Server message received. - ServerMessage(ServerMessage), + ServerMessage(Box<ServerMessage>), /// Server reader thread exited (connection lost). ServerDisconnected, /// Timer tick. @@ -936,19 +315,13 @@ enum ClientLoopEvent { /// /// This is the entry point called from `main.rs` when running in client mode. pub fn run_client() -> io::Result<()> { - run_client_with_mode( - requested_render_encoding(), - None, - None, - "connecting to server", - ) + run_client_with_mode(None, None, "connecting to server") } /// Runs a direct terminal attach client. #[cfg(unix)] pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()> { run_client_with_mode( - RenderEncoding::TerminalAnsi, Some((terminal_id, takeover)), Some(AttachEscapeState::default()), "attaching to terminal", @@ -965,266 +338,7 @@ pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result< )) } -/// Runs a read-only terminal session observer and prints one JSON envelope per frame. -pub fn run_terminal_session_observe(target: String, cols: u16, rows: u16) -> io::Result<()> { - let mut stream = - connect_terminal_session_stream(target.clone(), cols, rows, "observing terminal session")?; - write_to_server(&mut stream, &ClientMessage::ObserveTerminal { target })?; - write_terminal_session_output(stream) -} - -/// Runs a writable terminal session controller. -pub fn run_terminal_session_control( - target: String, - takeover: bool, - cols: u16, - rows: u16, -) -> io::Result<()> { - let mut stream = connect_terminal_session_stream( - target.clone(), - cols, - rows, - "controlling terminal session", - )?; - write_to_server( - &mut stream, - &ClientMessage::ControlTerminal { target, takeover }, - )?; - - let mut write_stream = stream.try_clone()?; - let _input_thread = std::thread::spawn(move || { - let stdin = io::stdin(); - for line in stdin.lock().lines() { - let Ok(line) = line else { - break; - }; - if line.trim().is_empty() { - continue; - } - match terminal_control_command_from_json(&line) { - Ok(message) => { - let release = matches!(message, ClientMessage::Detach); - if write_to_server(&mut write_stream, &message).is_err() { - return; - } - if release { - return; - } - } - Err(err) => eprintln!("herdr: terminal session control input ignored: {err}"), - } - } - let _ = write_to_server(&mut write_stream, &ClientMessage::Detach); - }); - - write_terminal_session_output(stream) -} - -fn connect_terminal_session_stream( - target: String, - cols: u16, - rows: u16, - log_message: &'static str, -) -> io::Result<LocalStream> { - init_logging(); - - let socket_path = client_socket_path(); - crate::logging::startup("client"); - info!(path = %socket_path.display(), target = %target, cols, rows, "{log_message}"); - - let mut stream = match crate::ipc::connect_local_stream(&socket_path) { - Ok(stream) => stream, - Err(err) => { - eprintln!("herdr: {}", ClientError::ConnectionFailed(err)); - std::process::exit(1); - } - }; - - match do_handshake( - &mut stream, - cols, - rows, - 0, - 0, - false, - RenderEncoding::TerminalAnsi, - true, - ) { - Ok(RenderEncoding::TerminalAnsi) => {} - Ok(encoding) => { - eprintln!( - "herdr: terminal session observe negotiated unsupported encoding {encoding:?}" - ); - std::process::exit(1); - } - Err(err) => { - eprintln!("herdr: {err}"); - std::process::exit(1); - } - } - - stream.set_nonblocking(false)?; - Ok(stream) -} - -fn write_terminal_session_output(mut stream: LocalStream) -> io::Result<()> { - let mut stdout = io::stdout().lock(); - loop { - match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE) { - Ok(ServerMessage::Terminal(frame)) => { - let encoded = base64::engine::general_purpose::STANDARD.encode(&frame.bytes); - let line = serde_json::json!({ - "type": "terminal.frame", - "seq": frame.seq, - "encoding": "ansi", - "width": frame.width, - "height": frame.height, - "full": frame.full, - "bytes": encoded, - }); - serde_json::to_writer(&mut stdout, &line)?; - stdout.write_all(b"\n")?; - stdout.flush()?; - } - Ok(ServerMessage::ServerShutdown { reason }) => { - let line = serde_json::json!({ - "type": "terminal.closed", - "reason": reason, - }); - serde_json::to_writer(&mut stdout, &line)?; - stdout.write_all(b"\n")?; - stdout.flush()?; - return Ok(()); - } - Ok(ServerMessage::Graphics { .. }) => {} - Ok(_) => {} - Err(protocol::FramingError::UnexpectedEof) => return Ok(()), - Err(err) => return Err(io::Error::other(err.to_string())), - } - } -} - -#[derive(serde::Deserialize)] -#[serde(tag = "type")] -enum TerminalControlCommand { - #[serde(rename = "terminal.input")] - Input { - text: Option<String>, - bytes: Option<String>, - }, - #[serde(rename = "terminal.resize")] - Resize { - cols: u16, - rows: u16, - #[serde(default)] - cell_width_px: u32, - #[serde(default)] - cell_height_px: u32, - }, - #[serde(rename = "terminal.scroll")] - Scroll { - direction: TerminalControlScrollDirection, - lines: u16, - #[serde(default)] - source: TerminalControlScrollSource, - #[serde(default)] - column: Option<u16>, - #[serde(default)] - row: Option<u16>, - #[serde(default)] - modifiers: u8, - }, - #[serde(rename = "terminal.release")] - Release {}, -} - -#[derive(Clone, Copy, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -enum TerminalControlScrollDirection { - Up, - Down, -} - -#[derive(Clone, Copy, Default, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -enum TerminalControlScrollSource { - #[default] - Wheel, - PageKey, -} - -fn terminal_control_command_from_json(raw: &str) -> Result<ClientMessage, String> { - let command = serde_json::from_str::<TerminalControlCommand>(raw) - .map_err(|err| format!("invalid json command: {err}"))?; - match command { - TerminalControlCommand::Input { text, bytes } => { - let data = match (text, bytes) { - (Some(_), Some(_)) => { - return Err("terminal.input accepts text or bytes, not both".into()) - } - (Some(text), None) => text.into_bytes(), - (None, Some(bytes)) => base64::engine::general_purpose::STANDARD - .decode(bytes) - .map_err(|err| format!("invalid terminal.input bytes: {err}"))?, - (None, None) => Vec::new(), - }; - Ok(ClientMessage::Input { data }) - } - TerminalControlCommand::Resize { - cols, - rows, - cell_width_px, - cell_height_px, - } => { - if cols == 0 || rows == 0 { - return Err("terminal.resize cols and rows must be greater than 0".into()); - } - Ok(ClientMessage::Resize { - cols, - rows, - cell_width_px, - cell_height_px, - }) - } - TerminalControlCommand::Scroll { - direction, - lines, - source, - column, - row, - modifiers, - } => { - if lines == 0 { - return Err("terminal.scroll lines must be greater than 0".into()); - } - let direction = match direction { - TerminalControlScrollDirection::Up => AttachScrollDirection::Up, - TerminalControlScrollDirection::Down => AttachScrollDirection::Down, - }; - let source = match source { - TerminalControlScrollSource::Wheel => AttachScrollSource::Wheel, - TerminalControlScrollSource::PageKey => AttachScrollSource::PageKey { - input: match direction { - AttachScrollDirection::Up => b"\x1b[5~".to_vec(), - AttachScrollDirection::Down => b"\x1b[6~".to_vec(), - }, - }, - }; - Ok(ClientMessage::AttachScroll { - source, - direction, - lines, - column, - row, - modifiers, - }) - } - TerminalControlCommand::Release {} => Ok(ClientMessage::Detach), - } -} - fn run_client_with_mode( - requested_encoding: RenderEncoding, attach_request: Option<(String, bool)>, attach_escape: Option<AttachEscapeState>, log_message: &'static str, @@ -1233,25 +347,43 @@ fn run_client_with_mode( let loaded_config = crate::config::Config::load(); crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; + let client_rendered_shell = attach_request.is_none(); + let socket_path = client_socket_path(); + let keybinding_source = client_shell_keybinding_source(); + let startup_config_diagnostic = + if keybinding_source == shell::ClientShellKeybindingSource::Endpoint { + crate::config::config_diagnostic_summary_without_keybindings(&loaded_config.diagnostics) + } else { + crate::config::config_diagnostic_summary(&loaded_config.diagnostics) + }; + let shell_config = client_rendered_shell.then(|| { + shell::ClientShellConfig::from_config(&loaded_config.config) + .with_startup_config_diagnostic(startup_config_diagnostic) + .with_startup_onboarding(loaded_config.config.should_show_onboarding()) + .with_keybinding_source(keybinding_source) + .with_local_endpoint(&socket_path) + }); let mouse_capture = loaded_config.config.ui.mouse_capture; let mouse_scroll_lines = loaded_config.config.ui.mouse_scroll_lines(); let redraw_on_focus_gained = loaded_config.config.ui.redraw_on_focus_gained; let host_cursor = loaded_config.config.ui.host_cursor; - let direct_attach_requested = attach_request.is_some(); let remote_image_paste_key = client_remote_image_paste_key(&loaded_config.config); let kitty_graphics_enabled = - loaded_config.config.experimental.kitty_graphics && !direct_attach_requested; + loaded_config.config.experimental.kitty_graphics && client_rendered_shell; + let pixel_geometry_enabled = kitty_graphics_enabled || attach_escape.is_some(); let loop_config = ClientLoopConfig { sound_config: loaded_config.config.ui.sound, mouse_scroll_lines, redraw_on_focus_gained, host_cursor, kitty_graphics_enabled, + pixel_geometry_enabled, + pixel_geometry_fallback: kitty_graphics_enabled, mouse_capture_active: mouse_capture, remote_image_paste_key, + shell_config, }; - let socket_path = client_socket_path(); crate::logging::startup("client"); info!(path = %socket_path.display(), "{log_message}"); @@ -1268,7 +400,16 @@ fn run_client_with_mode( // Get the terminal geometry before handshake (before raw mode). let (cols, rows, cell_width_px, cell_height_px, exact_cell_size) = - initial_terminal_geometry(kitty_graphics_enabled); + initial_terminal_geometry(pixel_geometry_enabled, kitty_graphics_enabled); + + let shell_surface_size = loop_config + .shell_config + .as_ref() + .map(|shell| shell.initial_surface_size(cols, rows)); + let endpoint_keybindings = loop_config + .shell_config + .as_ref() + .is_some_and(shell::ClientShellConfig::uses_endpoint_keybindings); // Perform handshake while the stream is still in blocking mode. let negotiated_encoding = match do_handshake( @@ -1278,8 +419,9 @@ fn run_client_with_mode( cell_width_px, cell_height_px, exact_cell_size, - requested_encoding, - direct_attach_requested, + shell_surface_size, + endpoint_keybindings, + loop_config.mouse_capture_active, ) { Ok(encoding) => encoding, Err(err) => { @@ -1312,19 +454,11 @@ fn run_client_with_mode( err })?; - // Install a panic hook to restore the terminal on panic (same as monolithic). - let panic_resets_modify_other_keys = terminal_guard.reset_modify_other_keys; - let panic_resets_host_color_scheme_reports = terminal_guard.reset_host_color_scheme_reports; - #[cfg(windows)] - let panic_restore_windows_input_mode = terminal_guard.restore_windows_input_mode; + // Install a panic hook so the foreground client always restores its terminal. + let panic_restore = terminal_guard.panic_restore(); let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - let _ = restore_terminal_state( - panic_resets_modify_other_keys, - panic_resets_host_color_scheme_reports, - #[cfg(windows)] - panic_restore_windows_input_mode, - ); + panic_restore(); original_hook(info); })); @@ -1352,6 +486,7 @@ fn run_client_with_mode( rows, cell_width_px, cell_height_px, + exact_cell_size, should_quit, loop_config, negotiated_encoding, @@ -1388,6 +523,148 @@ fn run_client_with_mode( Ok(()) } +fn dispatch_client_shell_actions( + actions: Vec<shell::ClientShellAction>, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + write_stream: &mut LocalStream, + detached_process_children: &mut Vec<std::process::Child>, +) -> Result<Vec<crossterm::event::MouseEvent>, ClientError> { + let mut replay_mouse = Vec::new(); + for action in actions { + match action { + shell::ClientShellAction::Endpoint { boot_id, request } => { + endpoint_commands.enqueue(boot_id, request); + } + shell::ClientShellAction::ClipboardWrite(bytes) => { + crate::selection::write_osc52_bytes(&bytes); + } + shell::ClientShellAction::Request(request) => { + write_to_server(write_stream, &request).map_err(ClientError::ConnectionLost)?; + } + shell::ClientShellAction::OpenSafeWebUrl(url) => { + if crate::app::actions::safe_web_url(&url).is_some() { + match crate::platform::open_url(&url) { + Ok(Some(child)) => detached_process_children.push(child), + Ok(None) => {} + Err(err) => warn!(err = %err, url = %url, "failed to open pane URL"), + } + } + } + shell::ClientShellAction::ReplayMouse(events) => replay_mouse.extend(events), + shell::ClientShellAction::Keybind(action) => { + debug!( + ?action, + "client shell action awaits its presentation family" + ); + } + } + } + endpoint_commands + .send_next(write_stream) + .map_err(ClientError::ConnectionLost)?; + Ok(replay_mouse) +} + +fn client_shell_resize_message( + shell: &shell::ClientShellState, + cols: u16, + rows: u16, + cell_width_px: u32, + cell_height_px: u32, + pixel_mouse: bool, +) -> ClientMessage { + ClientMessage::ClientShellResize { + cell_width_px, + cell_height_px, + surface_size: shell.surface_size(cols, rows), + pixel_mouse, + } +} + +fn sync_client_shell_keyboard_report_all(state: &mut ClientState) -> Result<(), ClientError> { + let Some(shell) = state.shell.as_ref() else { + return Ok(()); + }; + let desired = state.pane_keyboard_report_all || shell.host_keyboard_report_all_requested(); + if desired == state.keyboard_report_all_active { + return Ok(()); + } + crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired) + .map_err(ClientError::ConnectionFailed)?; + state.keyboard_report_all_active = desired; + Ok(()) +} + +fn apply_client_shell_input_source_changes( + state: &mut ClientState, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, +) { + let changes = state + .shell + .as_mut() + .map(shell::ClientShellState::take_input_source_changes) + .unwrap_or_default(); + for active in changes { + if active { + prefix_input_source.switch_to_ascii(); + } else { + prefix_input_source.restore(); + } + } +} + +fn finish_client_shell_input( + state: &mut ClientState, + outcome: shell::ClientShellInput, + frame: Option<FrameData>, + write_stream: &mut LocalStream, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, +) -> Result<bool, ClientError> { + apply_client_shell_input_source_changes(state, prefix_input_source); + if outcome.detach { + let _ = write_to_server(write_stream, &ClientMessage::Detach); + return Ok(true); + } + if outcome.resize { + let shell = state.shell.as_ref().expect("shell mode remains active"); + let resize = client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ); + write_to_server(write_stream, &resize).map_err(ClientError::ConnectionLost)?; + } + #[cfg(not(windows))] + if outcome.query_host_appearance { + query_host_terminal_appearance(); + } + if outcome.query_host_theme { + query_host_terminal_theme(); + } + sync_client_shell_keyboard_report_all(state)?; + let replay = dispatch_client_shell_actions( + outcome.actions, + endpoint_commands, + write_stream, + &mut state.detached_process_children, + )?; + debug_assert!( + replay.is_empty(), + "mouse replay only follows endpoint results" + ); + for request in outcome.requests { + write_to_server(write_stream, &request).map_err(ClientError::ConnectionLost)?; + } + if let Some(frame) = frame { + state.present_frame(frame); + } + Ok(false) +} + /// The main client event loop. /// /// Uses a threaded architecture: @@ -1401,6 +678,7 @@ async fn run_client_loop( rows: u16, initial_cell_width_px: u32, initial_cell_height_px: u32, + initial_pixel_geometry_exact: bool, should_quit: Arc<AtomicBool>, config: ClientLoopConfig, negotiated_encoding: RenderEncoding, @@ -1414,14 +692,25 @@ async fn run_client_loop( let mut state = ClientState { blit_encoder: render_ansi::BlitEncoder::new(), mouse_capture_active: config.mouse_capture_active, + endpoint_mouse_capture_requested: false, + endpoint_sgr_pixels_requested: false, + direct_mouse_capture_preference: attach_escape.is_some() && config.mouse_capture_active, + shell_mouse_capture_preference: config.mouse_capture_active, + direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState::default(), + pane_keyboard_report_all: false, keyboard_report_all_active: false, reported_size: (cols, rows), + reported_cell_size: (initial_cell_width_px, initial_cell_height_px), sound_config: config.sound_config, kitty_graphics_enabled: config.kitty_graphics_enabled, + pixel_geometry_enabled: config.pixel_geometry_enabled, + pixel_geometry_exact: initial_pixel_geometry_exact, #[cfg(unix)] direct_graphics_response: Arc::new(Mutex::new(direct_graphics::ResponseMatcher::default())), #[cfg(unix)] retired_direct_graphics: None, + #[cfg(unix)] + pending_surface_graphics: HashMap::new(), attach_escape, #[cfg(unix)] mouse_scroll_lines: config.mouse_scroll_lines, @@ -1429,7 +718,12 @@ async fn run_client_loop( redraw_on_focus_gained: config.redraw_on_focus_gained, repaint_pending: false, draw_host_cursor, + detached_process_children: Vec::new(), + shell: config.shell_config.map(shell::ClientShellState::new), }; + if let Some(shell) = state.shell.as_mut() { + shell.set_graphics_cell_size(initial_cell_width_px, initial_cell_height_px); + } debug!(?negotiated_encoding, "client render encoding active"); let host_mouse_capture_active = Arc::new(AtomicBool::new(state.mouse_capture_active)); // Cell size reported by the host terminal, packed as width<<32 | height. @@ -1440,6 +734,8 @@ async fn run_client_loop( // Channel for events from the stdin, resize, and server reader threads. let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256); + let mut endpoint_commands = endpoint_commands::EndpointCommands::default(); + // Spawn the stdin reader thread. let will_query_host_terminal_theme = state.attach_escape.is_none() && should_query_host_terminal_theme(); @@ -1475,6 +771,10 @@ async fn run_client_loop( if will_query_host_terminal_theme { query_host_terminal_theme(); + #[cfg(not(windows))] + if state.shell.is_some() { + query_host_terminal_appearance(); + } } if will_query_host_cell_size { @@ -1486,6 +786,8 @@ async fn run_client_loop( let resize_tx = event_tx.clone(); let resize_cell_size = reported_cell_size.clone(); let kitty_graphics_enabled = state.kitty_graphics_enabled; + let pixel_geometry_enabled = state.pixel_geometry_enabled; + let pixel_geometry_fallback = config.pixel_geometry_fallback; std::thread::spawn(move || { resize_poll_loop( resize_tx, @@ -1493,7 +795,9 @@ async fn run_client_loop( rows, initial_cell_width_px, initial_cell_height_px, - kitty_graphics_enabled, + initial_pixel_geometry_exact, + pixel_geometry_enabled, + pixel_geometry_fallback, &resize_cell_size, &resize_quit, ); @@ -1527,19 +831,95 @@ async fn run_client_loop( // This (foreground) client owns the prefix ASCII input-source switch // (implemented on macOS and Windows; a no-op on other platforms). - use crate::platform::PrefixInputSource; let mut prefix_input_source = crate::platform::RealPrefixInputSource::default(); // Main event loop. + let mut client_timer = timer::ClientLoopTimer::new(); while !should_quit.load(Ordering::Acquire) { + let timer_delay = state + .shell + .as_ref() + .map_or(Duration::from_millis(100), |shell| { + shell.timer_delay(std::time::Instant::now()) + }); + let timer_deadline = client_timer.deadline(std::time::Instant::now(), timer_delay); let event = tokio::select! { + biased; + _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer, ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer), - _ = tokio::time::sleep(Duration::from_millis(100)) => ClientLoopEvent::Timer, }; + let now = std::time::Instant::now(); + if let Some(shell) = state.shell.as_mut() { + shell.tick_popup_pending(now); + } match event { #[cfg(unix)] ClientLoopEvent::StdinInput(data) => { + if state.shell.is_some() { + if will_query_host_cell_size { + let events = crate::raw_input::parse_raw_input_bytes_sync(&data); + if let Some((width_px, height_px)) = reported_cell_size_from_events(&events) + { + store_reported_cell_size(&reported_cell_size, width_px, height_px); + } + } + let image_target = state + .shell + .as_ref() + .and_then(|shell| shell.clipboard_image_target()); + if let Some(target) = image_target.clone() { + if should_bridge_clipboard_image_paste( + &data, + is_remote_client, + state.remote_image_paste_key, + ) { + if let Some(image) = crate::platform::read_clipboard_image() { + write_remote_image_to_server( + &mut write_stream, + target, + image, + "clipboard paste", + )?; + continue; + } + info!( + "clipboard image paste trigger received, but local clipboard has no image" + ); + } + if let Some(image) = + read_image_file_from_terminal_drop(&data, is_remote_client) + { + write_remote_image_to_server( + &mut write_stream, + target, + image, + "file drop", + )?; + continue; + } + } + let (outcome, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let outcome = shell.handle_input_bytes(&data); + let frame = outcome + .repaint + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten(); + (outcome, frame) + }; + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut endpoint_commands, + &mut prefix_input_source, + )? { + return Ok(()); + } + continue; + } let data = if let Some(attach_escape) = &mut state.attach_escape { match attach_escape.filter_input( data, @@ -1547,30 +927,33 @@ async fn run_client_loop( state.mouse_scroll_lines, ) { AttachInputAction::Forward(data) => data, - AttachInputAction::ForwardAfterPendingPrefix(data) => { - let prefix = ClientMessage::Input { data: vec![0x02] }; - if let Err(e) = write_to_server(&mut write_stream, &prefix) { + AttachInputAction::ForwardPair(first, second) => { + for data in [first, second] { + if let Err(e) = write_to_server( + &mut write_stream, + &ClientMessage::Input { data }, + ) { + return Err(ClientError::ConnectionLost(e)); + } + } + continue; + } + AttachInputAction::Semantic(action) => { + if let Err(e) = write_attach_semantic_action(&mut write_stream, action) + { return Err(ClientError::ConnectionLost(e)); } - data + continue; } - AttachInputAction::Scroll { - source, - direction, - lines, - column, - row, - modifiers, - } => { - let msg = ClientMessage::AttachScroll { - source, - direction, - lines, - column, - row, - modifiers, - }; - if let Err(e) = write_to_server(&mut write_stream, &msg) { + AttachInputAction::ForwardThenSemantic(prefix, action) => { + if let Err(e) = write_to_server( + &mut write_stream, + &ClientMessage::Input { data: prefix }, + ) { + return Err(ClientError::ConnectionLost(e)); + } + if let Err(e) = write_attach_semantic_action(&mut write_stream, action) + { return Err(ClientError::ConnectionLost(e)); } continue; @@ -1606,7 +989,12 @@ async fn run_client_loop( state.remote_image_paste_key, ) { if let Some(image) = crate::platform::read_clipboard_image() { - write_remote_image_to_server(&mut write_stream, image, "clipboard paste")?; + write_remote_image_to_server( + &mut write_stream, + crate::protocol::ClientClipboardImageTarget::DirectTerminal, + image, + "clipboard paste", + )?; continue; } info!( @@ -1614,7 +1002,12 @@ async fn run_client_loop( ); } if let Some(image) = read_image_file_from_terminal_drop(&data, is_remote_client) { - write_remote_image_to_server(&mut write_stream, image, "file drop")?; + write_remote_image_to_server( + &mut write_stream, + crate::protocol::ClientClipboardImageTarget::DirectTerminal, + image, + "file drop", + )?; continue; } let msg = ClientMessage::Input { data }; @@ -1624,6 +1017,17 @@ async fn run_client_loop( } #[cfg(unix)] ClientLoopEvent::DirectGraphicsResponse(response) => { + let composed = state + .pending_surface_graphics + .remove(&response.transfer_id) + .filter(|_| response.success) + .and_then(|asset| { + let shell = state.shell.as_mut()?; + shell + .trust_direct_graphics_asset(&asset, response.image_id) + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten() + }); let message = ClientMessage::GraphicsTransmissionResult { transfer_id: response.transfer_id, image_id: response.image_id, @@ -1632,99 +1036,251 @@ async fn run_client_loop( if let Err(err) = write_to_server(&mut write_stream, &message) { return Err(ClientError::ConnectionLost(err)); } + if let Some(frame) = composed { + state.present_frame(frame); + } } #[cfg(unix)] ClientLoopEvent::PixelMouse(data, geometry) => { - let message = ClientMessage::InputPixels { - data, - cols: geometry.cols, - rows: geometry.rows, - width_px: geometry.width_px, - height_px: geometry.height_px, - }; - if let Err(err) = write_to_server(&mut write_stream, &message) { - return Err(ClientError::ConnectionLost(err)); + if state.shell.is_some() { + let (outcome, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let outcome = shell.handle_pixel_mouse(&data, geometry); + let frame = outcome + .repaint + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten(); + (outcome, frame) + }; + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut endpoint_commands, + &mut prefix_input_source, + )? { + return Ok(()); + } + continue; + } + if let Some(attach_escape) = state.attach_escape.as_mut() { + if let Some(prefix) = attach_escape.take_pending_prefix() { + if let Err(err) = write_to_server( + &mut write_stream, + &ClientMessage::Input { data: prefix }, + ) { + return Err(ClientError::ConnectionLost(err)); + } + } + if let Some((kind, position, modifiers)) = + direct_attach_pixel_mouse(&data, geometry) + { + let message = ClientMessage::AttachMouse { + kind, + position, + geometry: Some(crate::protocol::ClientMouseGeometry { + cols: geometry.cols, + rows: geometry.rows, + width_px: geometry.width_px, + height_px: geometry.height_px, + }), + modifiers, + lines: state.mouse_scroll_lines.max(1).min(u16::MAX as usize) as u16, + }; + if let Err(err) = write_to_server(&mut write_stream, &message) { + return Err(ClientError::ConnectionLost(err)); + } + } } } #[cfg(windows)] ClientLoopEvent::StdinEvents(events) => { - if state.attach_escape.is_some() { - continue; - } - if should_bridge_clipboard_image_events( - &events, - is_remote_client, - state.remote_image_paste_key, - ) { - if let Some(image) = crate::platform::read_clipboard_image() { - write_remote_image_to_server(&mut write_stream, image, "clipboard paste")?; - continue; + if state.shell.is_some() { + let image_target = state + .shell + .as_ref() + .and_then(|shell| shell.clipboard_image_target()); + if let Some(target) = image_target.clone() { + if should_bridge_clipboard_image_events( + &events, + is_remote_client, + state.remote_image_paste_key, + ) { + if let Some(image) = crate::platform::read_clipboard_image() { + write_remote_image_to_server( + &mut write_stream, + target, + image, + "clipboard paste", + )?; + continue; + } + info!( + "clipboard image paste trigger received, but local clipboard has no image" + ); + } + if let Some(image) = + read_image_file_from_client_events(&events, is_remote_client) + { + write_remote_image_to_server( + &mut write_stream, + target, + image, + "file drop", + )?; + continue; + } + } + let (outcome, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let outcome = shell.handle_client_events(&events); + let frame = outcome + .repaint + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten(); + (outcome, frame) + }; + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut endpoint_commands, + &mut prefix_input_source, + )? { + return Ok(()); } - info!( - "clipboard image paste trigger received, but local clipboard has no image" - ); - } - if let Some(image) = read_image_file_from_client_events(&events, is_remote_client) { - write_remote_image_to_server(&mut write_stream, image, "file drop")?; continue; } - let raw_events = events - .iter() - .map(crate::protocol::ClientInputEvent::to_raw_input_event) - .collect::<Vec<_>>(); - if crate::raw_input::events_require_host_surface_redraw( - &raw_events, - state.redraw_on_focus_gained, - ) { - state.request_repaint(); - } - let msg = ClientMessage::InputEvents { events }; - if let Err(e) = write_to_server(&mut write_stream, &msg) { - return Err(ClientError::ConnectionLost(e)); - } + // Direct terminal attach is Unix-only; every Windows client uses ClientShell. } - ClientLoopEvent::Resize(new_cols, new_rows, cell_width_px, cell_height_px) => { + ClientLoopEvent::Resize( + new_cols, + new_rows, + cell_width_px, + cell_height_px, + pixel_geometry_exact, + ) => { + if !pixel_geometry_exact && host_sgr_pixels_active.load(Ordering::Acquire) { + set_mouse_capture(state.mouse_capture_active, false) + .map_err(ClientError::ConnectionFailed)?; + host_sgr_pixels_active.store(false, Ordering::Release); + } state.reported_size = (new_cols, new_rows); - // Resizing invalidates the host-side blit baseline. + state.reported_cell_size = (cell_width_px, cell_height_px); + state.pixel_geometry_exact = pixel_geometry_exact; + // Resizing invalidates both the host-side blit baseline and pane hit geometry. state.request_repaint(); - let msg = ClientMessage::Resize { - cols: new_cols, - rows: new_rows, - cell_width_px, - cell_height_px, + if let Some(shell) = state.shell.as_mut() { + shell.set_graphics_cell_size(cell_width_px, cell_height_px); + shell.invalidate_pane_surface(); + } + let msg = if let Some(shell) = &state.shell { + client_shell_resize_message( + shell, + new_cols, + new_rows, + cell_width_px, + cell_height_px, + pixel_geometry_exact, + ) + } else { + ClientMessage::Resize { + cols: new_cols, + rows: new_rows, + cell_width_px, + cell_height_px, + pixel_mouse: pixel_geometry_exact, + } }; 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) => { - let frame_data = if state.draw_host_cursor { - render_ansi::frame_with_drawn_cursor(frame_data) - } else { - frame_data - }; - let encoded = if state.draw_host_cursor { - state.blit_encoder.encode_with_suppressed_visible_cursor( - &frame_data, - state.repaint_pending, + ClientLoopEvent::ServerMessage(msg) => match *msg { + ServerMessage::ClientShellSnapshot(snapshot) => { + let (composed, resize, graphics_cleanup) = if let Some(shell) = &mut state.shell + { + let previous_size = + shell.surface_size(state.reported_size.0, state.reported_size.1); + shell.set_snapshot(snapshot); + let graphics_cleanup = shell.take_pending_graphics_cleanup(); + let next_size = + shell.surface_size(state.reported_size.0, state.reported_size.1); + ( + shell.compose(state.reported_size.0, state.reported_size.1), + (previous_size != next_size).then(|| { + client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ) + }), + graphics_cleanup, ) } else { - state - .blit_encoder - .encode(&frame_data, state.repaint_pending) + (None, None, Vec::new()) }; - let mut stdout = io::stdout(); - let graphics = if state.kitty_graphics_enabled { - frame_data.graphics.as_slice() + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + state.present_graphics(&graphics_cleanup); + if let Some(resize) = resize { + if let Err(err) = write_to_server(&mut write_stream, &resize) { + return Err(ClientError::ConnectionLost(err)); + } + } + if let Some(frame) = composed { + state.present_frame(frame); + } + } + ServerMessage::PaneSurface(surface) => { + let composed = if let Some(shell) = &mut state.shell { + shell.set_pane_surface(surface); + shell.compose(state.reported_size.0, state.reported_size.1) } else { - &[] + None }; - let _ = - write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics); - let _ = stdout.flush(); - state.blit_encoder.commit(frame_data, encoded); - state.repaint_pending = false; + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + if let Some(frame) = composed { + state.present_frame(frame); + } + } + ServerMessage::PaneSurfacePatch(patch) => { + let patch_started = crate::render_prof::timer(); + let apply_started = crate::render_prof::timer(); + let outcome = state + .shell + .as_mut() + .map(|shell| shell.apply_pane_surface_patch(patch)); + crate::render_prof::duration_since("client_surface_patch.apply", apply_started); + let compose_fallback = match outcome { + Some(shell::ClientPaneSurfacePatchOutcome::Applied(Some(patch))) => { + match state.present_surface_patch(patch) { + Ok(presented) => !presented, + Err(error) => { + warn!(%error, "failed to present retained pane surface patch"); + state.request_repaint(); + false + } + } + } + Some(shell::ClientPaneSurfacePatchOutcome::Applied(None)) => true, + Some(shell::ClientPaneSurfacePatchOutcome::Rejected) | None => false, + }; + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + if compose_fallback { + let composed = state.shell.as_mut().and_then(|shell| { + shell.compose(state.reported_size.0, state.reported_size.1) + }); + if let Some(frame) = composed { + state.present_frame(frame); + } + } + crate::render_prof::duration_since("client_surface_patch.total", patch_started); + crate::render_prof::flush_if_due(); } ServerMessage::Terminal(frame) => { if state.kitty_graphics_enabled && contains_kitty_graphics_bytes(&frame.bytes) { @@ -1756,13 +1312,25 @@ async fn run_client_loop( transfer_id, leading, control, + surface_asset, } => { #[cfg(unix)] { if state.retired_direct_graphics.take() == Some((transfer_id, image_id)) { continue; } + let surface_asset_valid = match (state.shell.as_ref(), &surface_asset) { + (Some(shell), Some(asset)) => { + crate::kitty_graphics::surface::host_image_id( + shell.graphics_scope(), + asset, + ) == image_id + } + (None, None) => true, + _ => false, + }; let valid = state.kitty_graphics_enabled + && surface_asset_valid && usize::try_from(expected_len).ok().is_some_and(|len| { crate::pane_graphics_files::validate_direct_source( std::path::Path::new(&path), @@ -1796,6 +1364,9 @@ async fn run_client_loop( false }; if sent { + if let Some(asset) = surface_asset { + state.pending_surface_graphics.insert(transfer_id, asset); + } if let Ok(mut matcher) = state.direct_graphics_response.lock() { matcher.start(transfer_id); } @@ -1807,6 +1378,7 @@ async fn run_client_loop( return Err(ClientError::ConnectionLost(err)); } } else { + state.pending_surface_graphics.remove(&transfer_id); if let Ok(mut matcher) = state.direct_graphics_response.lock() { if valid { matcher.retire(transfer_id); @@ -1825,7 +1397,15 @@ async fn run_client_loop( } } #[cfg(not(unix))] - let _ = (path, expected_len, image_id, transfer_id, leading, control); + let _ = ( + path, + expected_len, + image_id, + transfer_id, + leading, + control, + surface_asset, + ); } ServerMessage::GraphicsTransmissionRetired { transfer_id, @@ -1834,6 +1414,15 @@ async fn run_client_loop( #[cfg(unix)] { state.retired_direct_graphics = Some((transfer_id, image_id)); + state.pending_surface_graphics.remove(&transfer_id); + let cleanup = state.shell.as_mut().map_or_else(Vec::new, |shell| { + shell.retire_direct_graphics_image(image_id); + shell + .compose(state.reported_size.0, state.reported_size.1) + .map(|frame| frame.graphics) + .unwrap_or_else(|| shell.take_pending_graphics_cleanup()) + }); + state.present_graphics(&cleanup); if let Ok(mut matcher) = state.direct_graphics_response.lock() { matcher.retire(transfer_id); } @@ -1849,10 +1438,117 @@ async fn run_client_loop( message, body, } => { - handle_notify(kind, &message, body.as_deref(), &state.sound_config); + if state.shell.is_none() { + handle_notify(kind, &message, body.as_deref(), &state.sound_config); + } + } + ServerMessage::SemanticNotification(event) => { + if state.shell.is_some() { + let (effects, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let (effects, repaint) = + shell.receive_notification(event, std::time::Instant::now()); + let frame = repaint + .then(|| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) + .flatten(); + (effects, frame) + }; + handle_shell_notification_effects(effects, &state.sound_config); + if let Some(frame) = frame { + state.present_frame(frame); + } + } + } + ServerMessage::ClientShellError { message } => { + if let Some(shell) = state.shell.as_mut() { + if shell.receive_endpoint_error(message) { + let frame = shell.compose(state.reported_size.0, state.reported_size.1); + if let Some(frame) = frame { + state.present_frame(frame); + } + } + } + } + ServerMessage::ClientShellEndpointResponseChunk { + boot_id, + request_id, + final_chunk, + data, + } => { + let completed = endpoint_commands + .receive_chunk(&boot_id, &request_id, final_chunk, data) + .map_err(ClientError::ConnectionLost)?; + let Some(completed) = completed else { + continue; + }; + let (repaint, actions) = state.shell.as_mut().map_or_else( + || (false, Vec::new()), + |shell| { + shell.handle_endpoint_result( + &completed.boot_id, + &completed.request_id, + completed.result, + ) + }, + ); + if let Some(shell) = state.shell.as_mut() { + shell.reconcile_input_source(); + } + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + let replay_mouse = dispatch_client_shell_actions( + actions, + &mut endpoint_commands, + &mut write_stream, + &mut state.detached_process_children, + )?; + if replay_mouse.is_empty() { + if repaint { + if let Some(frame) = state.shell.as_mut().and_then(|shell| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) { + state.present_frame(frame); + } + } + } else { + let (outcome, frame) = { + let shell = state.shell.as_mut().expect("shell endpoint response"); + let mut outcome = shell.replay_mouse_events(replay_mouse); + outcome.repaint |= repaint; + let frame = outcome + .repaint + .then(|| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) + .flatten(); + (outcome, frame) + }; + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut endpoint_commands, + &mut prefix_input_source, + )? { + return Ok(()); + } + } } ServerMessage::Clipboard { data } => { - forward_clipboard(&data); + if forward_clipboard(&data) { + let (width, height) = state.reported_size; + let frame = state.shell.as_mut().and_then(|shell| { + shell + .show_copy_feedback(std::time::Instant::now()) + .then(|| shell.compose(width, height)) + .flatten() + }); + if let Some(frame) = frame { + state.present_frame(frame); + } + } let _ = io::stdout().flush(); } ServerMessage::WindowTitle { title } => { @@ -1862,18 +1558,90 @@ async fn run_client_loop( ); } ServerMessage::ReloadSoundConfig => { + let previous_mouse_capture = state.shell_mouse_capture_preference; + let mut mouse_capture = previous_mouse_capture; reload_local_client_config( &mut state.sound_config, &mut state.redraw_on_focus_gained, &mut state.draw_host_cursor, &mut state.remote_image_paste_key, + &mut mouse_capture, ); + state.shell_mouse_capture_preference = mouse_capture; + state.direct_mouse_capture_preference = + state.attach_escape.is_some() && mouse_capture; + if state.shell.is_some() && previous_mouse_capture != mouse_capture { + write_to_server( + &mut write_stream, + &ClientMessage::ClientShellMouseCapture { + enabled: mouse_capture, + }, + ) + .map_err(ClientError::ConnectionLost)?; + } + if state.attach_escape.is_some() { + let enabled = effective_mouse_capture( + state.endpoint_mouse_capture_requested, + state.direct_mouse_capture_preference, + ); + let sgr_pixels = effective_sgr_pixel_mouse( + enabled, + state.endpoint_sgr_pixels_requested, + state.pixel_geometry_exact, + ); + if enabled != state.mouse_capture_active + || sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire) + { + set_mouse_capture(enabled, sgr_pixels) + .map_err(ClientError::ConnectionFailed)?; + } + state.mouse_capture_active = enabled; + host_mouse_capture_active.store(enabled, Ordering::Release); + host_sgr_pixels_active.store(sgr_pixels, Ordering::Release); + } + let (frame, resize) = if let Some(shell) = state.shell.as_mut() { + let previous_size = + shell.surface_size(state.reported_size.0, state.reported_size.1); + shell.reload_client_config(); + let next_size = + shell.surface_size(state.reported_size.0, state.reported_size.1); + let resize = (previous_size != next_size).then(|| { + shell.invalidate_pane_surface(); + client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ) + }); + ( + shell.compose(state.reported_size.0, state.reported_size.1), + resize, + ) + } else { + (None, None) + }; + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + if let Some(resize) = resize { + write_to_server(&mut write_stream, &resize) + .map_err(ClientError::ConnectionLost)?; + } + if let Some(frame) = frame { + state.present_frame(frame); + } } ServerMessage::MouseCapture { enabled, sgr_pixels, } => { - let next_sgr_pixels = enabled && sgr_pixels; + state.endpoint_mouse_capture_requested = enabled; + state.endpoint_sgr_pixels_requested = sgr_pixels; + let enabled = + effective_mouse_capture(enabled, state.direct_mouse_capture_preference); + let next_sgr_pixels = + effective_sgr_pixel_mouse(enabled, sgr_pixels, state.pixel_geometry_exact); let mouse_mode_changed = enabled != state.mouse_capture_active || next_sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire); if mouse_mode_changed { @@ -1892,21 +1660,24 @@ async fn run_client_loop( host_mouse_capture_active.store(enabled, Ordering::Release); host_sgr_pixels_active.store(next_sgr_pixels, Ordering::Release); } - ServerMessage::KittyKeyboardReportAll { enabled } => { - if enabled != state.keyboard_report_all_active { - crate::terminal_modes::set_host_kitty_keyboard_report_all( + ServerMessage::DirectTerminalKeyboardProtocol { + flags, + modify_other_keys_level, + } => { + if state.attach_escape.is_some() { + crate::terminal_modes::set_direct_host_keyboard_protocol( &mut io::stdout(), - enabled, + &mut state.direct_keyboard_protocol, + flags, + modify_other_keys_level, ) .map_err(ClientError::ConnectionFailed)?; - state.keyboard_report_all_active = enabled; } } - ServerMessage::PrefixInputSource { active } => { - if active { - prefix_input_source.switch_to_ascii(); - } else { - prefix_input_source.restore(); + ServerMessage::ClientShellKeyboardReportAll { enabled } => { + if state.shell.is_some() { + state.pane_keyboard_report_all = enabled; + sync_client_shell_keyboard_report_all(&mut state)?; } } ServerMessage::Welcome { .. } => { @@ -1920,10 +1691,38 @@ async fn run_client_loop( ))); } ClientLoopEvent::Timer => { + client_timer.fired(); #[cfg(unix)] if let Ok(mut matcher) = state.direct_graphics_response.lock() { matcher.expire(); } + state + .detached_process_children + .retain_mut(|child| child.try_wait().ok().flatten().is_none()); + if state.shell.is_some() { + let (effects, outcome, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let (effects, notification_repaint) = shell.tick_notifications(now); + let mut outcome = shell.tick_selection_autoscroll(now); + outcome.repaint |= notification_repaint | shell.tick_copy_feedback(now); + let frame = outcome + .repaint + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten(); + (effects, outcome, frame) + }; + handle_shell_notification_effects(effects, &state.sound_config); + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut endpoint_commands, + &mut prefix_input_source, + )? { + return Ok(()); + } + } } } } @@ -1965,7 +1764,7 @@ fn server_reader_thread( match protocol::read_message(&mut stream, max_frame_size) { Ok(msg) => { if event_tx - .blocking_send(ClientLoopEvent::ServerMessage(msg)) + .blocking_send(ClientLoopEvent::ServerMessage(Box::new(msg))) .is_err() { break; // Main loop gone. @@ -2000,57 +1799,10 @@ fn write_to_server(stream: &mut LocalStream, msg: &ClientMessage) -> io::Result< protocol::write_message(stream, msg).map_err(|e| io::Error::other(e.to_string())) } -fn write_remote_image_to_server( - stream: &mut LocalStream, - image: crate::platform::ClipboardImage, - source: &'static str, -) -> Result<(), ClientError> { - if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD { - warn!( - bytes = image.bytes.len(), - max = MAX_CLIPBOARD_IMAGE_PAYLOAD, - source, - "local image is too large to bridge" - ); - return Ok(()); - } - - info!( - bytes = image.bytes.len(), - extension = image.extension, - source, - "bridging local image to remote server" - ); - write_to_server( - stream, - &ClientMessage::ClipboardImage { - extension: image.extension.to_owned(), - data: image.bytes, - }, - ) - .map_err(ClientError::ConnectionLost) -} - // --------------------------------------------------------------------------- // Notifications // --------------------------------------------------------------------------- -fn client_remote_image_paste_key( - config: &crate::config::Config, -) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> { - if !is_remote_client_process() { - return None; - } - - match config.remote_image_paste_key() { - Ok(key) => key, - Err(diagnostic) => { - warn!(diagnostic = %diagnostic, "local remote image paste key config diagnostic"); - None - } - } -} - fn reload_local_client_config( sound_config: &mut crate::config::SoundConfig, redraw_on_focus_gained: &mut bool, @@ -2059,17 +1811,29 @@ fn reload_local_client_config( crossterm::event::KeyCode, crossterm::event::KeyModifiers, )>, + mouse_capture: &mut bool, ) { match crate::config::load_live_config() { Ok(loaded) => { - for diagnostic in loaded.config.ui.sound.diagnostics() { - warn!(diagnostic = %diagnostic, "local sound config diagnostic"); + let invalid_section = |section: &str| { + loaded + .invalid_sections + .iter() + .any(|invalid| invalid == section) + }; + if !invalid_section("ui") && loaded.config.invalid_sidebar_bounds_diagnostic().is_none() + { + for diagnostic in loaded.config.ui.sound.diagnostics() { + warn!(diagnostic = %diagnostic, "local sound config diagnostic"); + } + *sound_config = loaded.config.ui.sound.clone(); + *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; + *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor); + *mouse_capture = loaded.config.ui.mouse_capture; + } + if !invalid_section("keys") { + *remote_image_paste_key = client_remote_image_paste_key(&loaded.config); } - let loaded_remote_image_paste_key = client_remote_image_paste_key(&loaded.config); - *sound_config = loaded.config.ui.sound; - *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; - *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor); - *remote_image_paste_key = loaded_remote_image_paste_key; debug!("reloaded local client config"); } Err(diagnostics) => { @@ -2078,264 +1842,6 @@ fn reload_local_client_config( } } -fn handle_notify( - kind: NotifyKind, - message: &str, - body: Option<&str>, - sound_config: &crate::config::SoundConfig, -) { - handle_notify_with_notifiers( - kind, - message, - body, - sound_config, - crate::terminal_notify::show_notification, - crate::platform::show_desktop_notification, - ); -} - -fn handle_notify_with_notifiers( - kind: NotifyKind, - message: &str, - body: Option<&str>, - sound_config: &crate::config::SoundConfig, - mut show_terminal_notification: impl FnMut(&str, Option<&str>) -> io::Result<bool>, - mut show_system_notification: impl FnMut(&str, Option<&str>) -> io::Result<bool>, -) { - 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 terminal toast notification from server" - ); - if let Err(err) = show_terminal_notification(message, body) { - warn!(err = %err, "failed to emit terminal notification"); - } - } - NotifyKind::SystemToast => { - debug!( - message = message, - "received system toast notification from server" - ); - if let Err(err) = show_system_notification(message, body) { - warn!(err = %err, "failed to emit system notification"); - } - } - } -} - -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, - } -} - -#[cfg(unix)] -fn should_bridge_clipboard_image_paste( - data: &[u8], - is_remote_client: bool, - remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, -) -> bool { - if data == b"\x1b[200~\x1b[201~" { - return is_remote_client; - } - - let Some(remote_image_paste_key) = remote_image_paste_key else { - return false; - }; - - let events = crate::raw_input::parse_raw_input_bytes_sync(data); - matches!( - events.as_slice(), - [crate::raw_input::RawInputEvent::Key(key)] - if key.kind == crossterm::event::KeyEventKind::Press - && crate::config::terminal_key_matches_combo(key, remote_image_paste_key) - ) -} - -#[cfg(any(windows, test))] -fn should_bridge_clipboard_image_events( - events: &[crate::protocol::ClientInputEvent], - is_remote_client: bool, - remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, -) -> bool { - if !is_remote_client { - return false; - } - if matches!( - events, - [crate::protocol::ClientInputEvent::Paste { text }] if text.is_empty() - ) { - return true; - } - - let Some(remote_image_paste_key) = remote_image_paste_key else { - return false; - }; - matches!( - events, - [event] - if matches!( - event.to_raw_input_event(), - crate::raw_input::RawInputEvent::Key(key) - if key.kind == crossterm::event::KeyEventKind::Press - && crate::config::terminal_key_matches_combo( - &key, - remote_image_paste_key, - ) - ) - ) -} - -#[cfg(unix)] -fn read_image_file_from_terminal_drop( - data: &[u8], - is_remote_client: bool, -) -> Option<crate::platform::ClipboardImage> { - let (path, extension) = image_path_from_terminal_drop(data, is_remote_client)?; - read_image_file(path, extension) -} - -#[cfg(any(windows, test))] -fn read_image_file_from_client_events( - events: &[crate::protocol::ClientInputEvent], - is_remote_client: bool, -) -> Option<crate::platform::ClipboardImage> { - let [crate::protocol::ClientInputEvent::Paste { text }] = events else { - return None; - }; - let text = normalized_terminal_drop_text(text)?; - // Windows events already carry native paths; Unix backslash unescaping would corrupt them. - let (path, extension) = - image_path_from_drop_text(strip_matching_path_quotes(text), is_remote_client)?; - read_image_file(path, extension) -} - -fn read_image_file( - path: std::path::PathBuf, - extension: &'static str, -) -> Option<crate::platform::ClipboardImage> { - let metadata = std::fs::metadata(&path).ok()?; - if !metadata.is_file() { - return None; - } - - let file = std::fs::File::open(&path).ok()?; - let bytes = - match crate::platform::read_limited_reader(file, MAX_CLIPBOARD_IMAGE_PAYLOAD).ok()? { - crate::platform::LimitedRead::Complete(bytes) => bytes, - crate::platform::LimitedRead::Empty => return None, - crate::platform::LimitedRead::Oversized => { - warn!( - max = MAX_CLIPBOARD_IMAGE_PAYLOAD, - "local image file drop is too large to bridge" - ); - return None; - } - }; - - Some(crate::platform::ClipboardImage { bytes, extension }) -} - -#[cfg(unix)] -fn image_path_from_terminal_drop( - data: &[u8], - is_remote_client: bool, -) -> Option<(std::path::PathBuf, &'static str)> { - let bytes = bracketed_paste_payload(data).unwrap_or(data); - let text = std::str::from_utf8(bytes).ok()?; - let text = normalized_terminal_drop_text(text)?; - let text = unescape_terminal_drop_path(strip_matching_path_quotes(text)); - image_path_from_drop_text(&text, is_remote_client) -} - -fn normalized_terminal_drop_text(text: &str) -> Option<&str> { - let text = text.trim_end_matches(['\r', '\n']); - (!text.is_empty() && !text.contains(['\r', '\n'])).then_some(text) -} - -fn image_path_from_drop_text( - text: &str, - is_remote_client: bool, -) -> Option<(std::path::PathBuf, &'static str)> { - if !is_remote_client { - return None; - } - let path = std::path::PathBuf::from(text); - if !path.is_absolute() { - return None; - } - let extension = recognized_image_extension(path.extension()?.to_str()?)?; - Some((path, extension)) -} - -#[cfg(unix)] -fn bracketed_paste_payload(data: &[u8]) -> Option<&[u8]> { - const START: &[u8] = b"\x1b[200~"; - const END: &[u8] = b"\x1b[201~"; - data.strip_prefix(START)?.strip_suffix(END) -} - -fn strip_matching_path_quotes(text: &str) -> &str { - if text.len() < 2 { - return text; - } - - let bytes = text.as_bytes(); - match (bytes.first(), bytes.last()) { - (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"')) => &text[1..text.len() - 1], - _ => text, - } -} - -#[cfg(unix)] -fn unescape_terminal_drop_path(text: &str) -> String { - let mut unescaped = String::with_capacity(text.len()); - let mut chars = text.chars(); - while let Some(ch) = chars.next() { - if ch == '\\' { - if let Some(escaped) = chars.next() { - unescaped.push(escaped); - } else { - unescaped.push(ch); - } - } else { - unescaped.push(ch); - } - } - unescaped -} - -fn recognized_image_extension(extension: &str) -> Option<&'static str> { - if extension.eq_ignore_ascii_case("png") { - Some("png") - } else if extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg") { - Some("jpg") - } else if extension.eq_ignore_ascii_case("gif") { - Some("gif") - } else if extension.eq_ignore_ascii_case("webp") { - Some("webp") - } else if extension.eq_ignore_ascii_case("bmp") { - Some("bmp") - } else { - None - } -} - // --------------------------------------------------------------------------- // Clipboard forwarding // --------------------------------------------------------------------------- @@ -2347,308 +1853,14 @@ fn decode_clipboard_payload(data: &str) -> Option<Vec<u8>> { } /// Forwards a clipboard write from the server to the local client clipboard. -fn forward_clipboard(data: &str) { +fn forward_clipboard(data: &str) -> bool { let Some(bytes) = decode_clipboard_payload(data) else { warn!("received invalid clipboard payload from server"); - return; + return false; }; crate::selection::write_osc52_bytes(&bytes); -} - -// --------------------------------------------------------------------------- -// Frame output -// --------------------------------------------------------------------------- - -fn write_encoded_frame_with_graphics( - mut writer: impl io::Write, - encoded: &[u8], - graphics: &[u8], -) -> io::Result<()> { - if graphics.is_empty() { - return writer.write_all(encoded); - } - - let insertion = render_ansi::final_sync_output_end(encoded).unwrap_or(encoded.len()); - - writer.write_all(&encoded[..insertion])?; - record_received_kitty_graphics(graphics); - writer.write_all(b"\x1b7")?; - writer.write_all(graphics)?; - writer.write_all(b"\x1b8")?; - writer.write_all(&encoded[insertion..]) -} - -fn contains_kitty_graphics_bytes(bytes: &[u8]) -> bool { - bytes.windows(3).any(|window| window == b"\x1b_G") -} - -fn record_received_kitty_graphics(bytes: &[u8]) { - let ids = kitty_graphics_image_ids(bytes); - if ids.is_empty() { - return; - } - let set = RECEIVED_KITTY_GRAPHICS_IDS.get_or_init(|| Mutex::new(HashSet::new())); - if let Ok(mut set) = set.lock() { - set.extend(ids); - } -} - -fn clear_received_kitty_graphics(mut writer: impl io::Write) -> io::Result<()> { - let Some(set) = RECEIVED_KITTY_GRAPHICS_IDS.get() else { - return Ok(()); - }; - let Ok(mut set) = set.lock() else { - return Ok(()); - }; - for id in set.drain() { - write!(writer, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\")?; - } - writer.flush() -} - -fn kitty_graphics_image_ids(bytes: &[u8]) -> Vec<u32> { - let mut ids = Vec::new(); - let mut index = 0usize; - while let Some(start) = find_subslice(&bytes[index..], b"\x1b_G") { - let command_start = index + start + 3; - let Some(end) = find_subslice(&bytes[command_start..], b"\x1b\\") else { - break; - }; - let command = &bytes[command_start..command_start + end]; - if let Some(id) = kitty_graphics_command_image_id(command) { - ids.push(id); - } - index = command_start + end + 2; - } - ids -} - -fn kitty_graphics_command_image_id(command: &[u8]) -> Option<u32> { - let header_end = command - .iter() - .position(|byte| *byte == b';') - .unwrap_or(command.len()); - for part in command[..header_end].split(|byte| *byte == b',') { - let Some(value) = part.strip_prefix(b"i=") else { - continue; - }; - let text = std::str::from_utf8(value).ok()?; - if let Ok(id) = text.parse::<u32>() { - return Some(id); - } - } - None -} - -fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> { - if needle.is_empty() || needle.len() > haystack.len() { - return None; - } - haystack - .windows(needle.len()) - .position(|window| window == needle) -} - -// --------------------------------------------------------------------------- -// Resize polling -// --------------------------------------------------------------------------- - -/// Cell size assumed when neither the terminal size ioctl nor the host -/// terminal reports pixel dimensions. -const DEFAULT_CELL_WIDTH_PX: u32 = 8; -const DEFAULT_CELL_HEIGHT_PX: u32 = 16; - -/// Cell size derived from the terminal size ioctl, if it reports pixels. -fn ioctl_cell_size() -> Option<(u32, u32)> { - let size = crossterm::terminal::window_size().ok()?; - if size.columns == 0 || size.rows == 0 || size.width == 0 || size.height == 0 { - return None; - } - Some(( - (size.width as u32 / size.columns as u32).max(1), - (size.height as u32 / size.rows as u32).max(1), - )) -} - -/// Cell size used when the ioctl reports no pixels. -fn cell_size_fallback(reported: u64, last: Option<(u32, u32)>) -> (u32, u32) { - unpack_cell_size(reported) - .or(last.filter(|(width, height)| *width > 0 && *height > 0)) - .unwrap_or((DEFAULT_CELL_WIDTH_PX, DEFAULT_CELL_HEIGHT_PX)) -} - -#[cfg(any(unix, test))] -fn pack_cell_size(width_px: u32, height_px: u32) -> u64 { - (u64::from(width_px) << 32) | u64::from(height_px) -} - -fn unpack_cell_size(packed: u64) -> Option<(u32, u32)> { - let width_px = (packed >> 32) as u32; - let height_px = (packed & u64::from(u32::MAX)) as u32; - (width_px > 0 && height_px > 0).then_some((width_px, height_px)) -} - -fn current_terminal_geometry( - kitty_graphics_enabled: bool, - reported_cell_size: &AtomicU64, - last_cell_size: Option<(u32, u32)>, -) -> (u16, u16, u32, u32) { - let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); - if !kitty_graphics_enabled { - return (cols, rows, 0, 0); - } - let (cell_width_px, cell_height_px) = ioctl_cell_size().unwrap_or_else(|| { - cell_size_fallback(reported_cell_size.load(Ordering::Acquire), last_cell_size) - }); - (cols, rows, cell_width_px, cell_height_px) -} - -/// Reads terminal geometry before the handshake. Direct graphics is eligible -/// only when the host supplied exact pixel dimensions through the ioctl. -fn initial_terminal_geometry(kitty_graphics_enabled: bool) -> (u16, u16, u32, u32, bool) { - let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); - if !kitty_graphics_enabled { - return (cols, rows, 0, 0, false); - } - match ioctl_cell_size() { - Some((width, height)) => (cols, rows, width, height, true), - None => ( - cols, - rows, - DEFAULT_CELL_WIDTH_PX, - DEFAULT_CELL_HEIGHT_PX, - false, - ), - } -} - -/// Reports polled changes and signalled resizes that return to the same size. -fn resize_report_required( - signalled: bool, - new_size: (u16, u16, u32, u32), - last_size: (u16, u16, u32, u32), -) -> bool { - signalled || new_size != last_size -} - -/// Watches the terminal size and sends resize events when it changes. -/// -/// The baseline cell size must match what the handshake sent to the server: -/// reading a fresh one here would race the host cell size reply and could -/// swallow the first change. -fn resize_poll_loop( - resize_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>, - initial_cols: u16, - initial_rows: u16, - initial_cell_width: u32, - initial_cell_height: u32, - kitty_graphics_enabled: bool, - reported_cell_size: &AtomicU64, - should_quit: &Arc<AtomicBool>, -) { - crate::platform::watch_terminal_resize_signal(); - let mut last_size = ( - initial_cols, - initial_rows, - initial_cell_width, - initial_cell_height, - ); - while !should_quit.load(Ordering::Acquire) { - std::thread::sleep(Duration::from_millis(100)); - let signalled = crate::platform::take_terminal_resize_signal(); - let new_size = current_terminal_geometry( - kitty_graphics_enabled, - reported_cell_size, - Some((last_size.2, last_size.3)), - ); - if resize_report_required(signalled, new_size, last_size) { - last_size = new_size; - if resize_tx - .blocking_send(ClientLoopEvent::Resize( - new_size.0, new_size.1, new_size.2, new_size.3, - )) - .is_err() - { - break; // Main loop gone. - } - } - } -} - -// --------------------------------------------------------------------------- -// Logging -// --------------------------------------------------------------------------- - -#[cfg(any(not(windows), test))] -fn query_host_terminal_appearance() { - let _ = write_host_terminal_appearance_query(io::stdout()); -} - -#[cfg(any(not(windows), test))] -fn write_host_terminal_appearance_query(mut writer: impl io::Write) -> io::Result<()> { - writer.write_all(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())?; - writer.flush() -} - -/// Initialize logging for the client process. -fn query_host_terminal_theme() { - let _ = write_host_terminal_theme_query(io::stdout()); -} - -fn should_query_host_terminal_theme() -> bool { - !cfg!(windows) -} - -fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> { - let query = crate::terminal_theme::host_terminal_theme_query_sequence( - crate::platform::should_query_host_terminal_palette(), - ); - writer.write_all(query.as_bytes())?; - writer.flush() -} - -/// XTWINOPS request for the host terminal cell size in pixels. -const HOST_CELL_SIZE_QUERY: &[u8] = b"\x1b[16t"; - -fn query_host_cell_size() { - let _ = write_host_cell_size_query(io::stdout()); -} - -fn should_query_host_cell_size() -> bool { - !cfg!(windows) -} - -/// Only pane graphics need pixel dimensions, and only when the ioctl cannot -/// provide them. -fn host_cell_size_query_required(kitty_graphics_enabled: bool) -> bool { - kitty_graphics_enabled && should_query_host_cell_size() && ioctl_cell_size().is_none() -} - -fn write_host_cell_size_query(mut writer: impl io::Write) -> io::Result<()> { - writer.write_all(HOST_CELL_SIZE_QUERY)?; - writer.flush() -} - -#[cfg(any(unix, test))] -fn store_reported_cell_size(reported_cell_size: &AtomicU64, width_px: u32, height_px: u32) { - let packed = pack_cell_size(width_px, height_px); - if reported_cell_size.swap(packed, Ordering::AcqRel) != packed { - debug!(width_px, height_px, "host terminal reported cell size"); - } -} - -#[cfg(any(unix, test))] -fn reported_cell_size_from_events( - events: &[crate::raw_input::RawInputEvent], -) -> Option<(u32, u32)> { - events.iter().rev().find_map(|event| match event { - crate::raw_input::RawInputEvent::HostCellSizeReport { - width_px, - height_px, - } => Some((*width_px, *height_px)), - _ => None, - }) + true } fn init_logging() { @@ -2660,991 +1872,4 @@ fn init_logging() { // --------------------------------------------------------------------------- #[cfg(test)] -mod tests { - use super::*; - use std::ffi::OsString; - use std::sync::{Mutex, OnceLock}; - - fn env_lock() -> &'static Mutex<()> { - static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - - #[test] - fn resize_signal_reports_even_when_polled_size_is_unchanged() { - let size = (120, 40, 8, 16); - assert!(resize_report_required(true, size, size)); - assert!(!resize_report_required(false, size, size)); - assert!(resize_report_required(false, (120, 41, 8, 16), size)); - assert!(resize_report_required(false, (120, 40, 9, 18), size)); - } - - #[test] - fn approximate_cell_size_never_enables_direct_graphics() { - assert_eq!( - client_launch_mode(false, false, 8, 16), - ClientLaunchMode::App - ); - assert_eq!( - client_launch_mode(true, false, 8, 16), - ClientLaunchMode::TerminalAttach - ); - } - - #[test] - fn direct_graphics_profile_is_narrow_and_transport_safe() { - for (program, term, kitty, expected) in [ - ("ghostty", "", false, true), - ("WezTerm", "", false, true), - ("", "xterm-kitty", false, true), - ("", "xterm-256color", true, true), - ("", "xterm-256color", false, false), - ] { - assert_eq!( - direct_graphics_profile_values(program, term, kitty, false, true), - expected - ); - } - assert!(!direct_graphics_profile_values( - "ghostty", "", false, true, true - )); - assert!(!direct_graphics_profile_values( - "ghostty", "", false, false, false - )); - } - - fn restore_env_var(key: &str, value: Option<OsString>) { - if let Some(value) = value { - std::env::set_var(key, value); - } else { - std::env::remove_var(key); - } - } - - struct EnvVarGuard { - key: &'static str, - previous: Option<OsString>, - } - - impl EnvVarGuard { - fn set(key: &'static str, value: &str) -> Self { - let previous = std::env::var_os(key); - std::env::set_var(key, value); - Self { key, previous } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - restore_env_var(self.key, self.previous.clone()); - } - } - - #[test] - fn windows_virtual_terminal_input_mode_sets_only_vti_bit() { - assert_eq!(windows_virtual_terminal_input_mode(0x01f0), 0x03f0); - assert_eq!(windows_virtual_terminal_input_mode(0x03f0), 0x03f0); - } - - struct EnvVarsRemovedGuard { - previous: Vec<(&'static str, Option<OsString>)>, - } - - impl EnvVarsRemovedGuard { - fn new(keys: &[&'static str]) -> Self { - let previous: Vec<_> = keys - .iter() - .map(|key| (*key, std::env::var_os(key))) - .collect(); - for key in keys { - std::env::remove_var(key); - } - Self { previous } - } - } - - impl Drop for EnvVarsRemovedGuard { - fn drop(&mut self) { - for (key, value) in self.previous.clone() { - restore_env_var(key, value); - } - } - } - - #[test] - fn remote_client_uses_extended_handshake_timeout() { - let _guard = env_lock().lock().unwrap(); - let _remote = EnvVarGuard::set(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR, "local"); - - assert_eq!(handshake_read_timeout(), REMOTE_HANDSHAKE_READ_TIMEOUT); - } - - #[test] - fn host_cursor_policy_auto_uses_platform_default() { - assert_eq!( - should_draw_host_cursor(crate::config::HostCursorModeConfig::Auto), - crate::platform::should_draw_host_cursor_by_default() - ); - } - - #[test] - fn host_cursor_policy_native_and_drawn_override_auto_detection() { - let _guard = env_lock().lock().unwrap(); - let _env = EnvVarGuard::set("TERM_PROGRAM", "WezTerm"); - - assert!(!should_draw_host_cursor( - crate::config::HostCursorModeConfig::Native - )); - assert!(should_draw_host_cursor( - crate::config::HostCursorModeConfig::Drawn - )); - } - - #[cfg(unix)] - #[test] - fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() { - let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap(); - assert!(should_bridge_clipboard_image_paste( - &[0x16], - true, - Some(ctrl_v) - )); - assert!(should_bridge_clipboard_image_paste( - b"\x1b[118;5u", - true, - Some(ctrl_v) - )); - assert!(should_bridge_clipboard_image_paste( - b"\x1b[200~\x1b[201~", - true, - None - )); - assert!(!should_bridge_clipboard_image_paste( - b"\x1b[200~\x1b[201~", - false, - Some(ctrl_v) - )); - assert!(!should_bridge_clipboard_image_paste( - b"\x1b[200~text\x1b[201~", - true, - Some(ctrl_v) - )); - assert!(!should_bridge_clipboard_image_paste(&[0x16], true, None)); - assert!(!should_bridge_clipboard_image_paste( - b"v", - true, - Some(ctrl_v) - )); - } - - struct TempImageFile { - path: std::path::PathBuf, - } - - impl TempImageFile { - fn new(extension: &str, bytes: &[u8]) -> Self { - Self::with_name_fragment("test", extension, bytes) - } - - fn with_name_fragment(name_fragment: &str, extension: &str, bytes: &[u8]) -> Self { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = std::env::temp_dir().join(format!( - "herdr-client-drop-{name_fragment}-{}-{nanos}.{extension}", - std::process::id() - )); - std::fs::write(&path, bytes).unwrap(); - Self { path } - } - } - - impl Drop for TempImageFile { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.path); - } - } - - #[test] - fn clipboard_image_event_bridge_matches_remote_key_and_empty_paste() { - let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap(); - let key = crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('v'), - modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }; - let empty_paste = crate::protocol::ClientInputEvent::Paste { - text: String::new(), - }; - - assert!(should_bridge_clipboard_image_events( - std::slice::from_ref(&key), - true, - Some(ctrl_v), - )); - assert!(should_bridge_clipboard_image_events( - std::slice::from_ref(&empty_paste), - true, - None, - )); - assert!(!should_bridge_clipboard_image_events( - &[key], - false, - Some(ctrl_v), - )); - assert!(!should_bridge_clipboard_image_events( - &[crate::protocol::ClientInputEvent::Paste { - text: "text".to_string(), - }], - true, - Some(ctrl_v), - )); - } - - #[test] - fn remote_image_file_drop_bridge_reads_semantic_paste_path() { - let file = TempImageFile::new("PNG", b"image-bytes"); - let events = [crate::protocol::ClientInputEvent::Paste { - text: format!("\"{}\"", file.path.display()), - }]; - - let image = read_image_file_from_client_events(&events, true).unwrap(); - - assert_eq!(image.extension, "png"); - assert_eq!(image.bytes, b"image-bytes"); - } - - #[cfg(unix)] - #[test] - fn remote_image_file_drop_bridge_reads_bracketed_absolute_image_path() { - let file = TempImageFile::new("PNG", b"image-bytes"); - let input = format!("\x1b[200~{}\x1b[201~", file.path.display()); - - let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap(); - - assert_eq!(image.extension, "png"); - assert_eq!(image.bytes, b"image-bytes"); - } - - #[cfg(unix)] - #[test] - fn remote_image_file_drop_bridge_reads_plain_quoted_path_with_newline() { - let file = TempImageFile::new("jpeg", b"jpeg-bytes"); - let input = format!("'{}'\n", file.path.display()); - - let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap(); - - assert_eq!(image.extension, "jpg"); - assert_eq!(image.bytes, b"jpeg-bytes"); - } - - #[cfg(unix)] - #[test] - fn remote_image_file_drop_bridge_unescapes_spaces_in_paths() { - let file = TempImageFile::with_name_fragment("space test", "png", b"image-bytes"); - let escaped_path = file.path.display().to_string().replace(' ', "\\ "); - - let image = read_image_file_from_terminal_drop(escaped_path.as_bytes(), true).unwrap(); - - assert_eq!(image.extension, "png"); - assert_eq!(image.bytes, b"image-bytes"); - } - - #[cfg(unix)] - #[test] - fn remote_image_file_drop_bridge_ignores_non_remote_and_non_image_input() { - let file = TempImageFile::new("png", b"image-bytes"); - let path = file.path.display().to_string(); - - assert!(read_image_file_from_terminal_drop(path.as_bytes(), false).is_none()); - assert!(read_image_file_from_terminal_drop(b"relative.png\n", true).is_none()); - assert!(read_image_file_from_terminal_drop(b"/tmp/file.txt\n", true).is_none()); - assert!(read_image_file_from_terminal_drop( - format!("{}\nextra", file.path.display()).as_bytes(), - true - ) - .is_none()); - } - - #[test] - fn graphics_bytes_are_written_inside_synchronized_blit_with_saved_cursor() { - let mut output = Vec::new(); - write_encoded_frame_with_graphics( - &mut output, - b"\x1b[?2026htext\x1b[?2026lcursor", - b"graphics", - ) - .unwrap(); - - assert_eq!( - output, - b"\x1b[?2026htext\x1b7graphics\x1b8\x1b[?2026lcursor" - ); - } - - #[test] - fn empty_graphics_writes_only_blit_frame() { - let mut output = Vec::new(); - write_encoded_frame_with_graphics(&mut output, b"text", b"").unwrap(); - - assert_eq!(output, b"text"); - } - - #[test] - fn terminal_frame_kitty_detection_matches_apc_prefix() { - assert!(contains_kitty_graphics_bytes(b"text\x1b_Ga=p;\x1b\\")); - assert!(!contains_kitty_graphics_bytes(b"text\x1b[?2026h")); - } - - #[test] - fn kitty_graphics_image_id_parser_tracks_herdr_ids_only() { - let ids = kitty_graphics_image_ids( - b"text\x1b_Ga=t,t=d,f=32,s=1,v=1,i=10023,q=2;AAAA\x1b\\\x1b_Ga=p,i=10023,p=7;\x1b\\", - ); - assert_eq!(ids, vec![10023, 10023]); - } - - #[test] - fn kitty_graphics_cleanup_deletes_tracked_images_not_all_images() { - record_received_kitty_graphics(b"\x1b_Ga=t,i=123,q=2;AAAA\x1b\\"); - let mut output = Vec::new(); - clear_received_kitty_graphics(&mut output).unwrap(); - let text = String::from_utf8(output).unwrap(); - assert!(text.contains("a=d,d=I,i=123")); - assert!(!text.contains("d=A")); - } - - #[test] - fn write_host_terminal_appearance_query_emits_mode_2031_query() { - let mut output = Vec::new(); - write_host_terminal_appearance_query(&mut output).unwrap(); - assert_eq!(output, b"\x1b[?996n"); - } - - #[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_terminal_theme_query_sequence( - crate::platform::should_query_host_terminal_palette(), - ) - .as_bytes() - ); - assert!(!output - .windows(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.len()) - .any(|window| window - == crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())); - } - - #[test] - fn write_host_color_scheme_report_mode_emits_mode_sequences() { - let mut output = Vec::new(); - write_host_color_scheme_report_mode(&mut output, true).unwrap(); - write_host_color_scheme_report_mode(&mut output, false).unwrap(); - - let mut expected = Vec::new(); - expected.extend_from_slice( - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE.as_bytes(), - ); - expected.extend_from_slice( - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), - ); - assert_eq!(output, expected); - } - - #[test] - fn color_scheme_change_event_requests_host_theme_query() { - let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); - - assert!(crate::raw_input::events_require_host_terminal_theme_query( - &events - )); - } - - #[test] - fn host_terminal_theme_query_is_disabled_on_windows() { - assert_eq!(should_query_host_terminal_theme(), !cfg!(windows)); - } - - #[test] - fn write_host_cell_size_query_emits_xtwinops_request() { - let mut output = Vec::new(); - write_host_cell_size_query(&mut output).unwrap(); - - assert_eq!(output, b"\x1b[16t"); - } - - #[test] - fn host_cell_size_query_is_disabled_on_windows() { - assert_eq!(should_query_host_cell_size(), !cfg!(windows)); - } - - #[test] - fn cell_size_fallback_prefers_reported_then_previous_size() { - assert_eq!(cell_size_fallback(0, None), (8, 16)); - assert_eq!(cell_size_fallback(0, Some((11, 22))), (11, 22)); - assert_eq!( - cell_size_fallback(pack_cell_size(10, 21), Some((11, 22))), - (10, 21) - ); - assert_eq!(cell_size_fallback(pack_cell_size(10, 0), None), (8, 16)); - assert_eq!(cell_size_fallback(pack_cell_size(0, 21), None), (8, 16)); - } - - #[test] - fn reported_cell_size_is_taken_from_host_cell_size_events() { - let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); - assert_eq!(reported_cell_size_from_events(&events), None); - - let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[6;21;10t\x1b[6;18;9t"); - assert_eq!(reported_cell_size_from_events(&events), Some((9, 18))); - } - - #[test] - fn color_scheme_reports_are_enabled_only_for_full_clients() { - assert_eq!( - should_enable_host_color_scheme_reports(true), - !cfg!(windows) - ); - assert!(!should_enable_host_color_scheme_reports(false)); - } - - #[test] - fn terminal_restore_postlude_restores_visible_default_cursor() { - let mut output = Vec::new(); - write_terminal_restore_postlude(&mut output, false).unwrap(); - assert_eq!(output, b"\x1b[?25h\x1b[0 q"); - } - - #[test] - fn terminal_restore_postlude_disables_color_scheme_reports_when_enabled() { - let mut output = Vec::new(); - write_terminal_restore_postlude(&mut output, true).unwrap(); - - let mut expected = Vec::new(); - expected.extend_from_slice( - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), - ); - expected.extend_from_slice(b"\x1b[?25h\x1b[0 q"); - assert_eq!(output, expected); - } - - #[cfg(unix)] - #[test] - fn attach_escape_detaches_on_prefix_q() { - let mut escape = AttachEscapeState::default(); - assert!(matches!( - escape.filter_input(vec![0x02], 24, 3), - AttachInputAction::None - )); - assert!(matches!( - escape.filter_input(vec![b'q'], 24, 3), - AttachInputAction::Detach - )); - } - - #[cfg(unix)] - #[test] - fn attach_escape_sends_literal_prefix_on_double_prefix() { - let mut escape = AttachEscapeState::default(); - assert!(matches!( - escape.filter_input(vec![0x02], 24, 3), - AttachInputAction::None - )); - match escape.filter_input(vec![0x02], 24, 3) { - AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02]), - other => panic!("expected forwarded prefix, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_does_not_interpret_bracketed_paste_contents() { - let mut escape = AttachEscapeState::default(); - let paste = b"\x1b[200~one\x02q\ntwo\x1b[201~".to_vec(); - - match escape.filter_input(paste.clone(), 24, 3) { - AttachInputAction::Forward(bytes) => assert_eq!(bytes, paste), - other => panic!("expected opaque paste, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_flushes_pending_prefix_before_bracketed_paste() { - let mut escape = AttachEscapeState::default(); - let paste = b"\x1b[200~one\ntwo\x1b[201~".to_vec(); - assert!(matches!( - escape.filter_input(vec![0x02], 24, 3), - AttachInputAction::None - )); - - assert!(matches!( - escape.filter_input(paste.clone(), 24, 3), - AttachInputAction::ForwardAfterPendingPrefix(bytes) if bytes == paste - )); - assert!(matches!( - escape.filter_input(vec![b'q'], 24, 3), - AttachInputAction::Forward(bytes) if bytes == b"q" - )); - } - - #[cfg(unix)] - #[test] - fn attach_escape_forwards_prefix_before_non_escape_key() { - let mut escape = AttachEscapeState::default(); - assert!(matches!( - escape.filter_input(vec![b'a', 0x02], 24, 3), - AttachInputAction::Forward(bytes) if bytes == b"a" - )); - match escape.filter_input(vec![b'x'], 24, 3) { - AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02, b'x']), - other => panic!("expected forwarded bytes, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_turns_wheel_into_scroll_action() { - let mut escape = AttachEscapeState::default(); - match escape.filter_input(b"\x1b[<64;11;6M".to_vec(), 24, 7) { - AttachInputAction::Scroll { - source, - direction, - lines, - column, - row, - .. - } => { - assert_eq!(source, AttachScrollSource::Wheel); - assert_eq!(direction, AttachScrollDirection::Up); - assert_eq!(lines, 7); - assert_eq!(column, Some(10)); - assert_eq!(row, Some(5)); - } - other => panic!("expected scroll action, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_swallows_non_wheel_mouse_reports() { - let mut escape = AttachEscapeState::default(); - assert!(matches!( - escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7), - AttachInputAction::None - )); - } - - #[cfg(unix)] - #[test] - fn attach_escape_turns_plain_page_keys_into_scroll_actions() { - let mut escape = AttachEscapeState::default(); - match escape.filter_input(b"\x1b[5~".to_vec(), 12, 3) { - AttachInputAction::Scroll { - source, - direction, - lines, - .. - } => { - assert_eq!( - source, - AttachScrollSource::PageKey { - input: b"\x1b[5~".to_vec() - } - ); - assert_eq!(direction, AttachScrollDirection::Up); - assert_eq!(lines, 11); - } - other => panic!("expected page-up scroll action, got {other:?}"), - } - - match escape.filter_input(b"\x1b[6~".to_vec(), 12, 3) { - AttachInputAction::Scroll { - source, - direction, - lines, - .. - } => { - assert_eq!( - source, - AttachScrollSource::PageKey { - input: b"\x1b[6~".to_vec() - } - ); - assert_eq!(direction, AttachScrollDirection::Down); - assert_eq!(lines, 11); - } - other => panic!("expected page-down scroll action, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_forwards_modified_page_key() { - let mut escape = AttachEscapeState::default(); - match escape.filter_input(b"\x1b[5;5~".to_vec(), 12, 3) { - AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[5;5~"), - other => panic!("expected modified page key to forward, got {other:?}"), - } - } - - #[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_detached_default_session_reattach_hint() { - let _guard = env_lock().lock().unwrap(); - let _env = EnvVarsRemovedGuard::new(&[ - crate::remote::REATTACH_COMMAND_ENV_VAR, - crate::session::SESSION_ENV_VAR, - ]); - let err = ClientError::ServerShutdown { - reason: Some("detached".into()), - }; - let msg = err.to_string(); - assert!( - msg.contains("Run `herdr` to reattach"), - "should suggest default reattach command: {msg}" - ); - } - - #[test] - fn client_error_display_detached_named_session_reattach_hint() { - let _guard = env_lock().lock().unwrap(); - let _remote_env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]); - let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work"); - let err = ClientError::ServerShutdown { - reason: Some("detached".into()), - }; - let msg = err.to_string(); - assert!( - msg.contains("Run `herdr session attach work` to reattach"), - "should suggest named session reattach command: {msg}" - ); - } - - #[test] - fn client_error_display_detached_remote_reattach_hint_takes_precedence() { - let _guard = env_lock().lock().unwrap(); - let _remote_env = EnvVarGuard::set( - crate::remote::REATTACH_COMMAND_ENV_VAR, - "herdr --remote host --session work", - ); - let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work"); - let err = ClientError::ServerShutdown { - reason: Some("detached".into()), - }; - let msg = err.to_string(); - assert!( - msg.contains("Run `herdr --remote host --session work` to reattach"), - "should prefer remote reattach command: {msg}" - ); - } - - #[test] - fn client_error_display_connection_lost() { - let _guard = env_lock().lock().unwrap(); - let _env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]); - 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 client_error_display_remote_connection_lost_has_reattach_hint() { - let _guard = env_lock().lock().unwrap(); - let _remote_env = EnvVarGuard::set( - crate::remote::REATTACH_COMMAND_ENV_VAR, - "herdr --remote host --session work", - ); - let err = - ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")); - let msg = err.to_string(); - assert!( - msg.contains("lost connection to remote Herdr"), - "should mention remote connection loss: {msg}" - ); - assert!( - msg.contains("panes may still be running"), - "should explain possible persistence: {msg}" - ); - assert!( - msg.contains("Run `herdr --remote host --session work` to reattach"), - "should show remote reattach command: {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 reload_local_client_config_refreshes_local_client_presentation_state() { - let _guard = crate::config::test_config_env_lock().lock().unwrap(); - let path = std::env::temp_dir().join(format!( - "herdr-client-config-reload-{}-{}.toml", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::write( - &path, - "[ui]\nredraw_on_focus_gained = false\nhost_cursor = \"drawn\"\n", - ) - .unwrap(); - let path_string = path.to_string_lossy().to_string(); - let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string); - let mut sound_config = crate::config::SoundConfig::default(); - let mut redraw_on_focus_gained = true; - let mut draw_host_cursor = false; - let mut remote_image_paste_key = None; - - reload_local_client_config( - &mut sound_config, - &mut redraw_on_focus_gained, - &mut draw_host_cursor, - &mut remote_image_paste_key, - ); - - assert!(!redraw_on_focus_gained); - assert!(draw_host_cursor); - let _ = std::fs::remove_file(path); - } - - #[test] - fn toast_notify_from_server_is_emitted_even_when_attach_config_was_off() { - let sound_config = crate::config::SoundConfig::default(); - let mut emitted = None; - - handle_notify_with_notifiers( - NotifyKind::Toast, - "pi finished", - Some("workspace 1"), - &sound_config, - |title, body| { - emitted = Some((title.to_string(), body.map(str::to_string))); - Ok(true) - }, - |_, _| Ok(false), - ); - - assert_eq!( - emitted, - Some(("pi finished".to_string(), Some("workspace 1".to_string()))) - ); - } - - #[test] - fn system_toast_notify_from_server_uses_system_notifier() { - let sound_config = crate::config::SoundConfig::default(); - let mut emitted = None; - - handle_notify_with_notifiers( - NotifyKind::SystemToast, - "pi finished", - Some("workspace 1"), - &sound_config, - |_, _| Ok(false), - |title, body| { - emitted = Some((title.to_string(), body.map(str::to_string))); - Ok(true) - }, - ); - - assert_eq!( - emitted, - Some(("pi finished".to_string(), Some("workspace 1".to_string()))) - ); - } - - #[test] - fn system_toast_notify_preserves_colon_in_title() { - let sound_config = crate::config::SoundConfig::default(); - let mut emitted = None; - - handle_notify_with_notifiers( - NotifyKind::SystemToast, - "build: failed", - Some("api workspace"), - &sound_config, - |_, _| Ok(false), - |title, body| { - emitted = Some((title.to_string(), body.map(str::to_string))); - Ok(true) - }, - ); - - assert_eq!( - emitted, - Some(( - "build: failed".to_string(), - Some("api workspace".to_string()) - )) - ); - } - - #[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 terminal_control_input_command_accepts_text() { - let action = - terminal_control_command_from_json(r#"{"type":"terminal.input","text":"hello"}"#) - .unwrap(); - let ClientMessage::Input { data } = action else { - panic!("expected input command"); - }; - assert_eq!(data, b"hello"); - } - - #[test] - fn terminal_control_input_command_accepts_base64_bytes() { - let action = - terminal_control_command_from_json(r#"{"type":"terminal.input","bytes":"G1tB"}"#) - .unwrap(); - let ClientMessage::Input { data } = action else { - panic!("expected input command"); - }; - assert_eq!(data, b"\x1b[A"); - } - - #[test] - fn terminal_control_resize_command_maps_to_client_resize() { - let action = terminal_control_command_from_json( - r#"{"type":"terminal.resize","cols":100,"rows":30,"cell_width_px":8,"cell_height_px":16}"#, - ) - .unwrap(); - let ClientMessage::Resize { - cols, - rows, - cell_width_px, - cell_height_px, - } = action - else { - panic!("expected resize command"); - }; - assert_eq!( - (cols, rows, cell_width_px, cell_height_px), - (100, 30, 8, 16) - ); - } - - #[test] - fn terminal_control_scroll_command_maps_to_attach_scroll() { - let action = terminal_control_command_from_json( - r#"{"type":"terminal.scroll","direction":"up","lines":3}"#, - ) - .unwrap(); - let ClientMessage::AttachScroll { - source, - direction, - lines, - .. - } = action - else { - panic!("expected scroll command"); - }; - assert_eq!(source, AttachScrollSource::Wheel); - assert_eq!(direction, AttachScrollDirection::Up); - assert_eq!(lines, 3); - } - - #[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"); - } - } -} +mod tests; diff --git a/src/client/notifications.rs b/src/client/notifications.rs new file mode 100644 index 00000000..7c97742e --- /dev/null +++ b/src/client/notifications.rs @@ -0,0 +1,102 @@ +use std::io; + +use tracing::{debug, warn}; + +use crate::protocol::NotifyKind; + +use super::shell; + +pub(super) fn handle_shell_notification_effects( + effects: Vec<shell::ClientShellNotificationEffect>, + sound_config: &crate::config::SoundConfig, +) { + for effect in effects { + match effect { + shell::ClientShellNotificationEffect::Sound { sound, agent } => { + let agent = agent.as_deref().and_then(crate::detect::parse_agent_label); + if sound_config.allows(agent) { + crate::sound::play(sound, sound_config); + } + } + shell::ClientShellNotificationEffect::Terminal { title, body } => { + if let Err(err) = crate::terminal_notify::show_notification(&title, body.as_deref()) + { + warn!(err = %err, "failed to emit terminal notification"); + } + } + shell::ClientShellNotificationEffect::System { title, body } => { + if let Err(err) = + crate::platform::show_desktop_notification(&title, body.as_deref()) + { + warn!(err = %err, "failed to emit system notification"); + } + } + } + } +} + +pub(super) fn handle_notify( + kind: NotifyKind, + message: &str, + body: Option<&str>, + sound_config: &crate::config::SoundConfig, +) { + handle_notify_with_notifiers( + kind, + message, + body, + sound_config, + crate::terminal_notify::show_notification, + crate::platform::show_desktop_notification, + ); +} + +pub(super) fn handle_notify_with_notifiers( + kind: NotifyKind, + message: &str, + body: Option<&str>, + sound_config: &crate::config::SoundConfig, + mut show_terminal_notification: impl FnMut(&str, Option<&str>) -> io::Result<bool>, + mut show_system_notification: impl FnMut(&str, Option<&str>) -> io::Result<bool>, +) { + 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 terminal toast notification from server" + ); + if let Err(err) = show_terminal_notification(message, body) { + warn!(err = %err, "failed to emit terminal notification"); + } + } + NotifyKind::SystemToast => { + debug!( + message = message, + "received system toast notification from server" + ); + if let Err(err) = show_system_notification(message, body) { + warn!(err = %err, "failed to emit system notification"); + } + } + } +} + +pub(super) 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, + } +} diff --git a/src/client/shell.rs b/src/client/shell.rs new file mode 100644 index 00000000..9a877744 --- /dev/null +++ b/src/client/shell.rs @@ -0,0 +1,287 @@ +use std::collections::{HashMap, HashSet, VecDeque}; + +mod actions; +mod agent_sidebar; +mod composition; +mod config; +mod context_menu; +mod copy_mode; +mod global_menu; +mod graphics; +mod input; +mod mobile; +mod mouse; +mod notifications; +mod overlay_input; +mod preferences; +mod render; +mod scroll; +mod settings; +mod state; +mod surface_patch; +mod worktrees; + +pub(crate) use state::*; +#[cfg(test)] +pub(super) use surface_patch::apply_composed_surface_patch; +pub(super) use surface_patch::{ClientComposedSurfacePatch, ClientPaneSurfacePatchOutcome}; + +use crossterm::event::KeyCode; +#[cfg(test)] +use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use unicode_width::UnicodeWidthStr; + +use crate::app::state::Palette; +use crate::config::{ + Config, LiveKeybindConfig, SidebarCollapsedModeConfig, SpacesSidebarConfig, + TabBarPositionConfig, +}; +use crate::protocol::{ + ClientMessage, ClientMousePosition, ClientPaneInputEvent, ClientShellSnapshot, ClientShellTab, + ClientShellWorkspace, ClientSurfaceSize, FrameData, PaneSurfaceFrame, SemanticNotification, + SemanticNotificationKind, SemanticNotificationSound, +}; +#[cfg(test)] +use crate::raw_input::RawInputEvent; + +fn delete_overlay_word(rename: &mut ClientRenameOverlay) { + if rename.replace_on_type { + rename.input.clear(); + rename.replace_on_type = false; + return; + } + while rename.input.chars().last().is_some_and(char::is_whitespace) { + rename.input.pop(); + } + let Some(word) = rename + .input + .chars() + .last() + .map(|character| character.is_alphanumeric() || character == '_') + else { + return; + }; + while rename.input.chars().last().is_some_and(|character| { + !character.is_whitespace() && (character.is_alphanumeric() || character == '_') == word + }) { + rename.input.pop(); + } +} + +fn target_event_message(target: ClientInputTarget, event: ClientPaneInputEvent) -> ClientMessage { + match target { + ClientInputTarget::Pane(pane_id) => ClientMessage::ClientShellPaneInput { + pane_id, + events: vec![event], + }, + ClientInputTarget::Popup(terminal_id) => ClientMessage::ClientShellPopupInput { + terminal_id, + events: vec![event], + }, + } +} + +fn push_target_event( + target: ClientInputTarget, + event: ClientPaneInputEvent, + outcome: &mut ClientShellInput, +) { + match target { + ClientInputTarget::Pane(pane_id) => { + if let Some(ClientMessage::ClientShellPaneInput { + pane_id: pending_pane, + events, + }) = outcome.requests.last_mut() + { + if *pending_pane == pane_id { + events.push(event); + return; + } + } + outcome.requests.push(target_event_message( + ClientInputTarget::Pane(pane_id), + event, + )); + } + ClientInputTarget::Popup(terminal_id) => { + if let Some(ClientMessage::ClientShellPopupInput { + terminal_id: pending_terminal, + events, + }) = outcome.requests.last_mut() + { + if *pending_terminal == terminal_id { + events.push(event); + return; + } + } + outcome.requests.push(target_event_message( + ClientInputTarget::Popup(terminal_id), + event, + )); + } + } +} + +fn contains(rect: Rect, point: (u16, u16)) -> bool { + rect.width > 0 + && rect.height > 0 + && point.0 >= rect.x + && point.0 < rect.right() + && point.1 >= rect.y + && point.1 < rect.bottom() +} + +fn pane_surface_topology_signature(surface: &PaneSurfaceFrame) -> u64 { + const OFFSET: u64 = 0xcbf2_9ce4_8422_2325; + const PRIME: u64 = 0x0000_0100_0000_01b3; + + fn write(hash: &mut u64, bytes: &[u8]) { + for byte in bytes { + *hash ^= u64::from(*byte); + *hash = hash.wrapping_mul(PRIME); + } + *hash ^= 0xff; + *hash = hash.wrapping_mul(PRIME); + } + + let mut pane_ids = surface + .panes + .iter() + .map(|pane| pane.pane_id.as_bytes()) + .collect::<Vec<_>>(); + pane_ids.sort_unstable(); + let mut hash = OFFSET; + for pane_id in pane_ids { + write(&mut hash, pane_id); + } + let mut splits = surface.splits.iter().collect::<Vec<_>>(); + splits.sort_by(|left, right| left.path.cmp(&right.path)); + for split in splits { + write( + &mut hash, + &[match split.direction { + crate::protocol::PaneSurfaceSplitDirection::Horizontal => 0, + crate::protocol::PaneSurfaceSplitDirection::Vertical => 1, + }], + ); + write( + &mut hash, + &split + .path + .iter() + .map(|right| u8::from(*right)) + .collect::<Vec<_>>(), + ); + } + hash +} + +fn status_icon( + status: crate::api::schema::AgentStatus, + style: crate::config::StatusIndicatorStyle, +) -> &'static str { + use crate::api::schema::AgentStatus; + use crate::config::StatusIndicatorStyle; + match (style, status) { + ( + StatusIndicatorStyle::Dots, + AgentStatus::Working | AgentStatus::Blocked | AgentStatus::Done, + ) => "●", + (StatusIndicatorStyle::Dots, AgentStatus::Idle) => "○", + (StatusIndicatorStyle::Dots, AgentStatus::Unknown) => "·", + (StatusIndicatorStyle::Symbols, AgentStatus::Blocked) => "×", + (StatusIndicatorStyle::Symbols, AgentStatus::Working) => "◐", + (StatusIndicatorStyle::Symbols, AgentStatus::Done) => "✓", + (StatusIndicatorStyle::Symbols, AgentStatus::Idle) => "○", + (StatusIndicatorStyle::Symbols, AgentStatus::Unknown) => "·", + } +} + +fn status_dot(status: crate::api::schema::AgentStatus) -> &'static str { + status_icon(status, crate::config::StatusIndicatorStyle::Dots) +} + +fn status_priority(status: crate::api::schema::AgentStatus) -> u8 { + use crate::api::schema::AgentStatus; + match status { + AgentStatus::Blocked => 4, + AgentStatus::Done => 3, + AgentStatus::Working => 2, + AgentStatus::Idle => 1, + AgentStatus::Unknown => 0, + } +} + +fn status_text(status: crate::api::schema::AgentStatus) -> &'static str { + use crate::api::schema::AgentStatus; + match status { + AgentStatus::Working => "working", + AgentStatus::Blocked => "blocked", + AgentStatus::Done => "done", + AgentStatus::Idle => "idle", + AgentStatus::Unknown => "unknown", + } +} + +fn status_color( + status: crate::api::schema::AgentStatus, + palette: &Palette, +) -> ratatui::style::Color { + use crate::api::schema::AgentStatus; + match status { + AgentStatus::Working => palette.yellow, + AgentStatus::Blocked => palette.red, + AgentStatus::Done => palette.teal, + AgentStatus::Idle => palette.green, + AgentStatus::Unknown => palette.overlay0, + } +} + +fn panel_contrast_fg(palette: &Palette) -> ratatui::style::Color { + match palette.panel_bg { + ratatui::style::Color::Reset => palette.surface_dim, + color => color, + } +} + +fn blit_pane_surface(target: &mut FrameData, source: &FrameData, area: Rect) { + let copy_width = source.width.min(area.width); + let copy_height = source.height.min(area.height); + let hyperlink_base = target.hyperlinks.len() as u32; + target.hyperlinks.extend(source.hyperlinks.iter().cloned()); + + for row in 0..copy_height { + for col in 0..copy_width { + let source_index = row as usize * source.width as usize + col as usize; + let target_x = area.x + col; + let target_y = area.y + row; + let target_index = target_y as usize * target.width as usize + target_x as usize; + let (Some(source_cell), Some(target_cell)) = ( + source.cells.get(source_index), + target.cells.get_mut(target_index), + ) else { + continue; + }; + *target_cell = source_cell.clone(); + target_cell.hyperlink = source_cell.hyperlink.and_then(|index| { + ((index as usize) < source.hyperlinks.len()).then_some(hyperlink_base + index) + }); + } + } + + target.cursor = source.cursor.as_ref().and_then(|cursor| { + (cursor.x < copy_width && cursor.y < copy_height).then(|| crate::protocol::CursorState { + x: area.x + cursor.x, + y: area.y + cursor.y, + visible: cursor.visible, + shape: cursor.shape, + }) + }); + target.graphics.clear(); +} + +#[cfg(test)] +mod tests; diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs new file mode 100644 index 00000000..efc9aae9 --- /dev/null +++ b/src/client/shell/actions.rs @@ -0,0 +1,1051 @@ +use super::*; + +impl ClientShellState { + pub(super) fn record_binding( + &mut self, + binding: crate::input::KeybindMatch, + outcome: &mut ClientShellInput, + ) { + match binding { + crate::input::KeybindMatch::Action(crate::input::KeybindAction::Detach) => { + outcome.detach = true; + } + crate::input::KeybindMatch::Action(crate::input::KeybindAction::ToggleSidebar) => { + self.sidebar_collapsed = !self.sidebar_collapsed; + self.sidebar_collapsed_manual = true; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + self.persist_chrome_preferences(outcome); + } + crate::input::KeybindMatch::Action(action) => { + if matches!( + action, + crate::input::KeybindAction::NewWorktree + | crate::input::KeybindAction::OpenWorktree + | crate::input::KeybindAction::RemoveWorktree + ) { + self.begin_worktree_action(action, outcome); + return; + } + if action == crate::input::KeybindAction::OpenNavigator { + self.open_navigator_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::Help { + self.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay { + query: String::new(), + search_focused: false, + scroll: 0, + })); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::Settings { + self.open_settings_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::OpenNotificationTarget { + self.focus_visible_notification(outcome); + return; + } + if action == crate::input::KeybindAction::ReloadConfig { + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ServerReloadConfig( + crate::api::schema::EmptyParams::default(), + ), + PendingEndpointKind::ReloadConfig, + outcome, + ); + self.reload_client_config(); + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + return; + } + if action == crate::input::KeybindAction::NewWorkspace { + if self.config.prompt_new_workspace_name { + self.open_new_workspace_overlay(); + } else { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceCreate( + crate::api::schema::WorkspaceCreateParams { + source_workspace_id: self.workspace_action_id(), + cwd: None, + focus: true, + label: None, + env: Default::default(), + }, + ), + outcome, + ); + } + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::RenameWorkspace { + self.open_rename_workspace_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::CloseWorkspace { + if let Some(workspace_id) = self.workspace_action_id() { + if self.config.confirm_close { + self.open_confirm_close_overlay(workspace_id); + } else { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceClose( + crate::api::schema::WorkspaceCloseParams { + workspace_id, + close_group: true, + }, + ), + outcome, + ); + } + } + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::NewTab && self.config.prompt_new_tab_name + { + self.open_new_tab_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::RenameTab { + self.open_rename_tab_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::RenamePane { + self.open_rename_pane_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::WorkspacePicker { + self.mobile_switcher_scroll = 0; + self.reveal_mobile_workspace = false; + self.mode = ClientShellMode::Navigate; + self.navigate_workspace_id = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.clone()); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::EnterResizeMode { + self.mode = ClientShellMode::Resize; + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::CopyMode { + if self.enter_copy_mode(outcome) { + outcome.repaint = true; + } + return; + } + if let Some(method) = self.endpoint_method_for_action(action) { + self.push_endpoint_method(method, outcome); + return; + } + outcome.actions.push(ClientShellAction::Keybind(action)); + } + crate::input::KeybindMatch::Command(command) => { + let action = command.action.into(); + let resolved_labels = command.bindings.labels(); + let command_id = self.snapshot.as_deref().and_then(|snapshot| { + if let Some(candidate) = snapshot.commands.iter().find(|candidate| { + candidate.command_id == command.command && candidate.action == action + }) { + return Some(candidate.command_id.clone()); + } + let mut candidates = snapshot.commands.iter().filter(|candidate| { + candidate.action == action + && !resolved_labels.is_empty() + && resolved_labels + .iter() + .all(|label| candidate.binding_labels.contains(label)) + }); + let candidate = candidates.next()?; + candidates + .next() + .is_none() + .then(|| candidate.command_id.clone()) + }); + let Some(command_id) = command_id else { + self.endpoint_error = Some( + "custom command is not available on this endpoint; reload configuration" + .to_owned(), + ); + outcome.repaint = true; + return; + }; + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let selection = (action == crate::protocol::ClientShellCommandAction::PluginAction) + .then(|| { + let selection = self.selection.as_ref()?; + if !selection.is_visible() { + return None; + } + if snapshot.focused_pane_id.as_deref() != Some(selection.pane_id.as_str()) { + return None; + } + let content_revision = self + .pane_surface + .as_ref()? + .panes + .iter() + .find(|pane| pane.pane_id == selection.pane_id)? + .content_revision; + let (anchor, cursor) = selection.ordered_cells(); + Some(crate::api::schema::PaneSelectionReadParams { + pane_id: selection.pane_id.clone(), + anchor: crate::api::schema::PaneTextPoint { + row: anchor.0, + col: anchor.1, + }, + cursor: crate::api::schema::PaneTextPoint { + row: cursor.0, + col: cursor.1, + }, + content_revision: Some(content_revision), + }) + }) + .flatten(); + let params = crate::api::schema::CommandInvokeParams { + command_id, + workspace_id: snapshot.focused_workspace_id.clone(), + tab_id: snapshot.focused_tab_id.clone(), + pane_id: snapshot.focused_pane_id.clone(), + selection, + }; + if action == crate::protocol::ClientShellCommandAction::Popup { + self.popup_pending = true; + self.popup_pending_deadline = None; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::CommandInvoke(params), + PendingEndpointKind::PopupCommand, + outcome, + ); + } else { + self.push_endpoint_method( + crate::api::schema::Method::CommandInvoke(params), + outcome, + ); + } + } + } + } + + pub(super) fn request_selection_copy(&mut self, outcome: &mut ClientShellInput) { + self.request_selection_copy_with_fallback(outcome, None); + } + + pub(super) fn request_selection_copy_with_fallback( + &mut self, + outcome: &mut ClientShellInput, + fallback_key: Option<crate::input::TerminalKey>, + ) { + let Some(selection) = self.selection.as_ref() else { + return; + }; + let pane_id = selection.pane_id.clone(); + let content_revision = self + .pane_surface + .as_ref() + .and_then(|surface| surface.panes.iter().find(|pane| pane.pane_id == pane_id)) + .map(|pane| pane.content_revision); + let (anchor, cursor) = selection.ordered_cells(); + let fallback = fallback_key.and_then(|key| { + let press = ClientPaneInputEvent::from_terminal_key(key.clone())?; + let tracks_release = matches!( + &press, + ClientPaneInputEvent::Key { + tracks_release: true, + .. + } + ); + let mut message = + super::target_event_message(ClientInputTarget::Pane(pane_id.clone()), press); + if tracks_release { + let release = ClientPaneInputEvent::from_terminal_key( + key.with_kind(crossterm::event::KeyEventKind::Release), + )?; + if let ClientMessage::ClientShellPaneInput { events, .. } = &mut message { + events.push(release); + } + } + Some(message) + }); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneSelectionRead( + crate::api::schema::PaneSelectionReadParams { + pane_id, + anchor: crate::api::schema::PaneTextPoint { + row: anchor.0, + col: anchor.1, + }, + cursor: crate::api::schema::PaneTextPoint { + row: cursor.0, + col: cursor.1, + }, + content_revision, + }, + ), + PendingEndpointKind::SelectionCopy { fallback }, + outcome, + ); + } + + pub(super) fn request_word_selection( + &mut self, + hit: &PaneHit, + viewport_row: u16, + col: u16, + outcome: &mut ClientShellInput, + ) { + let absolute_row = crate::selection::absolute_row_for_viewport(viewport_row, hit.scroll); + let content_revision = self + .pane_surface + .as_ref() + .and_then(|surface| { + surface + .panes + .iter() + .find(|pane| pane.pane_id == hit.pane_id) + }) + .map(|pane| pane.content_revision); + self.word_selection_generation = self.word_selection_generation.saturating_add(1); + let generation = self.word_selection_generation; + self.pending_word_selection = Some(generation); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneSelectionRead( + crate::api::schema::PaneSelectionReadParams { + pane_id: hit.pane_id.clone(), + anchor: crate::api::schema::PaneTextPoint { + row: absolute_row, + col: 0, + }, + cursor: crate::api::schema::PaneTextPoint { + row: absolute_row, + col: hit.inner_rect.width.saturating_sub(1), + }, + content_revision, + }, + ), + PendingEndpointKind::WordSelection { + pane_id: hit.pane_id.clone(), + absolute_row, + col, + generation, + }, + outcome, + ); + } + + pub(super) fn push_endpoint_method( + &mut self, + method: crate::api::schema::Method, + outcome: &mut ClientShellInput, + ) { + self.push_endpoint_method_with_kind(method, PendingEndpointKind::Generic, outcome); + } + + pub(super) fn push_endpoint_method_with_kind( + &mut self, + method: crate::api::schema::Method, + kind: PendingEndpointKind, + outcome: &mut ClientShellInput, + ) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let confirmation_workspace_id = match &method { + crate::api::schema::Method::TabClose(target) => snapshot + .tabs + .iter() + .find(|tab| tab.tab_id == target.tab_id) + .map(|tab| tab.workspace_id.clone()), + crate::api::schema::Method::PaneClose(target) => snapshot + .panes + .iter() + .find(|pane| pane.pane_id == target.pane_id) + .map(|pane| pane.workspace_id.clone()), + _ => None, + }; + let request_id = self.next_request_id; + self.next_request_id = self.next_request_id.saturating_add(1); + let request_id = format!("client-shell:{request_id}"); + self.pending_requests.insert( + request_id.clone(), + PendingEndpointRequest { + boot_id: snapshot.boot_id.clone(), + confirmation_workspace_id, + kind, + }, + ); + outcome.actions.push(ClientShellAction::Endpoint { + boot_id: snapshot.boot_id.clone(), + request: Box::new(crate::api::schema::Request { + id: request_id, + method, + }), + }); + } + + pub(crate) fn receive_endpoint_error(&mut self, message: String) -> bool { + if self.endpoint_error.as_deref() == Some(message.as_str()) { + return false; + } + self.endpoint_error = Some(message); + true + } + + pub(crate) fn handle_endpoint_result( + &mut self, + boot_id: &str, + request_id: &str, + result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>, + ) -> (bool, Vec<ClientShellAction>) { + let Some(pending) = self.pending_requests.remove(request_id) else { + return (false, Vec::new()); + }; + if pending.boot_id != boot_id + || self + .snapshot + .as_deref() + .is_none_or(|snapshot| snapshot.boot_id != boot_id) + { + return (false, Vec::new()); + } + match pending.kind { + PendingEndpointKind::Generic => {} + PendingEndpointKind::ProductAnnouncementDismiss { version, id } => { + return match result { + Ok(_) => (false, Vec::new()), + Err(error) => { + let key = (version.clone(), id.clone()); + if self.dismissed_product_announcement.as_ref() == Some(&key) { + self.dismissed_product_announcement = None; + } + if self.overlay.is_none() { + if let Some(announcement) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.product_announcement.as_ref()) + .filter(|announcement| { + announcement.version == version && announcement.id == id + }) + { + self.overlay = Some(ClientShellOverlay::ProductAnnouncement( + product_announcement_state(announcement), + )); + } + } + self.endpoint_error = Some(error.message); + (true, Vec::new()) + } + }; + } + PendingEndpointKind::ReleaseNotesDismiss => { + return match result { + Ok(_) => (false, Vec::new()), + Err(error) => { + if self.overlay.is_none() { + if let Some(notes) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.release_notes.as_ref()) + { + self.overlay = Some(ClientShellOverlay::ReleaseNotes( + release_notes_state(notes), + )); + } + } + self.endpoint_error = Some(error.message); + (true, Vec::new()) + } + }; + } + PendingEndpointKind::PopupCommand => { + return match result { + Ok(_) => { + self.popup_pending_deadline = + Some(std::time::Instant::now() + std::time::Duration::from_secs(1)); + (false, Vec::new()) + } + Err(error) => { + self.popup_pending = false; + self.popup_pending_deadline = None; + self.endpoint_error = Some(error.message); + (true, Vec::new()) + } + }; + } + PendingEndpointKind::PaneScroll { pane_id, serial } => { + let mut outcome = ClientShellInput::default(); + let repaint = self.complete_pane_scroll(pane_id, serial, result, &mut outcome); + return (repaint, outcome.actions); + } + PendingEndpointKind::SelectionCopy { fallback } => { + let fallback = || { + fallback + .map(ClientShellAction::Request) + .into_iter() + .collect::<Vec<_>>() + }; + return match result { + Ok(crate::api::schema::ResponseResult::PaneSelection { text, .. }) + if !text.is_empty() => + { + let repaint = self.show_copy_feedback(std::time::Instant::now()); + ( + repaint, + vec![ClientShellAction::ClipboardWrite(text.into_bytes())], + ) + } + Ok(crate::api::schema::ResponseResult::PaneSelection { .. }) => { + (false, fallback()) + } + Ok(_) => { + self.endpoint_error = + Some("endpoint returned an unexpected selection result".to_owned()); + (true, fallback()) + } + Err(error) => { + self.endpoint_error = Some(error.message); + (true, fallback()) + } + }; + } + PendingEndpointKind::WordSelection { + pane_id, + absolute_row, + col, + generation, + } => { + if self.pending_word_selection != Some(generation) + || self.snapshot.as_deref().is_none_or(|snapshot| { + !snapshot.panes.iter().any(|pane| pane.pane_id == pane_id) + }) + { + return (false, Vec::new()); + } + self.pending_word_selection = None; + let row_text = match result { + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: returned_pane_id, + text, + }) if returned_pane_id == pane_id => text, + Ok(crate::api::schema::ResponseResult::PaneSelection { .. }) => { + return (false, Vec::new()) + } + Ok(_) => { + self.endpoint_error = Some( + "endpoint returned an unexpected word-selection result".to_owned(), + ); + return (true, Vec::new()); + } + Err(error) => { + self.endpoint_error = Some(error.message); + return (true, Vec::new()); + } + }; + let Some((start_col, end_col)) = + crate::app::actions::word_bounds_at_column(&row_text, col) + else { + self.selection = None; + return (true, Vec::new()); + }; + let mut selection = crate::selection::Selection::absolute_range( + pane_id, + (absolute_row, start_col), + (absolute_row, end_col), + ); + if !selection.finish() { + return (false, Vec::new()); + } + self.selection = Some(selection); + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + if !self.config.copy_on_select { + return (true, Vec::new()); + } + self.selection_highlight_clear_deadline = + Some(std::time::Instant::now() + std::time::Duration::from_millis(500)); + let mut outcome = ClientShellInput::default(); + self.request_selection_copy(&mut outcome); + return (true, outcome.actions); + } + PendingEndpointKind::PaneLinkActivate { + pane_id, + inner_rect, + fallback_events, + } => { + let completed_before_release = !fallback_events.iter().any(|event| { + event.kind + == crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left) + }); + let replay = (self.mode == ClientShellMode::Terminal + && self.overlay.is_none() + && self + .hits + .panes + .iter() + .any(|hit| hit.pane_id == pane_id && hit.inner_rect == inner_rect)) + .then_some(fallback_events); + if replay.is_none() { + self.url_click_consumes_until_up = completed_before_release; + } + let replay_action = |events: Option<Vec<crossterm::event::MouseEvent>>| { + events + .map(ClientShellAction::ReplayMouse) + .into_iter() + .collect() + }; + return match result { + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + handled: true, + .. + }) => { + self.url_click_consumes_until_up = completed_before_release; + (false, Vec::new()) + } + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + url: Some(url), + handled: false, + }) if crate::app::actions::safe_web_url(&url).is_some() => { + self.url_click_consumes_until_up = completed_before_release; + (false, vec![ClientShellAction::OpenSafeWebUrl(url)]) + } + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { .. }) => { + (false, replay_action(replay)) + } + Ok(_) => { + self.endpoint_error = + Some("endpoint returned an unexpected link result".to_owned()); + (true, replay_action(replay)) + } + Err(error) + if matches!( + error.code.as_deref(), + Some("stale_content" | "stale_target") + ) => + { + self.url_click_consumes_until_up = completed_before_release; + (false, Vec::new()) + } + Err(error) => { + self.endpoint_error = Some(error.message); + (true, replay_action(replay)) + } + }; + } + PendingEndpointKind::CopyMotion { + pane_id, + origin, + session_generation, + } => { + let mut outcome = ClientShellInput::default(); + let (repaint, continue_queue) = match result { + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { + pane_id: returned_pane_id, + cursor, + content_revision, + }) if returned_pane_id == pane_id => ( + self.apply_copy_motion_target( + &pane_id, + origin, + cursor, + content_revision, + &mut outcome, + ), + true, + ), + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { .. }) => (false, false), + Ok(_) => { + self.endpoint_error = + Some("endpoint returned an unexpected copy-motion result".to_owned()); + (true, false) + } + Err(error) => { + self.endpoint_error = Some(error.message); + (true, false) + } + }; + self.complete_copy_operation(session_generation, continue_queue, &mut outcome); + return (repaint || outcome.repaint, outcome.actions); + } + PendingEndpointKind::CopySearch { + pane_id, + origin, + query, + direction, + repeat, + generation, + session_generation, + } => { + let mut outcome = ClientShellInput::default(); + let (repaint, continue_queue) = match result { + Ok(crate::api::schema::ResponseResult::PaneCopySearch { + pane_id: returned_pane_id, + content_revision, + matches, + total, + current, + current_global, + }) if returned_pane_id == pane_id => { + let repaint = self.apply_copy_search_result( + &pane_id, + origin, + query, + direction, + repeat, + generation, + ClientCopySearchResult { + content_revision, + matches, + total, + current: current.and_then(|index| usize::try_from(index).ok()), + current_global, + }, + &mut outcome, + ); + if !repaint { + self.cancel_deferred_copy_after_search(generation); + } + (repaint, repaint) + } + Ok(crate::api::schema::ResponseResult::PaneCopySearch { .. }) => { + self.cancel_deferred_copy_after_search(generation); + (false, false) + } + Ok(_) => { + self.cancel_deferred_copy_after_search(generation); + self.endpoint_error = + Some("endpoint returned an unexpected copy-search result".to_owned()); + (true, false) + } + Err(error) => { + self.cancel_deferred_copy_after_search(generation); + self.endpoint_error = Some(error.message); + (true, false) + } + }; + self.complete_copy_operation(session_generation, continue_queue, &mut outcome); + return (repaint || outcome.repaint, outcome.actions); + } + PendingEndpointKind::ReloadConfig => { + let repaint = match result { + Ok(crate::api::schema::ResponseResult::ConfigReload { .. }) => false, + Ok(_) => { + self.endpoint_error = + Some("endpoint returned an unexpected config reload result".to_owned()); + true + } + Err(error) => { + self.endpoint_error = Some(error.message); + true + } + }; + return (repaint, Vec::new()); + } + kind @ (PendingEndpointKind::IntegrationList + | PendingEndpointKind::IntegrationInstall) => { + return self.handle_settings_endpoint_result(kind, result); + } + kind => { + return ( + self.handle_worktree_endpoint_result(kind, result), + Vec::new(), + ); + } + } + let repaint = match result { + Ok(_) => false, + Err(error) + if self.config.confirm_close + && error.code.as_deref() == Some("confirmation_required") + && pending.confirmation_workspace_id.is_some() => + { + if let Some(workspace_id) = pending.confirmation_workspace_id { + self.open_confirm_close_overlay(workspace_id); + } + true + } + Err(error) => { + self.endpoint_error = Some(error.message); + true + } + }; + (repaint, Vec::new()) + } + + pub(super) fn endpoint_method_for_action( + &mut self, + action: crate::input::KeybindAction, + ) -> Option<crate::api::schema::Method> { + use crate::api::schema::{ + Method, PaneDirection, PaneFocusDirectionParams, PaneResizeParams, PaneSplitParams, + PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, SplitDirection, + TabCreateParams, TabMoveParams, TabTarget, WorkspaceTarget, + }; + use crate::input::KeybindAction; + + let snapshot = self.snapshot.as_deref()?; + let focused_workspace = snapshot.focused_workspace_id.clone()?; + let focused_tab = snapshot.focused_tab_id.clone(); + let focused_pane = snapshot.focused_pane_id.clone(); + let direction = |action| match action { + KeybindAction::FocusPaneLeft + | KeybindAction::SwapPaneLeft + | KeybindAction::ResizePaneLeft => Some(PaneDirection::Left), + KeybindAction::FocusPaneDown + | KeybindAction::SwapPaneDown + | KeybindAction::ResizePaneDown => Some(PaneDirection::Down), + KeybindAction::FocusPaneUp + | KeybindAction::SwapPaneUp + | KeybindAction::ResizePaneUp => Some(PaneDirection::Up), + KeybindAction::FocusPaneRight + | KeybindAction::SwapPaneRight + | KeybindAction::ResizePaneRight => Some(PaneDirection::Right), + _ => None, + }; + + match action { + KeybindAction::FocusAgent(index) => { + let agents = super::agent_sidebar::ordered_agent_pane_ids( + snapshot, + self.config.agent_panel_sort, + ); + Some(Method::PaneFocus(PaneTarget { + pane_id: agents.get(index)?.clone(), + })) + } + KeybindAction::PreviousAgent | KeybindAction::NextAgent => { + let agents = super::agent_sidebar::ordered_agent_pane_ids( + snapshot, + self.config.agent_panel_sort, + ); + if agents.is_empty() { + return None; + } + let current = agents.iter().position(|pane_id| { + Some(pane_id.as_str()) == snapshot.focused_pane_id.as_deref() + }); + let next = match (current, action) { + (Some(current), KeybindAction::PreviousAgent) => { + (current + agents.len() - 1) % agents.len() + } + (Some(current), KeybindAction::NextAgent) => (current + 1) % agents.len(), + (None, KeybindAction::PreviousAgent) => agents.len() - 1, + (None, KeybindAction::NextAgent) => 0, + _ => unreachable!("relative agent action"), + }; + let pane_id = agents[next].clone(); + if !self + .hits + .agents + .iter() + .any(|(_, visible_pane_id)| visible_pane_id == &pane_id) + { + self.agent_scroll = next.min(self.hits.agent_max_scroll); + } + Some(Method::PaneFocus(PaneTarget { pane_id })) + } + KeybindAction::SwitchWorkspace(index) => { + let entries = self.navigation_workspace_entries(snapshot); + let workspace_id = snapshot + .workspaces + .get(entries.get(index)?.index)? + .workspace_id + .clone(); + self.reveal_workspace(&workspace_id); + Some(Method::WorkspaceFocus(WorkspaceTarget { workspace_id })) + } + KeybindAction::PreviousWorkspace | KeybindAction::NextWorkspace => { + let entries = self.navigation_workspace_entries(snapshot); + if entries.is_empty() { + return None; + } + let current = entries + .iter() + .position(|entry| { + snapshot.workspaces[entry.index].workspace_id == focused_workspace + }) + .unwrap_or(0); + let delta = if action == KeybindAction::PreviousWorkspace { + -1 + } else { + 1 + }; + let next = (current as isize + delta).rem_euclid(entries.len() as isize) as usize; + let workspace_id = snapshot.workspaces[entries[next].index] + .workspace_id + .clone(); + self.reveal_workspace(&workspace_id); + Some(Method::WorkspaceFocus(WorkspaceTarget { workspace_id })) + } + KeybindAction::SwitchTab(index) => { + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == focused_workspace) + .collect::<Vec<_>>(); + Some(Method::TabFocus(TabTarget { + tab_id: tabs.get(index)?.tab_id.clone(), + })) + } + KeybindAction::PreviousTab | KeybindAction::NextTab => { + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == focused_workspace) + .collect::<Vec<_>>(); + let focused_tab = focused_tab?; + let current = tabs.iter().position(|tab| tab.tab_id == focused_tab)?; + let delta = if action == KeybindAction::PreviousTab { + -1 + } else { + 1 + }; + let next = (current as isize + delta).rem_euclid(tabs.len() as isize) as usize; + Some(Method::TabFocus(TabTarget { + tab_id: tabs[next].tab_id.clone(), + })) + } + KeybindAction::MoveTabPrevious | KeybindAction::MoveTabNext => { + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == focused_workspace) + .collect::<Vec<_>>(); + if tabs.len() <= 1 { + return None; + } + let focused_tab = focused_tab?; + let source = tabs.iter().position(|tab| tab.tab_id == focused_tab)?; + let insert_index = if action == KeybindAction::MoveTabNext { + if source + 1 >= tabs.len() { + 0 + } else { + source + 2 + } + } else if source == 0 { + tabs.len() + } else { + source - 1 + }; + Some(Method::TabMove(TabMoveParams { + tab_id: focused_tab, + insert_index, + })) + } + KeybindAction::NewTab if !self.config.prompt_new_tab_name => { + Some(Method::TabCreate(TabCreateParams { + workspace_id: Some(focused_workspace), + cwd: None, + focus: true, + label: None, + env: Default::default(), + })) + } + KeybindAction::FocusPaneLeft + | KeybindAction::FocusPaneDown + | KeybindAction::FocusPaneUp + | KeybindAction::FocusPaneRight => { + Some(Method::PaneFocusDirection(PaneFocusDirectionParams { + pane_id: focused_pane, + direction: direction(action)?, + })) + } + KeybindAction::SwapPaneLeft + | KeybindAction::SwapPaneDown + | KeybindAction::SwapPaneUp + | KeybindAction::SwapPaneRight => Some(Method::PaneSwap(PaneSwapParams { + pane_id: focused_pane, + direction: Some(direction(action)?), + source_pane_id: None, + target_pane_id: None, + })), + KeybindAction::SplitVertical | KeybindAction::SplitHorizontal => { + Some(Method::PaneSplit(PaneSplitParams { + workspace_id: Some(focused_workspace), + target_pane_id: focused_pane, + direction: if action == KeybindAction::SplitVertical { + SplitDirection::Right + } else { + SplitDirection::Down + }, + ratio: None, + cwd: None, + focus: true, + right_click: Default::default(), + env: Default::default(), + })) + } + KeybindAction::CloseTab => Some(Method::TabClose(TabTarget { + tab_id: focused_tab?, + })), + KeybindAction::ClosePane => Some(Method::PaneClose(PaneTarget { + pane_id: focused_pane.clone()?, + })), + KeybindAction::CyclePaneNext | KeybindAction::CyclePanePrevious => { + let focused_tab = focused_tab?; + let panes = snapshot + .panes + .iter() + .filter(|pane| pane.tab_id == focused_tab) + .collect::<Vec<_>>(); + if panes.is_empty() { + return None; + } + let focused_pane = focused_pane?; + let current = panes + .iter() + .position(|pane| pane.pane_id == focused_pane) + .unwrap_or(0); + let next = if action == KeybindAction::CyclePanePrevious { + (current + panes.len() - 1) % panes.len() + } else { + (current + 1) % panes.len() + }; + Some(Method::PaneFocus(PaneTarget { + pane_id: panes[next].pane_id.clone(), + })) + } + KeybindAction::LastPane => { + let pane_id = self.previous_pane_id.as_ref()?; + if Some(pane_id.as_str()) == focused_pane.as_deref() + || !snapshot.panes.iter().any(|pane| &pane.pane_id == pane_id) + { + return None; + } + Some(Method::PaneFocus(PaneTarget { + pane_id: pane_id.clone(), + })) + } + KeybindAction::Zoom => Some(Method::PaneZoom(PaneZoomParams { + pane_id: focused_pane, + mode: PaneZoomMode::Toggle, + })), + KeybindAction::EditScrollback => Some(Method::PaneEditScrollback(PaneTarget { + pane_id: focused_pane?, + })), + KeybindAction::ResizePaneLeft + | KeybindAction::ResizePaneDown + | KeybindAction::ResizePaneUp + | KeybindAction::ResizePaneRight => Some(Method::PaneResize(PaneResizeParams { + pane_id: focused_pane, + direction: direction(action)?, + amount: None, + })), + _ => None, + } + } +} diff --git a/src/client/shell/agent_sidebar.rs b/src/client/shell/agent_sidebar.rs new file mode 100644 index 00000000..8f87b6ad --- /dev/null +++ b/src/client/shell/agent_sidebar.rs @@ -0,0 +1,343 @@ +use std::collections::HashMap; + +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Modifier, Style}, + text::Line, + widgets::{Paragraph, Widget}, +}; + +use super::*; + +struct AgentRow { + pane_id: String, + status: crate::api::schema::AgentStatus, + focused: bool, + rows: Vec<Vec<crate::ui::ResolvedToken>>, +} + +pub(super) fn ordered_agent_pane_ids( + snapshot: &ClientShellSnapshot, + sort: crate::config::AgentPanelSortConfig, +) -> Vec<String> { + if snapshot.agent_view_label.is_some() { + return snapshot + .agent_order + .iter() + .filter(|pane_id| { + snapshot + .agents + .iter() + .any(|agent| agent.pane_id == pane_id.as_str()) + }) + .cloned() + .collect(); + } + let mut agents = snapshot.agents.iter().collect::<Vec<_>>(); + if sort == crate::config::AgentPanelSortConfig::Priority { + agents.sort_by_key(|agent| { + ( + std::cmp::Reverse(status_priority(agent.agent_status)), + std::cmp::Reverse(agent.state_change_seq), + ) + }); + } + agents + .into_iter() + .map(|agent| agent.pane_id.clone()) + .collect() +} + +pub(super) fn render_agent_panel( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + agent_scroll: &mut usize, + hits: &mut ShellHitMap, +) { + if area.height == 0 { + return; + } + put_text( + buffer, + area.x, + area.y, + area.width, + &"─".repeat(area.width as usize), + Style::default().fg(config.palette.surface_dim), + ); + if area.height < 2 { + return; + } + put_text( + buffer, + area.x, + area.y + 1, + area.width, + " agents", + Style::default() + .fg(config.palette.overlay0) + .add_modifier(Modifier::BOLD), + ); + let sort_label = + snapshot + .agent_view_label + .as_deref() + .unwrap_or(match config.agent_panel_sort { + crate::config::AgentPanelSortConfig::Spaces => "grouped", + crate::config::AgentPanelSortConfig::Priority => "priority", + }); + let sort_width = display_width(sort_label).min(area.width as usize) as u16; + let sort_rect = Rect::new( + area.right().saturating_sub(sort_width), + area.y + 1, + sort_width, + 1, + ); + hits.agent_sort_toggle = if config.mouse_capture && snapshot.agent_view_label.is_none() { + sort_rect + } else { + Rect::default() + }; + put_text( + buffer, + sort_rect.x, + sort_rect.y, + sort_rect.width, + sort_label, + Style::default() + .fg(if snapshot.agent_view_label.is_some() { + config.palette.accent + } else { + config.palette.overlay0 + }) + .add_modifier(Modifier::BOLD), + ); + + let rows = agent_rows(snapshot, config); + let body = Rect::new( + area.x, + area.y.saturating_add(3), + area.width, + area.height.saturating_sub(3), + ); + hits.agent_body = body; + if body.is_empty() || rows.is_empty() { + *agent_scroll = 0; + if !body.is_empty() && snapshot.agent_view_label.is_some() { + put_text( + buffer, + body.x, + body.y, + body.width, + " no matching agents", + Style::default() + .fg(config.palette.overlay0) + .add_modifier(Modifier::DIM), + ); + } + return; + } + + let row_heights = rows + .iter() + .map(|row| row.rows.len().max(1).min(u16::MAX as usize) as u16) + .collect::<Vec<_>>(); + let gaps = rows + .iter() + .enumerate() + .map(|(index, _)| { + if index + 1 < rows.len() { + config.agents.row_gap + } else { + 0 + } + }) + .collect::<Vec<_>>(); + let metrics = + super::scroll::list_scroll_metrics(&row_heights, &gaps, body.height, *agent_scroll); + hits.agent_max_scroll = metrics.max_offset_from_bottom; + hits.agent_scroll_metrics = Some(metrics); + *agent_scroll = metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom); + let show_scrollbar = metrics.max_offset_from_bottom > 0 && body.width > 1; + let content_width = body.width.saturating_sub(u16::from(show_scrollbar)); + let mut y = body.y; + for (index, row) in rows.iter().enumerate().skip(*agent_scroll) { + let height = (row.rows.len().max(1).min(u16::MAX as usize) as u16).min(body.height); + if y.saturating_add(height) > body.bottom() { + break; + } + let rect = Rect::new(body.x, y, content_width, height); + hits.agents.push((rect, row.pane_id.clone())); + render_agent_row(buffer, rect, row, config); + y = y + .saturating_add(height) + .saturating_add(if index + 1 < rows.len() { + config.agents.row_gap + } else { + 0 + }); + } + + if show_scrollbar { + let track = Rect::new(body.right().saturating_sub(1), body.y, 1, body.height); + hits.agent_scrollbar = track; + super::scroll::render_list_scrollbar(buffer, track, metrics, &config.palette); + } +} + +fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec<AgentRow> { + ordered_agent_pane_ids(snapshot, config.agent_panel_sort) + .into_iter() + .filter_map(|pane_id| { + let agent = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id)?; + let workspace = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == agent.workspace_id)?; + let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id); + let pane = snapshot + .panes + .iter() + .find(|pane| pane.pane_id == agent.pane_id); + let tab_count = snapshot + .tabs + .iter() + .filter(|candidate| candidate.workspace_id == agent.workspace_id) + .count(); + let tab_label = tab + .filter(|tab| tab_count > 1 || tab.custom_label) + .map(|tab| tab.label.as_str()); + let agent_label = agent + .display_agent + .as_deref() + .or(agent.name.as_deref()) + .or(agent.agent.as_deref()) + .or(agent.title.as_deref()); + let labels = agent + .state_labels + .iter() + .cloned() + .collect::<HashMap<_, _>>(); + let tokens = agent.tokens.iter().cloned().collect::<HashMap<_, _>>(); + let state_text = labels + .get(status_text(agent.agent_status)) + .map(String::as_str) + .unwrap_or_else(|| sidebar_status_text(agent.agent_status)); + let canonical_agent = agent + .agent + .as_deref() + .and_then(crate::detect::parse_agent_label); + let rows = crate::ui::sidebar_agent_rows( + &config.agents, + crate::ui::AgentTokenContext { + workspace: &workspace.label, + tab: tab_label, + pane: agent + .title + .as_deref() + .or_else(|| pane.and_then(|pane| pane.label.as_deref())), + agent_label, + terminal_title: agent.terminal_title.as_deref(), + terminal_title_stripped: agent.terminal_title_stripped.as_deref(), + canonical_agent, + tokens: &tokens, + }, + state_text, + ); + Some(AgentRow { + pane_id: agent.pane_id.clone(), + status: agent.agent_status, + focused: agent.focused, + rows, + }) + }) + .collect() +} + +fn render_agent_row(buffer: &mut Buffer, rect: Rect, row: &AgentRow, config: &ClientShellConfig) { + let palette = &config.palette; + let row_style = if row.focused { + Style::default().bg(palette.active_row_bg) + } else { + Style::default() + }; + let name_style = if row.focused { + Style::default() + .fg(palette.text) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(palette.subtext0) + .add_modifier(Modifier::BOLD) + }; + let status_style = Style::default() + .fg(status_color(row.status, palette)) + .add_modifier(if row.focused { + Modifier::empty() + } else { + Modifier::DIM + }); + let secondary = Style::default() + .fg(palette.overlay0) + .add_modifier(Modifier::DIM); + let icon = ( + status_icon(row.status, config.status_indicators), + Style::default().fg(status_color(row.status, palette)), + ); + let rows = if row.rows.is_empty() { + vec![vec![crate::ui::ResolvedToken { + kind: crate::ui::ResolvedTokenKind::StateIcon, + style: Default::default(), + }]] + } else { + row.rows.clone() + }; + for (index, tokens) in rows.iter().take(rect.height as usize).enumerate() { + let indent = if index == 0 { 1 } else { 3 }; + let mut spans = vec![ratatui::text::Span::raw(" ".repeat(indent))]; + spans.extend(crate::ui::resolved_token_spans( + tokens, + icon, + status_style, + name_style, + secondary, + secondary, + palette, + rect.width.saturating_sub(indent as u16) as usize, + )); + Paragraph::new(Line::from(spans)).style(row_style).render( + Rect::new(rect.x, rect.y + index as u16, rect.width, 1), + buffer, + ); + } +} + +fn put_text(buffer: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) { + for (offset, character) in text.chars().take(width as usize).enumerate() { + if let Some(cell) = buffer.cell_mut((x + offset as u16, y)) { + cell.set_char(character).set_style(style); + } + } +} + +fn display_width(text: &str) -> usize { + unicode_width::UnicodeWidthStr::width(text) +} + +fn sidebar_status_text(status: crate::api::schema::AgentStatus) -> &'static str { + use crate::api::schema::AgentStatus; + match status { + AgentStatus::Blocked => "blocked", + AgentStatus::Done => "done", + AgentStatus::Working => "working", + AgentStatus::Idle | AgentStatus::Unknown => "idle", + } +} diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs new file mode 100644 index 00000000..6432f3e1 --- /dev/null +++ b/src/client/shell/composition.rs @@ -0,0 +1,539 @@ +use super::*; + +fn restore_mode_bar( + frame: &mut FrameData, + bar: Option<Rect>, + cells: Option<&[crate::protocol::CellData]>, +) { + let (Some(bar), Some(cells)) = (bar, cells) else { + return; + }; + let start = usize::from(bar.y) * usize::from(frame.width) + usize::from(bar.x); + frame.cells[start..start + usize::from(bar.width)].clone_from_slice(cells); + if frame + .cursor + .as_ref() + .is_some_and(|cursor| cursor.y == bar.y) + { + frame.cursor = None; + } +} + +impl ClientShellState { + pub(crate) fn compose(&mut self, cols: u16, rows: u16) -> Option<FrameData> { + self.last_composed_size = Some((cols, rows)); + let snapshot = self.snapshot.as_deref()?; + let surface = self.pane_surface.as_ref()?; + if snapshot.revision != surface.projection_revision { + return None; + } + let layout = self.layout(cols, rows); + if self.last_tab_bar_width != Some(layout.tab_bar.width) { + self.last_tab_bar_width = Some(layout.tab_bar.width); + self.reveal_focused_tab = true; + } + let tab_drag_insert_index = match &self.chrome_drag { + Some(ClientChromeDrag::Tab { insert_index, .. }) => *insert_index, + _ => None, + }; + let (dragged_workspace_id, workspace_drop_indicator_row) = match &self.chrome_drag { + Some(ClientChromeDrag::Workspace { + source_workspace_id, + target, + }) => ( + Some(source_workspace_id.as_str()), + target.as_ref().map(|(_, row)| *row), + ), + _ => (None, None), + }; + let mut buffer = Buffer::empty(Rect::new(0, 0, cols, rows)); + self.hits = render::render_shell( + &mut buffer, + layout, + snapshot, + &self.config, + render::ShellRenderState { + collapsed_groups: &self.collapsed_groups, + workspace_scroll: &mut self.workspace_scroll, + agent_scroll: &mut self.agent_scroll, + tab_scroll: &mut self.tab_scroll, + reveal_focused_tab: &mut self.reveal_focused_tab, + sidebar_collapsed: self.sidebar_collapsed, + sidebar_section_split: self.sidebar_section_split, + tab_drag_insert_index, + selected_workspace_id: (self.mode == ClientShellMode::Navigate) + .then_some(self.navigate_workspace_id.as_deref()) + .flatten(), + dragged_workspace_id, + workspace_drop_indicator_row, + }, + ); + self.hits.panes = surface + .panes + .iter() + .map(|pane| PaneHit { + rect: Rect::new( + layout.pane_surface.x.saturating_add(pane.rect.x), + layout.pane_surface.y.saturating_add(pane.rect.y), + pane.rect.width, + pane.rect.height, + ), + inner_rect: Rect::new( + layout.pane_surface.x.saturating_add(pane.inner_rect.x), + layout.pane_surface.y.saturating_add(pane.inner_rect.y), + pane.inner_rect.width, + pane.inner_rect.height, + ), + scrollbar_rect: pane.scrollbar_rect.map(|rect| { + Rect::new( + layout.pane_surface.x.saturating_add(rect.x), + layout.pane_surface.y.saturating_add(rect.y), + rect.width, + rect.height, + ) + }), + scroll: pane.scroll.map(|metrics| crate::pane::ScrollMetrics { + offset_from_bottom: usize::try_from(metrics.offset_from_bottom) + .unwrap_or(usize::MAX), + max_offset_from_bottom: usize::try_from(metrics.max_offset_from_bottom) + .unwrap_or(usize::MAX), + viewport_rows: usize::try_from(metrics.viewport_rows).unwrap_or(usize::MAX), + }), + pane_id: pane.pane_id.clone(), + popup: false, + mouse_reporting: pane.mouse_reporting, + sgr_pixel_mouse: pane.sgr_pixel_mouse, + pixel_width: pane.pixel_width, + pixel_height: pane.pixel_height, + }) + .collect(); + let topology_signature = pane_surface_topology_signature(surface); + self.hits.pane_splits = surface + .splits + .iter() + .map(|split| PaneSplitHit { + direction: split.direction, + pos: match split.direction { + crate::protocol::PaneSurfaceSplitDirection::Horizontal => { + layout.pane_surface.x.saturating_add(split.pos) + } + crate::protocol::PaneSurfaceSplitDirection::Vertical => { + layout.pane_surface.y.saturating_add(split.pos) + } + }, + area: Rect::new( + layout.pane_surface.x.saturating_add(split.area.x), + layout.pane_surface.y.saturating_add(split.area.y), + split.area.width, + split.area.height, + ), + hit_rect: Rect::new( + layout.pane_surface.x.saturating_add(split.hit_rect.x), + layout.pane_surface.y.saturating_add(split.hit_rect.y), + split.hit_rect.width, + split.hit_rect.height, + ), + path: split.path.clone(), + topology_signature, + }) + .collect(); + if !self.config.mouse_capture { + self.hits.pane_splits.clear(); + } + let mode_bar_area = if layout.mobile_header.is_empty() + && self.config.tab_bar_position == TabBarPositionConfig::Bottom + && !layout.tab_bar.is_empty() + { + layout.tab_bar + } else { + layout.pane_surface + }; + let mobile_navigate_panel = !layout.mobile_header.is_empty() + && self.mode == ClientShellMode::Navigate + && self.endpoint_error.is_none(); + let mode_bar = if mobile_navigate_panel || self.overlay.is_some() { + None + } else { + render::render_mode_bar( + &mut buffer, + mode_bar_area, + self.mode, + self.copy_mode.as_ref(), + self.endpoint_error.as_deref(), + snapshot.update_available.is_some(), + &self.config.keybinds, + &self.config.palette, + ) + }; + if mode_bar == Some(layout.tab_bar) { + self.hits.tabs.clear(); + self.hits.new_tab = Rect::default(); + self.hits.tab_scroll_left = Rect::default(); + self.hits.tab_scroll_right = Rect::default(); + } + let mut frame = FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, None, &[]); + let mode_bar_cells = mode_bar.map(|bar| { + let start = usize::from(bar.y) * usize::from(frame.width) + usize::from(bar.x); + frame.cells[start..start + usize::from(bar.width)].to_vec() + }); + blit_pane_surface(&mut frame, &surface.frame, layout.pane_surface); + restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); + let has_selection = self + .selection + .as_ref() + .is_some_and(|selection| selection.is_visible()); + let has_search = self + .copy_mode + .as_ref() + .is_some_and(|copy_mode| !copy_mode.search_matches.is_empty()); + if has_selection || has_search { + let cursor = frame.cursor.clone(); + let mut composed = frame.to_ratatui_buffer()?; + for hit in &self.hits.panes { + let copy_surface_coherent = + client_copy_surface_coherent(self.copy_mode.as_ref(), hit); + if copy_surface_coherent { + render_client_copy_search_highlights( + &mut composed, + self.copy_mode.as_ref(), + hit, + &self.config.palette, + false, + ); + } + let selection_is_stale_copy_projection = !copy_surface_coherent + && self.copy_mode.as_ref().is_some_and(|copy_mode| { + copy_mode.pane_id == hit.pane_id + && self + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == hit.pane_id) + }); + if !selection_is_stale_copy_projection { + crate::ui::render_selection_highlight( + self.selection.as_ref(), + &mut composed, + &hit.pane_id, + hit.inner_rect, + hit.scroll, + &self.config.palette, + crate::terminal_theme::TerminalTheme::default(), + ); + } + if copy_surface_coherent { + render_client_copy_search_highlights( + &mut composed, + self.copy_mode.as_ref(), + hit, + &self.config.palette, + true, + ); + } + } + frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); + } + if self.mode == ClientShellMode::Copy { + frame.cursor = None; + if let Some(copy_mode) = self.copy_mode.as_ref() { + if let Some(hit) = self.hits.panes.iter().find(|hit| { + hit.pane_id == copy_mode.pane_id + && client_copy_surface_coherent(Some(copy_mode), hit) + }) { + let viewport_top = copy_mode + .max_offset_from_bottom + .saturating_sub(copy_mode.offset_from_bottom) + .min(u32::MAX as usize) as u32; + let viewport_row = copy_mode.cursor.row.saturating_sub(viewport_top); + if viewport_row < u32::from(hit.inner_rect.height) + && copy_mode.cursor.col < hit.inner_rect.width + { + let mut composed = frame.to_ratatui_buffer()?; + let x = hit.inner_rect.x + copy_mode.cursor.col; + let y = hit.inner_rect.y + viewport_row as u16; + composed[(x, y)].set_style( + Style::default() + .fg(match self.config.palette.panel_bg { + ratatui::style::Color::Reset => self.config.palette.surface_dim, + color => color, + }) + .bg(self.config.palette.accent) + .add_modifier(Modifier::BOLD), + ); + frame.replace_from_ratatui_buffer_preserving_effects(&composed, None); + } else { + frame.cursor = None; + } + } + } + } + restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); + self.hits.notification_toast = Rect::default(); + let has_config_diagnostic = self.config_diagnostic.is_some(); + if has_config_diagnostic || self.visible_notification.is_some() { + let cursor = frame.cursor.clone(); + let mut composed = frame.to_ratatui_buffer()?; + if let Some(diagnostic) = self.config_diagnostic.as_deref() { + let diagnostic_area = if layout.mobile_header.is_empty() { + Rect::new(0, 0, cols, rows) + } else { + layout.pane_surface + }; + crate::ui::render_config_diagnostic_buffer( + &mut composed, + diagnostic_area, + diagnostic, + &self.config.palette, + ); + } + if let Some(notification) = self.visible_notification.as_ref() { + self.hits.notification_toast = if layout.mobile_header.is_empty() { + notifications::render_visible_notification( + &mut composed, + Rect::new(0, 0, cols, rows), + notification, + self.config.toast_position, + u16::from(has_config_diagnostic), + &self.config.palette, + ) + } else { + notifications::render_mobile_notification_banner( + &mut composed, + Rect::new(0, 0, cols, rows), + notification, + has_config_diagnostic, + &self.config.palette, + ) + }; + } + frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); + } + if let Some(feedback) = self.copy_feedback.as_ref() { + let cursor = frame.cursor.clone(); + let mut composed = frame.to_ratatui_buffer()?; + let base_offset = u16::from(has_config_diagnostic); + let feedback_area = if layout.mobile_header.is_empty() { + layout.pane_surface + } else { + Rect::new(0, 0, cols, rows) + }; + let offset = crate::ui::copy_feedback_offset_for_toast( + feedback_area, + feedback, + base_offset, + self.config.clipboard_toast_position, + self.hits.notification_toast, + ); + crate::ui::render_copy_feedback_buffer( + &mut composed, + feedback_area, + feedback, + offset, + self.config.clipboard_toast_position, + &self.config.palette, + ); + frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); + } + self.hits.popup = None; + if let Some(popup) = surface.popup.as_deref() { + let width = popup.width.map(client_popup_size); + let height = popup.height.map(client_popup_size); + if let Some(geometry) = + crate::popup_size::resolve_popup_geometry(width, height, layout.pane_surface) + { + let mut composed = frame.to_ratatui_buffer()?; + let block = ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::ALL) + .border_style(ratatui::style::Style::default().fg(self.config.palette.accent)) + .title(popup.title.clone()) + .style(ratatui::style::Style::default().bg(self.config.palette.panel_bg)); + ratatui::widgets::Widget::render( + ratatui::widgets::Clear, + geometry.outer, + &mut composed, + ); + ratatui::widgets::Widget::render(block, geometry.outer, &mut composed); + frame.replace_from_ratatui_buffer_preserving_effects(&composed, None); + blit_pane_surface(&mut frame, &popup.frame, geometry.inner); + self.hits.popup = Some(PaneHit { + rect: geometry.outer, + inner_rect: geometry.inner, + scrollbar_rect: None, + scroll: None, + pane_id: popup.terminal_id.clone(), + popup: true, + mouse_reporting: popup.mouse_reporting, + sgr_pixel_mouse: popup.sgr_pixel_mouse, + pixel_width: popup.pixel_width, + pixel_height: popup.pixel_height, + }); + } + } + if !layout.mobile_header.is_empty() + && self.mode == ClientShellMode::Navigate + && self.overlay.is_none() + { + let mut composed = frame.to_ratatui_buffer()?; + super::mobile::render_mobile_switcher( + &mut composed, + Rect::new(0, 0, cols, rows), + snapshot, + &self.config, + self.navigate_workspace_id.as_deref(), + &mut self.mobile_switcher_scroll, + &mut self.reveal_mobile_workspace, + &mut self.hits, + ); + frame.replace_from_ratatui_buffer_preserving_effects(&composed, None); + self.hits.panes.clear(); + self.hits.pane_splits.clear(); + self.hits.popup = None; + } + restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); + if let Some(overlay) = self.overlay.as_ref() { + let mut composed = frame.to_ratatui_buffer()?; + let cursor = if let ClientShellOverlay::ContextMenu(menu) = overlay { + self.hits.context_menu_rows = + render::render_context_menu(&mut composed, menu, &self.config.palette)?; + None + } else if let ClientShellOverlay::GlobalMenu(menu) = overlay { + self.hits.global_menu_rows = render::render_global_menu( + &mut composed, + self.hits.global_launcher, + menu, + snapshot, + &self.config.palette, + )?; + None + } else { + let rendered = render::render_client_overlay( + &mut composed, + overlay, + snapshot, + &self.config.keybinds, + &self.config.palette, + )?; + self.hits.overlay_primary = rendered.primary; + self.hits.overlay_clear = rendered.clear; + self.hits.overlay_cancel = rendered.cancel; + self.hits.navigator_popup = rendered.navigator_popup; + self.hits.navigator_search = rendered.navigator_search; + self.hits.navigator_rows = rendered.navigator_rows; + self.hits.worktree_search = rendered.worktree_search; + self.hits.worktree_rows = rendered.worktree_rows; + self.hits.help_popup = rendered.help_popup; + self.hits.help_scrollbar = rendered.help_scrollbar; + self.hits.help_scroll_metrics = rendered.help_scroll_metrics; + self.hits.help_max_scroll = rendered.help_max_scroll; + self.hits.settings_popup = rendered.settings_popup; + self.hits.settings_tabs = rendered.settings_tabs; + self.hits.settings_choices = rendered.settings_choices; + self.hits.product_announcement_scrollbar = rendered.product_announcement_scrollbar; + self.hits.product_announcement_scroll_metrics = + rendered.product_announcement_scroll_metrics; + self.hits.product_announcement_max_scroll = + rendered.product_announcement_max_scroll; + self.hits.release_notes_scrollbar = rendered.release_notes_scrollbar; + self.hits.release_notes_scroll_metrics = rendered.release_notes_scroll_metrics; + self.hits.release_notes_max_scroll = rendered.release_notes_max_scroll; + rendered.cursor + }; + frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); + } + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = help.scroll.min(self.hits.help_max_scroll); + } + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = self.overlay.as_mut() { + announcement.scroll = announcement + .scroll + .min(u16::try_from(self.hits.product_announcement_max_scroll).unwrap_or(u16::MAX)); + } + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + notes.scroll = notes + .scroll + .min(u16::try_from(self.hits.release_notes_max_scroll).unwrap_or(u16::MAX)); + } + self.compose_graphics(&mut frame, layout); + Some(frame) + } +} + +fn client_copy_surface_coherent(copy_mode: Option<&ClientCopyModeState>, hit: &PaneHit) -> bool { + copy_mode + .filter(|copy_mode| copy_mode.pane_id == hit.pane_id) + .is_none_or(|copy_mode| { + copy_mode.geometry == (hit.inner_rect.width, hit.inner_rect.height) + && hit.scroll.is_some_and(|scroll| { + scroll.offset_from_bottom == copy_mode.offset_from_bottom + && scroll.max_offset_from_bottom == copy_mode.max_offset_from_bottom + }) + }) +} + +fn render_client_copy_search_highlights( + buffer: &mut Buffer, + copy_mode: Option<&ClientCopyModeState>, + hit: &PaneHit, + palette: &Palette, + current_only: bool, +) { + let Some(copy_mode) = copy_mode.filter(|copy_mode| copy_mode.pane_id == hit.pane_id) else { + return; + }; + if hit.inner_rect.is_empty() { + return; + } + let top = copy_mode + .max_offset_from_bottom + .saturating_sub(copy_mode.offset_from_bottom) + .min(u32::MAX as usize) as u32; + let bottom = top.saturating_add(u32::from(hit.inner_rect.height.saturating_sub(1))); + let style = if current_only { + Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text).bg(palette.surface1) + }; + for (index, text_match) in copy_mode.search_matches.iter().enumerate() { + if (copy_mode.search_current == Some(index)) != current_only + || text_match.end.row < top + || text_match.start.row > bottom + { + continue; + } + let start_row = text_match.start.row.max(top); + let end_row = text_match.end.row.min(bottom); + for absolute_row in start_row..=end_row { + let viewport_row = absolute_row.saturating_sub(top) as u16; + let start_col = if absolute_row == text_match.start.row { + text_match.start.col + } else { + 0 + }; + let end_col = if absolute_row == text_match.end.row { + text_match.end.col + } else { + hit.inner_rect.width.saturating_sub(1) + }; + for col in start_col..=end_col.min(hit.inner_rect.width.saturating_sub(1)) { + buffer[( + hit.inner_rect.x.saturating_add(col), + hit.inner_rect.y.saturating_add(viewport_row), + )] + .set_style(style); + } + } + } +} + +fn client_popup_size(size: crate::protocol::ClientShellPopupSize) -> crate::popup_size::PopupSize { + match size { + crate::protocol::ClientShellPopupSize::Cells(cells) => { + crate::popup_size::PopupSize::Cells(cells) + } + crate::protocol::ClientShellPopupSize::Percent(percent) => { + crate::popup_size::PopupSize::Percent(percent) + } + } +} diff --git a/src/client/shell/config.rs b/src/client/shell/config.rs new file mode 100644 index 00000000..0273cefb --- /dev/null +++ b/src/client/shell/config.rs @@ -0,0 +1,503 @@ +use super::*; + +pub(super) fn merged_config_diagnostic( + local: Option<&str>, + endpoint: Option<&str>, +) -> Option<String> { + match (local, endpoint) { + (Some(local), Some(endpoint)) if local == endpoint => { + Some(format!("client + endpoint: {local}")) + } + (Some(local), Some(endpoint)) => Some(format!("client: {local}\nendpoint: {endpoint}")), + (Some(local), None) => Some(local.to_owned()), + (None, Some(endpoint)) => Some(endpoint.to_owned()), + (None, None) => None, + } +} + +impl ClientShellState { + pub(super) fn set_local_config_diagnostic(&mut self, diagnostic: Option<String>) { + self.local_config_diagnostic = diagnostic; + self.config_diagnostic = merged_config_diagnostic( + self.local_config_diagnostic.as_deref(), + self.snapshot + .as_deref() + .and_then(|snapshot| snapshot.config_diagnostic.as_deref()), + ); + } + + pub(super) fn persist_chrome_preferences(&mut self, outcome: &mut ClientShellInput) { + let Some(path) = self.config.preferences_path.as_deref() else { + return; + }; + let mut collapsed_groups = self.collapsed_groups.iter().cloned().collect::<Vec<_>>(); + collapsed_groups.sort(); + let preferences = preferences::ClientChromePreferences { + sidebar_width: self.sidebar_width_manual.then_some(self.sidebar_width), + sidebar_section_split: self + .sidebar_section_split_manual + .then_some(self.sidebar_section_split), + sidebar_collapsed: self + .sidebar_collapsed_manual + .then_some(self.sidebar_collapsed), + agent_panel_sort: self + .agent_panel_sort_manual + .then_some(self.config.agent_panel_sort), + collapsed_groups, + }; + if let Err(error) = preferences::store(path, preferences) { + self.endpoint_error = Some(error); + outcome.repaint = true; + } + } + + pub(crate) fn reload_client_config(&mut self) { + match crate::config::load_live_config() { + Ok(loaded) => { + let agent_panel_sort = self.config.agent_panel_sort; + let diagnostics = self.config.apply_live_config( + &loaded.config, + &loaded.diagnostics, + &loaded.invalid_sections, + ); + if let Some(appearance) = self.host_appearance { + self.config.palette = crate::app::client_palette_for_appearance( + &self.config.theme_runtime, + appearance, + ); + } + if !self.sidebar_width_manual { + self.sidebar_width = self.config.sidebar_width; + } + if self.agent_panel_sort_manual { + self.config.agent_panel_sort = agent_panel_sort; + } + self.set_local_config_diagnostic(self.config.local_config_diagnostic(&diagnostics)); + if let Some(snapshot) = self.snapshot.as_deref() { + let profile = snapshot.server_keybindings_toml.clone(); + let commands = snapshot.commands.clone(); + if let Err(err) = self + .config + .apply_snapshot_keybindings(profile.as_deref(), &commands) + { + self.endpoint_error = Some(err); + } + } + } + Err(diagnostics) => { + self.set_local_config_diagnostic(self.config.local_config_diagnostic(&diagnostics)); + } + } + self.reconcile_input_source(); + } +} + +impl ClientShellConfig { + pub(crate) fn from_config(config: &Config) -> Self { + let theme_runtime = crate::app::client_theme_runtime_from_config(config); + Self { + sidebar_width: config.ui.sidebar_width, + sidebar_min_width: config.ui.sidebar_min_width, + sidebar_max_width: config.ui.sidebar_max_width, + sidebar_start_collapsed: config.ui.sidebar_start_collapsed, + sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode, + mobile_width_threshold: config.ui.mobile_width_threshold, + tab_bar_position: config.ui.tab_bar_position, + hide_tab_bar_when_single_tab: config.ui.hide_tab_bar_when_single_tab, + spaces: config.ui.sidebar.spaces.clone(), + agents: config.ui.sidebar.agents.clone(), + agent_panel_sort: config.ui.agent_panel_sort, + status_indicators: config.ui.status_indicators, + sound_enabled: config.ui.sound.enabled, + toast_delivery: config.ui.toast.delivery, + toast_delay_seconds: config.ui.toast.delay_seconds, + toast_position: config.ui.toast.herdr.position, + copy_on_select: config.ui.copy_on_select, + clipboard_toast_enabled: config.ui.toast.clipboard.enabled, + clipboard_toast_position: config.ui.toast.clipboard.position, + theme_name: theme_runtime.manual_name.clone(), + theme_runtime, + palette: crate::app::client_palette_from_config(config), + keybinds: config + .live_keybinds_with_diagnostics() + .map(|(keybinds, _diagnostics)| keybinds) + .unwrap_or_else(|_diagnostics| LiveKeybindConfig { + prefix: config.prefix_key(), + keybinds: config.keybinds(), + }), + local_keys: config.keys.clone(), + keybinding_source: ClientShellKeybindingSource::Local, + prompt_new_tab_name: config.ui.prompt_new_tab_name, + prompt_new_workspace_name: config.ui.prompt_new_workspace_name, + confirm_close: config.ui.confirm_close, + mouse_capture: config.ui.mouse_capture, + mouse_scroll_lines: config.ui.mouse_scroll_lines(), + right_click_passthrough_modifiers: config.ui.right_click_passthrough_modifiers(), + redraw_on_focus_gained: config.ui.redraw_on_focus_gained, + switch_ascii_input_source_in_prefix: config + .experimental + .switch_ascii_input_source_in_prefix, + local_config_path: crate::config::config_path(), + preferences_path: None, + preferences: preferences::ClientChromePreferences::default(), + startup_config_diagnostic: None, + startup_onboarding: false, + } + } + + pub(crate) fn with_startup_config_diagnostic(mut self, diagnostic: Option<String>) -> Self { + self.startup_config_diagnostic = diagnostic; + self + } + + pub(crate) fn with_startup_onboarding(mut self, show: bool) -> Self { + self.startup_onboarding = show; + self + } + + pub(crate) fn with_keybinding_source(mut self, source: ClientShellKeybindingSource) -> Self { + self.keybinding_source = source; + self.keybinds.keybinds.custom_commands.clear(); + self + } + + pub(crate) fn uses_endpoint_keybindings(&self) -> bool { + self.keybinding_source == ClientShellKeybindingSource::Endpoint + } + + pub(super) fn local_config_diagnostic(&self, diagnostics: &[String]) -> Option<String> { + if self.uses_endpoint_keybindings() { + crate::config::config_diagnostic_summary_without_keybindings(diagnostics) + } else { + crate::config::config_diagnostic_summary(diagnostics) + } + } + + pub(crate) fn with_local_endpoint(self, socket_path: &std::path::Path) -> Self { + self.with_preferences_path(preferences::path_for_local_endpoint(socket_path)) + } + + pub(super) fn with_preferences_path(mut self, path: std::path::PathBuf) -> Self { + self.preferences = preferences::load(&path).unwrap_or_default(); + self.preferences_path = Some(path); + self + } + + pub(super) fn apply_snapshot_keybindings( + &mut self, + profile: Option<&str>, + commands: &[crate::protocol::ClientShellCommand], + ) -> Result<(), String> { + let mut keybinds = match self.keybinding_source { + ClientShellKeybindingSource::Endpoint => crate::config::keybindings_from_profile_toml( + profile.ok_or("endpoint did not publish its keybindings")?, + )?, + ClientShellKeybindingSource::RemoteLocal => return Ok(()), + ClientShellKeybindingSource::Local => { + let mut config = crate::config::Config { + keys: self.local_keys.clone(), + ..Default::default() + }; + config.keys.command = commands + .iter() + .map(|command| crate::config::CommandKeybindConfig { + key: if command.binding_labels.len() == 1 { + crate::config::BindingConfig::One(command.binding_labels[0].clone()) + } else { + crate::config::BindingConfig::Many(command.binding_labels.clone()) + }, + // The client never executes this field; preserve the opaque endpoint ID + // through the shared config collision resolver. + command: command.command_id.clone(), + action_type: match command.action { + crate::protocol::ClientShellCommandAction::Shell => { + crate::config::CommandKeybindType::Shell + } + crate::protocol::ClientShellCommandAction::Pane => { + crate::config::CommandKeybindType::Pane + } + crate::protocol::ClientShellCommandAction::Popup => { + crate::config::CommandKeybindType::Popup + } + crate::protocol::ClientShellCommandAction::PluginAction => { + crate::config::CommandKeybindType::PluginAction + } + }, + description: command.description.clone(), + width: None, + height: None, + }) + .collect(); + config + .live_keybinds_with_diagnostics() + .map(|(keybinds, _diagnostics)| keybinds) + .map_err(|diagnostics| diagnostics.join("; "))? + } + }; + if self.keybinding_source == ClientShellKeybindingSource::Endpoint { + for command in commands { + keybinds + .keybinds + .custom_commands + .push(crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::from_labels( + &command.binding_labels, + )?, + label: command.binding_label.clone(), + command: command.command_id.clone(), + action: command.action.into(), + description: command.description.clone(), + width: None, + height: None, + }); + } + } + self.keybinds = keybinds; + Ok(()) + } + + pub(super) fn apply_live_config( + &mut self, + config: &Config, + load_diagnostics: &[String], + invalid_sections: &[String], + ) -> Vec<String> { + let mut diagnostics = load_diagnostics.to_vec(); + let invalid_section = + |section: &str| invalid_sections.iter().any(|invalid| invalid == section); + + if !invalid_section("keys") + && self.keybinding_source != ClientShellKeybindingSource::Endpoint + { + match config.live_keybinds_with_diagnostics() { + Ok((mut keybinds, keybind_diagnostics)) => { + self.local_keys = config.keys.clone(); + if self.keybinding_source == ClientShellKeybindingSource::RemoteLocal { + keybinds.keybinds.custom_commands.clear(); + } + self.keybinds = keybinds; + diagnostics.extend(keybind_diagnostics); + } + Err(keybind_diagnostics) => diagnostics.extend( + keybind_diagnostics + .into_iter() + .map(|diagnostic| format!("{diagnostic}; kept current keybinds")), + ), + } + } + + if !invalid_section("ui") { + if let Some(diagnostic) = config.invalid_sidebar_bounds_diagnostic() { + diagnostics.push(format!("{diagnostic}; keeping previous [ui] settings")); + } else { + let ui = &config.ui; + diagnostics.extend(ui.sound.diagnostics()); + self.sidebar_width = ui.sidebar_width; + self.sidebar_min_width = ui.sidebar_min_width; + self.sidebar_max_width = ui.sidebar_max_width; + self.sidebar_collapsed_mode = ui.sidebar_collapsed_mode; + self.mobile_width_threshold = ui.mobile_width_threshold; + self.tab_bar_position = ui.tab_bar_position; + self.hide_tab_bar_when_single_tab = ui.hide_tab_bar_when_single_tab; + self.spaces = ui.sidebar.spaces.clone(); + self.agents = ui.sidebar.agents.clone(); + self.agent_panel_sort = ui.agent_panel_sort; + self.status_indicators = ui.status_indicators; + self.sound_enabled = ui.sound.enabled; + self.toast_delivery = ui.toast.delivery; + self.toast_delay_seconds = ui.toast.delay_seconds; + self.toast_position = ui.toast.herdr.position; + self.copy_on_select = ui.copy_on_select; + self.clipboard_toast_enabled = ui.toast.clipboard.enabled; + self.clipboard_toast_position = ui.toast.clipboard.position; + self.prompt_new_tab_name = ui.prompt_new_tab_name; + self.prompt_new_workspace_name = ui.prompt_new_workspace_name; + self.confirm_close = ui.confirm_close; + self.mouse_capture = ui.mouse_capture; + self.mouse_scroll_lines = ui.mouse_scroll_lines(); + self.right_click_passthrough_modifiers = ui.right_click_passthrough_modifiers(); + self.redraw_on_focus_gained = ui.redraw_on_focus_gained; + } + } + + if !invalid_section("theme") { + self.theme_runtime = crate::app::client_theme_runtime_from_config(config); + self.theme_name = self.theme_runtime.manual_name.clone(); + self.palette = crate::app::client_palette_from_config(config); + } + if !invalid_section("experimental") { + self.switch_ascii_input_source_in_prefix = + config.experimental.switch_ascii_input_source_in_prefix; + } + + diagnostics + } + + pub(super) fn layout( + &self, + cols: u16, + rows: u16, + sidebar_collapsed: bool, + tab_count: usize, + sidebar_width: u16, + ) -> ClientShellLayout { + if cols <= self.mobile_width_threshold { + let header_height = rows.min(2); + return ClientShellLayout { + sidebar: Rect::default(), + tab_bar: Rect::default(), + mobile_header: Rect::new(0, 0, cols, header_height), + pane_surface: Rect::new(0, header_height, cols, rows.saturating_sub(header_height)), + }; + } + + let sidebar_width = if sidebar_collapsed { + match self.sidebar_collapsed_mode { + SidebarCollapsedModeConfig::Compact => 4, + SidebarCollapsedModeConfig::Hidden => 0, + } + } else { + let (min, max) = crate::config::validated_sidebar_bounds( + self.sidebar_min_width, + self.sidebar_max_width, + ) + .unwrap_or((18, 36)); + sidebar_width.clamp(min, max) + } + .min(cols.saturating_sub(1)); + let main = Rect::new(sidebar_width, 0, cols.saturating_sub(sidebar_width), rows); + let show_tab_bar = rows > 1 && !(self.hide_tab_bar_when_single_tab && tab_count == 1); + let tab_height = u16::from(show_tab_bar); + let (tab_bar, pane_surface) = match self.tab_bar_position { + TabBarPositionConfig::Top => ( + Rect::new(main.x, 0, main.width, tab_height), + Rect::new( + main.x, + tab_height, + main.width, + rows.saturating_sub(tab_height), + ), + ), + TabBarPositionConfig::Bottom => ( + Rect::new( + main.x, + rows.saturating_sub(tab_height), + main.width, + tab_height, + ), + Rect::new(main.x, 0, main.width, rows.saturating_sub(tab_height)), + ), + }; + + ClientShellLayout { + sidebar: Rect::new(0, 0, sidebar_width, rows), + tab_bar, + mobile_header: Rect::default(), + pane_surface, + } + } + + pub(crate) fn initial_surface_size(&self, cols: u16, rows: u16) -> ClientSurfaceSize { + let sidebar_collapsed = self + .preferences + .sidebar_collapsed + .unwrap_or(self.sidebar_start_collapsed); + let (min_width, max_width) = + crate::config::validated_sidebar_bounds(self.sidebar_min_width, self.sidebar_max_width) + .unwrap_or((18, 36)); + let sidebar_width = self + .preferences + .sidebar_width + .unwrap_or(self.sidebar_width) + .clamp(min_width, max_width); + let surface = self + .layout(cols, rows, sidebar_collapsed, 0, sidebar_width) + .pane_surface; + ClientSurfaceSize { + cols: surface.width.max(1), + rows: surface.height.max(1), + } + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyModifiers}; + + use super::*; + + #[test] + fn live_reload_applies_client_owned_sections() { + let mut shell = ClientShellConfig::from_config(&Config::default()); + let mut next = Config::default(); + next.ui.sidebar_width = 31; + next.ui.tab_bar_position = TabBarPositionConfig::Bottom; + next.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; + next.ui.status_indicators = crate::config::StatusIndicatorStyle::Symbols; + next.ui.sidebar.agents.row_gap = 2; + next.keys.prefix = "ctrl+a".to_owned(); + + let diagnostics = shell.apply_live_config(&next, &[], &[]); + + assert!(diagnostics.is_empty()); + assert_eq!(shell.sidebar_width, 31); + assert_eq!(shell.tab_bar_position, TabBarPositionConfig::Bottom); + assert_eq!( + shell.agent_panel_sort, + crate::config::AgentPanelSortConfig::Priority + ); + assert_eq!( + shell.status_indicators, + crate::config::StatusIndicatorStyle::Symbols + ); + assert_eq!(shell.agents.row_gap, 2); + assert_eq!( + shell.keybinds.prefix, + (KeyCode::Char('a'), KeyModifiers::CONTROL) + ); + } + + #[test] + fn initial_surface_size_uses_persisted_endpoint_chrome() { + let path = std::env::temp_dir().join(format!( + "herdr-initial-shell-preferences-{}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + preferences::store( + &path, + preferences::ClientChromePreferences { + sidebar_width: Some(31), + sidebar_collapsed: Some(true), + ..preferences::ClientChromePreferences::default() + }, + ) + .expect("persist endpoint chrome"); + let config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let initial = config.initial_surface_size(100, 30); + let state = ClientShellState::new(config); + assert_eq!(initial, state.surface_size(100, 30)); + std::fs::remove_file(path).expect("remove endpoint chrome"); + } + + #[test] + fn live_reload_preserves_invalid_client_owned_sections() { + let mut initial = Config::default(); + initial.ui.sidebar_width = 29; + initial.keys.prefix = "ctrl+x".to_owned(); + let mut shell = ClientShellConfig::from_config(&initial); + + let mut invalid = Config::default(); + invalid.ui.sidebar_width = 35; + invalid.keys.prefix = "ctrl+a".to_owned(); + let invalid_sections = vec!["ui".to_owned(), "keys".to_owned()]; + shell.apply_live_config(&invalid, &[], &invalid_sections); + + assert_eq!(shell.sidebar_width, 29); + assert_eq!( + shell.keybinds.prefix, + (KeyCode::Char('x'), KeyModifiers::CONTROL) + ); + } +} diff --git a/src/client/shell/context_menu.rs b/src/client/shell/context_menu.rs new file mode 100644 index 00000000..af271bb6 --- /dev/null +++ b/src/client/shell/context_menu.rs @@ -0,0 +1,469 @@ +use super::*; + +impl ClientContextMenuOverlay { + pub(super) fn items(&self) -> Vec<ClientContextMenuItem> { + use ClientContextMenuAction as Action; + + let item = |label, action| ClientContextMenuItem { label, action }; + match &self.target { + ClientContextMenuTarget::Workspace { is_git: false, .. } => { + vec![item("Rename", Action::Rename), item("Close", Action::Close)] + } + ClientContextMenuTarget::Workspace { + is_linked_worktree: false, + has_worktree_children: false, + .. + } => vec![ + item("Rename", Action::Rename), + item("Close", Action::Close), + item("New worktree", Action::NewWorktree), + item("Open worktree...", Action::OpenWorktree), + ], + ClientContextMenuTarget::Workspace { + is_linked_worktree: true, + .. + } => vec![ + item("Rename", Action::Rename), + item("Close", Action::Close), + item("Delete worktree checkout...", Action::RemoveWorktree), + ], + ClientContextMenuTarget::Workspace { + has_worktree_children: true, + collapsed, + .. + } => vec![ + item("Rename", Action::Rename), + item("Close group", Action::Close), + item("New worktree", Action::NewWorktree), + item("Open worktree...", Action::OpenWorktree), + item( + if *collapsed { "Expand" } else { "Collapse" }, + Action::ToggleGroup, + ), + ], + ClientContextMenuTarget::Tab { .. } => vec![ + item("New tab", Action::NewTab), + item("Rename", Action::Rename), + item("Close", Action::Close), + ], + ClientContextMenuTarget::Pane { + source_pane_id, + has_manual_label, + right_click_passthrough, + .. + } => { + let mut items = vec![item("Rename pane", Action::RenamePane)]; + if *has_manual_label { + items.push(item("Clear pane name", Action::ClearPaneName)); + } + if source_pane_id.is_some() { + items.push(item("Swap with focused pane", Action::SwapWithFocusedPane)); + } + items.extend([ + item("Split right", Action::SplitRight), + item("Split down", Action::SplitDown), + item("Zoom", Action::Zoom), + item( + if *right_click_passthrough { + "Use Herdr right-click menu" + } else { + "Send right-clicks to pane" + }, + Action::ToggleRightClickPassthrough, + ), + item("Close pane", Action::ClosePane), + ]); + items + } + } + } +} + +impl ClientShellState { + pub(super) fn open_workspace_context_menu(&mut self, workspace_id: String, x: u16, y: u16) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(workspace) = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + else { + return; + }; + let worktree = workspace.worktree.as_ref(); + let has_worktree_children = worktree.is_some_and(|worktree| { + !worktree.is_linked_worktree + && snapshot + .workspaces + .iter() + .filter(|candidate| { + candidate + .worktree + .as_ref() + .is_some_and(|candidate| candidate.key == worktree.key) + }) + .count() + >= 2 + }); + let collapsed = + worktree.is_some_and(|worktree| self.collapsed_groups.contains(&worktree.key)); + self.overlay = Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + target: ClientContextMenuTarget::Workspace { + workspace_id, + is_git: worktree.is_some() || workspace.branch.is_some(), + is_linked_worktree: worktree.is_some_and(|worktree| worktree.is_linked_worktree), + has_worktree_children, + collapsed, + }, + x, + y, + highlighted: 0, + })); + } + + pub(super) fn open_tab_context_menu(&mut self, tab_id: String, x: u16, y: u16) { + let Some(tab) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.tabs.iter().find(|tab| tab.tab_id == tab_id)) + else { + return; + }; + self.overlay = Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + target: ClientContextMenuTarget::Tab { + tab_id, + workspace_id: tab.workspace_id.clone(), + }, + x, + y, + highlighted: 0, + })); + } + + pub(super) fn open_pane_context_menu(&mut self, pane_id: String, x: u16, y: u16) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(pane) = snapshot.panes.iter().find(|pane| pane.pane_id == pane_id) else { + return; + }; + let source_pane_id = snapshot + .focused_pane_id + .clone() + .filter(|focused| focused != &pane_id); + self.overlay = Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + target: ClientContextMenuTarget::Pane { + pane_id, + workspace_id: pane.workspace_id.clone(), + source_pane_id, + has_manual_label: pane.label.is_some(), + right_click_passthrough: pane.right_click_passthrough, + }, + x, + y, + highlighted: 0, + })); + } + + pub(super) fn move_context_menu_selection(&mut self, delta: isize) { + let Some(ClientShellOverlay::ContextMenu(menu)) = self.overlay.as_mut() else { + return; + }; + let item_count = menu.items().len(); + if item_count == 0 { + return; + } + menu.highlighted = (menu.highlighted as isize + delta) + .clamp(0, item_count.saturating_sub(1) as isize) as usize; + } + + pub(super) fn activate_context_menu_item( + &mut self, + index: usize, + outcome: &mut ClientShellInput, + ) { + let Some(ClientShellOverlay::ContextMenu(menu)) = self.overlay.take() else { + return; + }; + let Some(action) = menu.items().get(index).map(|item| item.action) else { + outcome.repaint = true; + return; + }; + match menu.target { + ClientContextMenuTarget::Workspace { workspace_id, .. } => { + self.activate_workspace_context_action(workspace_id, action, outcome) + } + ClientContextMenuTarget::Tab { + tab_id, + workspace_id, + } => self.activate_tab_context_action(tab_id, workspace_id, action, outcome), + ClientContextMenuTarget::Pane { + pane_id, + workspace_id, + source_pane_id, + right_click_passthrough, + .. + } => self.activate_pane_context_action( + pane_id, + workspace_id, + source_pane_id, + right_click_passthrough, + action, + outcome, + ), + } + outcome.repaint = true; + } + + fn activate_workspace_context_action( + &mut self, + workspace_id: String, + action: ClientContextMenuAction, + outcome: &mut ClientShellInput, + ) { + use crate::input::KeybindAction; + + match action { + ClientContextMenuAction::Rename => { + let label = self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + }) + .map(|workspace| workspace.label.clone()); + if let Some(label) = label { + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename workspace", + input: label, + replace_on_type: false, + target: ClientRenameTarget::Workspace { workspace_id }, + })); + } + } + ClientContextMenuAction::Close => { + if self.config.confirm_close { + self.open_confirm_close_overlay(workspace_id); + } else { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceClose( + crate::api::schema::WorkspaceCloseParams { + workspace_id, + close_group: true, + }, + ), + outcome, + ); + } + } + ClientContextMenuAction::NewWorktree => { + self.begin_worktree_action_for(KeybindAction::NewWorktree, workspace_id, outcome) + } + ClientContextMenuAction::OpenWorktree => { + self.begin_worktree_action_for(KeybindAction::OpenWorktree, workspace_id, outcome) + } + ClientContextMenuAction::RemoveWorktree => { + self.begin_worktree_action_for(KeybindAction::RemoveWorktree, workspace_id, outcome) + } + ClientContextMenuAction::ToggleGroup => { + let key = self.snapshot.as_deref().and_then(|snapshot| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + .and_then(|workspace| workspace.worktree.as_ref()) + .map(|worktree| worktree.key.clone()) + }); + if let Some(key) = key { + if !self.collapsed_groups.remove(&key) { + self.collapsed_groups.insert(key); + } + self.persist_chrome_preferences(outcome); + } + } + _ => {} + } + } + + fn activate_tab_context_action( + &mut self, + tab_id: String, + workspace_id: String, + action: ClientContextMenuAction, + outcome: &mut ClientShellInput, + ) { + use crate::api::schema::{Method, TabTarget}; + + self.push_endpoint_method( + Method::TabFocus(TabTarget { + tab_id: tab_id.clone(), + }), + outcome, + ); + match action { + ClientContextMenuAction::NewTab => { + if self.config.prompt_new_tab_name { + let default_name = (self + .snapshot + .as_deref() + .map(|snapshot| { + snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .count() + }) + .unwrap_or(0) + + 1) + .to_string(); + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "new tab", + input: default_name.clone(), + replace_on_type: true, + target: ClientRenameTarget::NewTab { + workspace_id, + default_name, + }, + })); + } else { + self.push_endpoint_method( + Method::TabCreate(crate::api::schema::TabCreateParams { + workspace_id: Some(workspace_id), + cwd: None, + focus: true, + label: None, + env: Default::default(), + }), + outcome, + ); + } + } + ClientContextMenuAction::Rename => { + let tab = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.tabs.iter().find(|tab| tab.tab_id == tab_id)); + if let Some(tab) = tab { + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename tab", + input: tab.label.clone(), + replace_on_type: false, + target: ClientRenameTarget::Tab { + tab_id, + auto_name: !tab.custom_label, + original_name: tab.label.clone(), + }, + })); + } + } + ClientContextMenuAction::Close => { + self.push_endpoint_method(Method::TabClose(TabTarget { tab_id }), outcome); + } + _ => {} + } + } + + fn activate_pane_context_action( + &mut self, + pane_id: String, + workspace_id: String, + source_pane_id: Option<String>, + right_click_passthrough: bool, + action: ClientContextMenuAction, + outcome: &mut ClientShellInput, + ) { + use crate::api::schema::{ + Method, PaneInputSetParams, PaneRenameParams, PaneRightClickTarget, PaneSplitParams, + PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, SplitDirection, + }; + + match action { + ClientContextMenuAction::RenamePane => { + let label = self.snapshot.as_deref().and_then(|snapshot| { + snapshot + .panes + .iter() + .find(|pane| pane.pane_id == pane_id) + .and_then(|pane| pane.label.clone()) + }); + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename pane", + input: label.clone().unwrap_or_default(), + replace_on_type: label.is_none(), + target: ClientRenameTarget::Pane { pane_id }, + })); + } + ClientContextMenuAction::ClearPaneName => self.push_endpoint_method( + Method::PaneRename(PaneRenameParams { + pane_id, + label: None, + }), + outcome, + ), + ClientContextMenuAction::SwapWithFocusedPane => { + if let Some(source_pane_id) = source_pane_id { + self.push_endpoint_method( + Method::PaneSwap(PaneSwapParams { + pane_id: None, + direction: None, + source_pane_id: Some(source_pane_id.clone()), + target_pane_id: Some(pane_id), + }), + outcome, + ); + self.push_endpoint_method( + Method::PaneFocus(PaneTarget { + pane_id: source_pane_id, + }), + outcome, + ); + } + } + ClientContextMenuAction::SplitRight | ClientContextMenuAction::SplitDown => { + self.push_endpoint_method( + Method::PaneSplit(PaneSplitParams { + workspace_id: Some(workspace_id), + target_pane_id: Some(pane_id), + direction: if action == ClientContextMenuAction::SplitRight { + SplitDirection::Right + } else { + SplitDirection::Down + }, + ratio: None, + cwd: None, + focus: true, + right_click: Default::default(), + env: Default::default(), + }), + outcome, + ); + } + ClientContextMenuAction::Zoom => self.push_endpoint_method( + Method::PaneZoom(PaneZoomParams { + pane_id: Some(pane_id), + mode: PaneZoomMode::Toggle, + }), + outcome, + ), + ClientContextMenuAction::ToggleRightClickPassthrough => self.push_endpoint_method( + Method::PaneInputSet(PaneInputSetParams { + pane_id, + right_click: if right_click_passthrough { + PaneRightClickTarget::Herdr + } else { + PaneRightClickTarget::Pane + }, + }), + outcome, + ), + ClientContextMenuAction::ClosePane => { + self.push_endpoint_method(Method::PaneClose(PaneTarget { pane_id }), outcome) + } + _ => {} + } + } +} diff --git a/src/client/shell/copy_mode.rs b/src/client/shell/copy_mode.rs new file mode 100644 index 00000000..c39a7507 --- /dev/null +++ b/src/client/shell/copy_mode.rs @@ -0,0 +1,871 @@ +use super::*; +use crossterm::event::{KeyCode, KeyModifiers}; + +impl ClientShellState { + pub(super) fn reset_copy_pipeline(&mut self) { + self.copy_session_generation = self.copy_session_generation.saturating_add(1); + self.copy_operation_in_flight = false; + self.copy_operation_queue.clear(); + self.copy_input_queue.clear(); + } + + pub(super) fn enter_copy_mode(&mut self, outcome: &mut ClientShellInput) -> bool { + let pane_id = match self.focused_pane_id() { + Some(pane_id) => pane_id, + None => return false, + }; + if self + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.pane_id == pane_id) + { + self.mode = ClientShellMode::Copy; + return true; + } + if self.copy_mode.is_some() { + self.exit_copy_mode(false, outcome); + } + let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| hit.pane_id == pane_id) + .cloned() + else { + return false; + }; + let Some(metrics) = hit.scroll else { + return false; + }; + let viewport_top = metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom) + .min(u32::MAX as usize) as u32; + let cursor = self + .pane_surface + .as_ref() + .and_then(|surface| { + let pane = surface.panes.iter().find(|pane| pane.pane_id == pane_id)?; + let cursor = surface + .frame + .cursor + .as_ref() + .filter(|cursor| cursor.visible)?; + let inner = pane.inner_rect; + (cursor.x >= inner.x + && cursor.x < inner.x.saturating_add(inner.width) + && cursor.y >= inner.y + && cursor.y < inner.y.saturating_add(inner.height)) + .then_some(crate::api::schema::PaneTextPoint { + row: viewport_top.saturating_add(u32::from(cursor.y - inner.y)), + col: cursor.x - inner.x, + }) + }) + .unwrap_or(crate::api::schema::PaneTextPoint { + row: viewport_top + .saturating_add(u32::from(hit.inner_rect.height.saturating_sub(1))), + col: 0, + }); + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + self.reset_copy_pipeline(); + let content_revision = self + .pane_surface + .as_ref() + .and_then(|surface| surface.panes.iter().find(|pane| pane.pane_id == pane_id)) + .map_or(0, |pane| pane.content_revision); + self.copy_mode = Some(ClientCopyModeState { + pane_id, + content_revision, + geometry: (hit.inner_rect.width, hit.inner_rect.height), + cursor, + offset_from_bottom: metrics.offset_from_bottom, + max_offset_from_bottom: metrics.max_offset_from_bottom, + entry_offset_from_bottom: metrics.offset_from_bottom, + selection: None, + search_prompt: None, + search_query: String::new(), + search_direction: None, + search_matches: Vec::new(), + search_total: 0, + search_current: None, + search_current_global: None, + search_generation: 0, + copy_after_search: false, + }); + self.mode = ClientShellMode::Copy; + true + } + + pub(super) fn route_copy_mode_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + if self.route_copy_search_prompt_key(key, outcome) { + return; + } + match key.code { + KeyCode::Esc => { + let should_clear = self.copy_mode.as_ref().is_some_and(|copy_mode| { + copy_mode.selection.is_some() + || !copy_mode.search_query.is_empty() + || !copy_mode.search_matches.is_empty() + || copy_mode.search_direction.is_some() + }); + if should_clear { + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.selection = None; + copy_mode.search_query.clear(); + copy_mode.search_direction = None; + copy_mode.search_matches.clear(); + copy_mode.search_total = 0; + copy_mode.search_current = None; + copy_mode.search_current_global = None; + copy_mode.search_generation = copy_mode.search_generation.saturating_add(1); + copy_mode.copy_after_search = false; + } + self.selection = None; + } else { + self.exit_copy_mode(false, outcome); + } + outcome.repaint = true; + return; + } + KeyCode::Enter => { + if !self.defer_copy_until_search_result() { + self.exit_copy_mode(true, outcome); + } + return; + } + KeyCode::Left => { + self.move_copy_cursor(0, -1, outcome); + return; + } + KeyCode::Down => { + self.move_copy_cursor(1, 0, outcome); + return; + } + KeyCode::Up => { + self.move_copy_cursor(-1, 0, outcome); + return; + } + KeyCode::Right => { + self.move_copy_cursor(0, 1, outcome); + return; + } + KeyCode::PageUp => { + self.move_copy_page(-1, false, outcome); + return; + } + KeyCode::PageDown => { + self.move_copy_page(1, false, outcome); + return; + } + KeyCode::Home => { + self.set_copy_cursor_col(0); + self.sync_copy_selection(); + outcome.repaint = true; + return; + } + KeyCode::End => { + self.request_copy_motion(crate::api::schema::PaneCopyMotion::LineEnd, outcome); + return; + } + _ => {} + } + + match (key.code, key.modifiers) { + (KeyCode::Char('b'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { + self.move_copy_page(-1, false, outcome); + return; + } + (KeyCode::Char('f'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { + self.move_copy_page(1, false, outcome); + return; + } + (KeyCode::Char('u'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { + self.move_copy_page(-1, true, outcome); + return; + } + (KeyCode::Char('d'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { + self.move_copy_page(1, true, outcome); + return; + } + _ => {} + } + + let Some(command) = crate::copy_mode::copy_mode_command_char(key.clone()) else { + return; + }; + match command { + 'q' => self.exit_copy_mode(false, outcome), + 'y' => { + if !self.defer_copy_until_search_result() { + self.exit_copy_mode(true, outcome); + } + } + 'v' | ' ' => self.begin_copy_selection(false), + 'V' => self.begin_copy_selection(true), + 'h' => self.move_copy_cursor(0, -1, outcome), + 'j' => self.move_copy_cursor(1, 0, outcome), + 'k' => self.move_copy_cursor(-1, 0, outcome), + 'l' => self.move_copy_cursor(0, 1, outcome), + 'g' => self.move_copy_history(true, outcome), + 'G' => self.move_copy_history(false, outcome), + '0' => { + self.set_copy_cursor_col(0); + self.sync_copy_selection(); + outcome.repaint = true; + } + '$' => self.request_copy_motion(crate::api::schema::PaneCopyMotion::LineEnd, outcome), + '^' => { + self.request_copy_motion(crate::api::schema::PaneCopyMotion::FirstNonBlank, outcome) + } + '/' => self.open_copy_search(crate::api::schema::PaneCopySearchDirection::Forward), + '?' => self.open_copy_search(crate::api::schema::PaneCopySearchDirection::Backward), + 'n' => self.repeat_copy_search(false, outcome), + 'N' => self.repeat_copy_search(true, outcome), + 'w' => { + self.request_copy_motion(crate::api::schema::PaneCopyMotion::NextWordStart, outcome) + } + 'b' => self.request_copy_motion( + crate::api::schema::PaneCopyMotion::PreviousWordStart, + outcome, + ), + 'e' => { + self.request_copy_motion(crate::api::schema::PaneCopyMotion::NextWordEnd, outcome) + } + 'W' => self.request_copy_motion( + crate::api::schema::PaneCopyMotion::NextBigWordStart, + outcome, + ), + 'B' => self.request_copy_motion( + crate::api::schema::PaneCopyMotion::PreviousBigWordStart, + outcome, + ), + 'E' => self + .request_copy_motion(crate::api::schema::PaneCopyMotion::NextBigWordEnd, outcome), + '{' => self.request_copy_motion( + crate::api::schema::PaneCopyMotion::PreviousParagraph, + outcome, + ), + '}' => { + self.request_copy_motion(crate::api::schema::PaneCopyMotion::NextParagraph, outcome) + } + _ => return, + } + outcome.repaint = true; + } + + fn route_copy_search_prompt_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) -> bool { + let Some(prompt) = self + .copy_mode + .as_ref() + .and_then(|copy_mode| copy_mode.search_prompt.as_ref()) + else { + return false; + }; + let mut submit = None; + match key.code { + KeyCode::Esc => { + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.search_prompt = None; + } + } + KeyCode::Enter => { + submit = Some((prompt.query.clone(), prompt.direction)); + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.search_prompt = None; + } + } + KeyCode::Backspace => { + if let Some(prompt) = self + .copy_mode + .as_mut() + .and_then(|copy_mode| copy_mode.search_prompt.as_mut()) + { + prompt.query.pop(); + } + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(prompt) = self + .copy_mode + .as_mut() + .and_then(|copy_mode| copy_mode.search_prompt.as_mut()) + { + prompt.query.clear(); + } + } + _ => { + if let Some(ch) = crate::copy_mode::copy_mode_command_char(key.clone()) { + if let Some(prompt) = self + .copy_mode + .as_mut() + .and_then(|copy_mode| copy_mode.search_prompt.as_mut()) + { + prompt.query.push(ch); + } + } + } + } + if let Some((query, direction)) = submit { + self.request_copy_search(query, direction, false, outcome); + } + outcome.repaint = true; + true + } + + pub(super) fn insert_copy_search_text(&mut self, text: &str) -> bool { + let Some(prompt) = self + .copy_mode + .as_mut() + .and_then(|copy_mode| copy_mode.search_prompt.as_mut()) + else { + return false; + }; + prompt + .query + .extend(text.chars().filter(|character| !character.is_control())); + true + } + + fn open_copy_search(&mut self, direction: crate::api::schema::PaneCopySearchDirection) { + let Some(copy_mode) = self.copy_mode.as_mut() else { + return; + }; + copy_mode.search_prompt = Some(ClientCopySearchPrompt { + direction, + query: String::new(), + }); + } + + fn repeat_copy_search(&mut self, reverse: bool, outcome: &mut ClientShellInput) { + let Some(copy_mode) = self.copy_mode.as_ref() else { + return; + }; + if copy_mode.search_query.is_empty() { + return; + } + let Some(direction) = copy_mode.search_direction else { + return; + }; + let direction = if reverse { + match direction { + crate::api::schema::PaneCopySearchDirection::Forward => { + crate::api::schema::PaneCopySearchDirection::Backward + } + crate::api::schema::PaneCopySearchDirection::Backward => { + crate::api::schema::PaneCopySearchDirection::Forward + } + } + } else { + direction + }; + self.request_copy_search(copy_mode.search_query.clone(), direction, true, outcome); + } + + fn defer_copy_until_search_result(&mut self) -> bool { + let Some(copy_mode) = self.copy_mode.as_ref() else { + return false; + }; + let pane_id = copy_mode.pane_id.clone(); + let generation = copy_mode.search_generation; + let pending = self.pending_requests.values().any(|pending| { + matches!( + &pending.kind, + PendingEndpointKind::CopySearch { + pane_id: pending_pane, + generation: pending_generation, + .. + } if pending_pane == &pane_id && *pending_generation == generation + ) + }) || self + .copy_operation_queue + .iter() + .any(|operation| matches!(operation, ClientCopyOperation::Search { .. })); + if pending { + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.copy_after_search = true; + } + } + pending + } + + fn request_copy_search( + &mut self, + query: String, + direction: crate::api::schema::PaneCopySearchDirection, + repeat: bool, + outcome: &mut ClientShellInput, + ) { + if query.is_empty() || self.copy_mode.is_none() { + return; + } + self.copy_operation_queue + .push_back(ClientCopyOperation::Search { + query, + direction, + repeat, + }); + self.dispatch_next_copy_operation(outcome); + } + + pub(super) fn apply_copy_search_result( + &mut self, + pane_id: &str, + origin: crate::api::schema::PaneTextPoint, + query: String, + direction: crate::api::schema::PaneCopySearchDirection, + repeat: bool, + generation: u64, + result: ClientCopySearchResult, + outcome: &mut ClientShellInput, + ) -> bool { + let search_queued = self + .copy_operation_queue + .iter() + .any(|operation| matches!(operation, ClientCopyOperation::Search { .. })); + let Some(copy_mode) = self.copy_mode.as_mut() else { + return false; + }; + if copy_mode.pane_id != pane_id + || copy_mode.cursor != origin + || copy_mode.content_revision != result.content_revision + || copy_mode.search_generation != generation + { + return false; + } + let current = result.current.filter(|index| *index < result.matches.len()); + copy_mode.search_query = query; + if !repeat { + copy_mode.search_direction = Some(direction); + } + copy_mode.search_matches = result.matches; + copy_mode.search_total = result.total; + copy_mode.search_current = current; + copy_mode.search_current_global = result.current_global; + let target = current.and_then(|index| copy_mode.search_matches.get(index).copied()); + let copy_after_search = if search_queued { + false + } else { + std::mem::take(&mut copy_mode.copy_after_search) + }; + if let Some(target) = target { + copy_mode.cursor = target.start; + self.reveal_copy_cursor(outcome, true); + self.sync_copy_selection(); + } + if copy_after_search { + self.exit_copy_mode(true, outcome); + } + outcome.repaint = true; + true + } + + pub(super) fn complete_copy_operation( + &mut self, + session_generation: u64, + continue_queue: bool, + outcome: &mut ClientShellInput, + ) { + if self.copy_session_generation != session_generation { + return; + } + self.copy_operation_in_flight = false; + if continue_queue && self.copy_mode.is_some() { + self.dispatch_next_copy_operation(outcome); + self.dispatch_queued_copy_input(outcome); + } else { + self.copy_operation_queue.clear(); + self.copy_input_queue.clear(); + } + } + + fn dispatch_queued_copy_input(&mut self, outcome: &mut ClientShellInput) { + while !self.copy_operation_in_flight { + let Some(key) = self.copy_input_queue.pop_front() else { + return; + }; + self.handle_key(key, outcome); + } + } + + pub(super) fn cancel_deferred_copy_after_search(&mut self, generation: u64) { + if let Some(copy_mode) = self + .copy_mode + .as_mut() + .filter(|copy_mode| copy_mode.search_generation == generation) + { + copy_mode.copy_after_search = false; + } + } + + fn copy_hit(&self) -> Option<PaneHit> { + let pane_id = self.copy_mode.as_ref()?.pane_id.as_str(); + self.hits + .panes + .iter() + .find(|hit| hit.pane_id == pane_id) + .cloned() + } + + fn move_copy_cursor(&mut self, row_delta: i16, col_delta: i16, outcome: &mut ClientShellInput) { + let Some(hit) = self.copy_hit() else { + self.exit_copy_mode(false, outcome); + return; + }; + let Some(copy_mode) = self.copy_mode.as_mut() else { + return; + }; + if col_delta < 0 { + copy_mode.cursor.col = copy_mode + .cursor + .col + .saturating_sub(col_delta.unsigned_abs()); + } else if col_delta > 0 { + copy_mode.cursor.col = copy_mode + .cursor + .col + .saturating_add(col_delta as u16) + .min(hit.inner_rect.width.saturating_sub(1)); + } + let total_rows = copy_mode + .max_offset_from_bottom + .saturating_add(hit.inner_rect.height as usize) + .max(1); + if row_delta < 0 { + copy_mode.cursor.row = copy_mode + .cursor + .row + .saturating_sub(u32::from(row_delta.unsigned_abs())); + } else if row_delta > 0 { + copy_mode.cursor.row = copy_mode + .cursor + .row + .saturating_add(u32::from(row_delta as u16)) + .min(total_rows.saturating_sub(1).min(u32::MAX as usize) as u32); + } + self.reveal_copy_cursor(outcome, false); + self.sync_copy_selection(); + outcome.repaint = true; + } + + fn move_copy_page(&mut self, direction: i8, half_page: bool, outcome: &mut ClientShellInput) { + let Some(hit) = self.copy_hit() else { + return; + }; + let lines = crate::copy_mode::copy_mode_page_lines(hit.inner_rect.height, half_page); + let Some((pane_id, next_offset)) = self.copy_mode.as_mut().map(|copy_mode| { + if direction < 0 { + copy_mode.cursor.row = copy_mode.cursor.row.saturating_sub(lines as u32); + copy_mode.offset_from_bottom = copy_mode + .offset_from_bottom + .saturating_add(lines) + .min(copy_mode.max_offset_from_bottom); + } else { + let last_row = copy_mode + .max_offset_from_bottom + .saturating_add(hit.inner_rect.height as usize) + .saturating_sub(1) + .min(u32::MAX as usize) as u32; + copy_mode.cursor.row = copy_mode + .cursor + .row + .saturating_add(lines as u32) + .min(last_row); + copy_mode.offset_from_bottom = copy_mode.offset_from_bottom.saturating_sub(lines); + } + (copy_mode.pane_id.clone(), copy_mode.offset_from_bottom) + }) else { + return; + }; + self.push_pane_scroll_offset(pane_id, next_offset, outcome); + self.sync_copy_selection(); + outcome.repaint = true; + } + + fn move_copy_history(&mut self, top: bool, outcome: &mut ClientShellInput) { + let Some(hit) = self.copy_hit() else { + return; + }; + let Some((pane_id, offset_from_bottom)) = self.copy_mode.as_mut().map(|copy_mode| { + if top { + copy_mode.cursor.row = 0; + copy_mode.offset_from_bottom = copy_mode.max_offset_from_bottom; + } else { + copy_mode.cursor.row = copy_mode + .max_offset_from_bottom + .saturating_add(hit.inner_rect.height as usize) + .saturating_sub(1) + .min(u32::MAX as usize) as u32; + copy_mode.offset_from_bottom = 0; + } + (copy_mode.pane_id.clone(), copy_mode.offset_from_bottom) + }) else { + return; + }; + self.push_pane_scroll_offset(pane_id, offset_from_bottom, outcome); + self.sync_copy_selection(); + outcome.repaint = true; + } + + fn set_copy_cursor_col(&mut self, col: u16) { + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.cursor.col = col; + } + } + + fn reveal_copy_cursor(&mut self, outcome: &mut ClientShellInput, reserve_mode_bar_row: bool) { + let Some(hit) = self.copy_hit() else { + return; + }; + let request = self.copy_mode.as_mut().and_then(|copy_mode| { + let current_top = copy_mode + .max_offset_from_bottom + .saturating_sub(copy_mode.offset_from_bottom) as u32; + let max_cursor_row = hit + .inner_rect + .height + .saturating_sub(if reserve_mode_bar_row { 2 } else { 1 }); + let bottom = current_top.saturating_add(u32::from(max_cursor_row)); + let desired_top = if copy_mode.cursor.row < current_top { + copy_mode.cursor.row + } else if copy_mode.cursor.row > bottom { + copy_mode + .cursor + .row + .saturating_sub(u32::from(max_cursor_row)) + } else { + current_top + }; + let offset = copy_mode + .max_offset_from_bottom + .saturating_sub(desired_top as usize); + if offset == copy_mode.offset_from_bottom { + return None; + } + copy_mode.offset_from_bottom = offset; + Some((copy_mode.pane_id.clone(), offset)) + }); + if let Some((pane_id, offset)) = request { + self.push_pane_scroll_offset(pane_id, offset, outcome); + } + } + + fn begin_copy_selection(&mut self, linewise: bool) { + let end_col = self + .copy_hit() + .map_or(0, |hit| hit.inner_rect.width.saturating_sub(1)); + let Some(copy_mode) = self.copy_mode.as_mut() else { + return; + }; + if linewise { + copy_mode.selection = Some(ClientCopySelection::Linewise { + anchor_row: copy_mode.cursor.row, + }); + self.selection = Some(crate::selection::Selection::line_range( + copy_mode.pane_id.clone(), + copy_mode.cursor.row, + copy_mode.cursor.row, + end_col, + )); + } else { + copy_mode.selection = Some(ClientCopySelection::Character { + anchor: copy_mode.cursor, + }); + self.selection = Some(crate::selection::Selection::absolute_anchor( + copy_mode.pane_id.clone(), + (copy_mode.cursor.row, copy_mode.cursor.col), + )); + } + } + + pub(super) fn sync_copy_selection(&mut self) { + let Some(copy_mode) = self.copy_mode.as_ref() else { + return; + }; + let Some(selection) = copy_mode.selection else { + return; + }; + self.selection = Some(match selection { + ClientCopySelection::Character { anchor } => { + crate::selection::Selection::absolute_range( + copy_mode.pane_id.clone(), + (anchor.row, anchor.col), + (copy_mode.cursor.row, copy_mode.cursor.col), + ) + } + ClientCopySelection::Linewise { anchor_row } => { + crate::selection::Selection::line_range( + copy_mode.pane_id.clone(), + anchor_row, + copy_mode.cursor.row, + self.copy_hit() + .map_or(0, |hit| hit.inner_rect.width.saturating_sub(1)), + ) + } + }); + } + + fn request_copy_motion( + &mut self, + motion: crate::api::schema::PaneCopyMotion, + outcome: &mut ClientShellInput, + ) { + if self.copy_mode.is_none() { + return; + } + self.copy_operation_queue + .push_back(ClientCopyOperation::Motion(motion)); + self.dispatch_next_copy_operation(outcome); + } + + pub(super) fn dispatch_next_copy_operation(&mut self, outcome: &mut ClientShellInput) { + if self.copy_operation_in_flight { + return; + } + while let Some(operation) = self.copy_operation_queue.pop_front() { + let Some(copy_mode) = self.copy_mode.as_mut() else { + self.copy_operation_queue.clear(); + self.copy_input_queue.clear(); + return; + }; + let session_generation = self.copy_session_generation; + let pane_id = copy_mode.pane_id.clone(); + let origin = copy_mode.cursor; + let (method, kind) = match operation { + ClientCopyOperation::Motion(motion) => ( + crate::api::schema::Method::PaneCopyMotion( + crate::api::schema::PaneCopyMotionParams { + pane_id: pane_id.clone(), + cursor: origin, + motion, + content_revision: Some(copy_mode.content_revision), + }, + ), + PendingEndpointKind::CopyMotion { + pane_id, + origin, + session_generation, + }, + ), + ClientCopyOperation::Search { + query, + direction, + repeat, + } => { + if query.is_empty() { + continue; + } + copy_mode.search_generation = copy_mode.search_generation.saturating_add(1); + let generation = copy_mode.search_generation; + let previous = repeat + .then(|| { + copy_mode + .search_current + .and_then(|index| copy_mode.search_matches.get(index).copied()) + .filter(|text_match| text_match.start == copy_mode.cursor) + }) + .flatten(); + ( + crate::api::schema::Method::PaneCopySearch( + crate::api::schema::PaneCopySearchParams { + pane_id: pane_id.clone(), + query: query.clone(), + direction, + cursor: origin, + content_revision: copy_mode.content_revision, + previous, + }, + ), + PendingEndpointKind::CopySearch { + pane_id, + origin, + query, + direction, + repeat, + generation, + session_generation, + }, + ) + } + }; + self.copy_operation_in_flight = true; + self.push_endpoint_method_with_kind(method, kind, outcome); + return; + } + } + + pub(super) fn apply_copy_motion_target( + &mut self, + pane_id: &str, + origin: crate::api::schema::PaneTextPoint, + cursor: crate::api::schema::PaneTextPoint, + content_revision: u64, + outcome: &mut ClientShellInput, + ) -> bool { + let Some(copy_mode) = self.copy_mode.as_mut() else { + return false; + }; + if copy_mode.pane_id != pane_id + || copy_mode.cursor != origin + || copy_mode.content_revision != content_revision + { + return false; + } + copy_mode.cursor = cursor; + self.reveal_copy_cursor(outcome, false); + self.sync_copy_selection(); + outcome.repaint = true; + true + } + + pub(super) fn exit_copy_mode(&mut self, copy: bool, outcome: &mut ClientShellInput) { + if copy + && !self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible) + { + if let Some((pane_id, text_match)) = self.copy_mode.as_ref().and_then(|copy_mode| { + copy_mode + .search_current + .and_then(|index| copy_mode.search_matches.get(index).copied()) + .map(|text_match| (copy_mode.pane_id.clone(), text_match)) + }) { + self.selection = Some(crate::selection::Selection::absolute_range( + pane_id, + (text_match.start.row, text_match.start.col), + (text_match.end.row, text_match.end.col), + )); + } + } + let Some(copy_mode) = self.copy_mode.take() else { + return; + }; + self.reset_copy_pipeline(); + if copy + && self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible) + { + self.request_selection_copy(outcome); + } + self.selection = None; + self.selection_highlight_clear_deadline = None; + self.push_pane_scroll_offset( + copy_mode.pane_id, + copy_mode.entry_offset_from_bottom, + outcome, + ); + self.mode = ClientShellMode::Terminal; + outcome.repaint = true; + } +} diff --git a/src/client/shell/global_menu.rs b/src/client/shell/global_menu.rs new file mode 100644 index 00000000..e5bd6f78 --- /dev/null +++ b/src/client/shell/global_menu.rs @@ -0,0 +1,110 @@ +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ClientGlobalMenuAction { + Binding(crate::input::KeybindAction), + WhatsNew, +} + +pub(super) fn global_menu_attention(snapshot: &ClientShellSnapshot) -> bool { + snapshot.update_available.is_some() || snapshot.integration_updates_available +} + +pub(super) fn global_menu_item_has_badge( + snapshot: &ClientShellSnapshot, + action: ClientGlobalMenuAction, +) -> bool { + (action == ClientGlobalMenuAction::WhatsNew && snapshot.update_available.is_some()) + || (action == ClientGlobalMenuAction::Binding(crate::input::KeybindAction::Settings) + && snapshot.integration_updates_available) +} + +pub(super) fn global_menu_items( + snapshot: &ClientShellSnapshot, +) -> Vec<(&'static str, ClientGlobalMenuAction)> { + let mut items = vec![ + ( + "settings", + ClientGlobalMenuAction::Binding(crate::input::KeybindAction::Settings), + ), + ( + "keybinds", + ClientGlobalMenuAction::Binding(crate::input::KeybindAction::Help), + ), + ( + "reload config", + ClientGlobalMenuAction::Binding(crate::input::KeybindAction::ReloadConfig), + ), + ]; + if snapshot.update_available.is_some() || snapshot.latest_release_notes_available { + items.push(( + if snapshot.update_available.is_some() { + "update ready" + } else { + "what's new" + }, + ClientGlobalMenuAction::WhatsNew, + )); + } + items.push(( + "detach", + ClientGlobalMenuAction::Binding(crate::input::KeybindAction::Detach), + )); + items +} + +impl ClientShellState { + pub(super) fn toggle_global_menu(&mut self) { + if matches!(self.overlay, Some(ClientShellOverlay::GlobalMenu(_))) { + self.overlay = None; + } else { + self.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 0, + })); + } + } + + pub(super) fn move_global_menu_selection(&mut self, delta: isize) { + let item_count = self + .snapshot + .as_deref() + .map(global_menu_items) + .map_or(0, |items| items.len()); + let Some(ClientShellOverlay::GlobalMenu(menu)) = self.overlay.as_mut() else { + return; + }; + menu.highlighted = (menu.highlighted as isize + delta) + .clamp(0, item_count.saturating_sub(1) as isize) as usize; + } + + pub(super) fn activate_global_menu_item( + &mut self, + index: usize, + outcome: &mut ClientShellInput, + ) { + let Some(action) = self.snapshot.as_deref().and_then(|snapshot| { + global_menu_items(snapshot) + .get(index) + .map(|(_, action)| *action) + }) else { + return; + }; + if action == ClientGlobalMenuAction::WhatsNew + && self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.release_notes.as_ref()) + .is_none() + { + return; + } + self.overlay = None; + match action { + ClientGlobalMenuAction::Binding(binding) => { + self.record_binding(crate::input::KeybindMatch::Action(binding), outcome) + } + ClientGlobalMenuAction::WhatsNew => self.open_release_notes(), + } + outcome.repaint = true; + } +} diff --git a/src/client/shell/graphics.rs b/src/client/shell/graphics.rs new file mode 100644 index 00000000..1fc23414 --- /dev/null +++ b/src/client/shell/graphics.rs @@ -0,0 +1,67 @@ +use super::*; + +impl ClientShellState { + #[cfg(unix)] + pub(crate) fn graphics_scope(&self) -> &str { + self.snapshot + .as_deref() + .map(|snapshot| snapshot.boot_id.as_str()) + .unwrap_or_default() + } + + #[cfg(unix)] + pub(crate) fn trust_direct_graphics_asset( + &mut self, + key: &crate::protocol::SurfaceGraphicsAssetKey, + image_id: u32, + ) -> bool { + self.graphics.trust_direct_asset(key, image_id) + } + + #[cfg(unix)] + pub(crate) fn retire_direct_graphics_image(&mut self, image_id: u32) { + self.graphics.retire_direct_image(image_id); + } + + pub(crate) fn take_pending_graphics_cleanup(&mut self) -> Vec<u8> { + self.graphics.take_pending_cleanup() + } + + pub(crate) fn set_graphics_cell_size(&mut self, width_px: u32, height_px: u32) { + self.graphics_cell_size = crate::kitty_graphics::HostCellSize { + width_px: width_px.max(1), + height_px: height_px.max(1), + }; + } + + pub(super) fn compose_graphics(&mut self, frame: &mut FrameData, layout: ClientShellLayout) { + let local_cover = self.overlay.is_some() + || self.mode != ClientShellMode::Terminal + || self.endpoint_error.is_some() + || self.config_diagnostic.is_some() + || self.visible_notification.is_some() + || self.copy_feedback.is_some() + || self + .selection + .as_ref() + .is_some_and(|selection| selection.is_visible()); + let visibility = if local_cover { + crate::kitty_graphics::surface::Visibility::Hidden + } else if self.hits.popup.is_some() { + crate::kitty_graphics::surface::Visibility::Popup + } else { + crate::kitty_graphics::surface::Visibility::Main + }; + let popup_origin = self + .hits + .popup + .as_ref() + .map(|popup| (popup.inner_rect.x, popup.inner_rect.y)); + frame.graphics = self.graphics.encode( + visibility, + (layout.pane_surface.x, layout.pane_surface.y), + popup_origin, + self.graphics_cell_size, + ); + } +} diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs new file mode 100644 index 00000000..d3af1480 --- /dev/null +++ b/src/client/shell/input.rs @@ -0,0 +1,1011 @@ +use super::*; +use crate::protocol::ClientPaneInputEvent; +use crate::raw_input::RawInputEvent; +use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; + +const LOCAL_INPUT_SOURCE: u8 = 0; + +fn is_retained_selection_copy_key(key: &crate::input::TerminalKey) -> bool { + matches!(key.code, KeyCode::Char('c' | 'C')) + && matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER) +} + +pub(super) fn is_modal_paste_shortcut_for_platform( + key: &crate::input::TerminalKey, + macos: bool, +) -> bool { + matches!(key.code, KeyCode::Char('v' | 'V')) + && (key.modifiers.contains(KeyModifiers::CONTROL) + || macos && key.modifiers.contains(KeyModifiers::SUPER)) +} + +fn is_modal_paste_shortcut(key: &crate::input::TerminalKey) -> bool { + is_modal_paste_shortcut_for_platform(key, cfg!(target_os = "macos")) +} + +fn host_theme_update(event: &RawInputEvent) -> Option<crate::protocol::ClientHostThemeUpdate> { + use crate::protocol::{ + ClientHostAppearance, ClientHostDefaultColorKind, ClientHostThemeUpdate, + }; + + match event { + RawInputEvent::HostDefaultColor { kind, color } => { + Some(ClientHostThemeUpdate::DefaultColor { + kind: match kind { + crate::terminal_theme::DefaultColorKind::Foreground => { + ClientHostDefaultColorKind::Foreground + } + crate::terminal_theme::DefaultColorKind::Background => { + ClientHostDefaultColorKind::Background + } + }, + color: (*color).into(), + }) + } + RawInputEvent::HostPaletteColors { colors } => Some(ClientHostThemeUpdate::PaletteColors( + colors + .iter() + .map(|(index, color)| (*index, (*color).into())) + .collect(), + )), + RawInputEvent::HostColorSchemeChanged(appearance) => { + Some(ClientHostThemeUpdate::Appearance(match appearance { + crate::terminal_theme::HostAppearance::Dark => ClientHostAppearance::Dark, + crate::terminal_theme::HostAppearance::Light => ClientHostAppearance::Light, + })) + } + _ => None, + } +} + +impl ClientShellState { + pub(crate) fn host_keyboard_report_all_requested(&self) -> bool { + matches!( + self.mode, + ClientShellMode::Prefix | ClientShellMode::Navigate + ) + } + + #[cfg(any(unix, test))] + pub(crate) fn handle_input_bytes(&mut self, data: &[u8]) -> ClientShellInput { + self.handle_raw_events(crate::raw_input::parse_raw_input_bytes_sync(data)) + } + + #[cfg(any(unix, test))] + pub(crate) fn handle_pixel_mouse( + &mut self, + data: &[u8], + geometry: crate::input::mouse::HostGeometry, + ) -> ClientShellInput { + let Some((x, y)) = crate::input::mouse::parse_report(data) else { + return ClientShellInput::default(); + }; + let Some((column, row)) = geometry.cell(x, y) else { + return ClientShellInput::default(); + }; + let Some(cell_report) = crate::input::mouse::report_at_cell(data, column, row) else { + return ClientShellInput::default(); + }; + let events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report); + if events.len() != 1 || !matches!(events[0], RawInputEvent::Mouse(_)) { + return ClientShellInput::default(); + } + self.host_mouse_pixels = Some(crate::input::mouse::HostPixels { x, y, geometry }); + let outcome = self.handle_raw_events(events); + self.host_mouse_pixels = None; + outcome + } + + #[cfg(windows)] + pub(crate) fn handle_client_events( + &mut self, + events: &[crate::protocol::ClientInputEvent], + ) -> ClientShellInput { + self.handle_raw_events( + events + .iter() + .map(crate::protocol::ClientInputEvent::to_raw_input_event) + .collect(), + ) + } + + fn prepare_committed_text(&mut self, text: &str, outcome: &mut ClientShellInput) -> bool { + if self.insert_copy_search_text(text) { + outcome.repaint = true; + return true; + } + self.pending_word_selection = None; + if self.copy_or_terminal_mode() != ClientShellMode::Copy && self.selection.take().is_some() + { + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + outcome.repaint = true; + } + false + } + + pub(crate) fn replay_mouse_events( + &mut self, + events: Vec<crossterm::event::MouseEvent>, + ) -> ClientShellInput { + self.replaying_url_click = true; + let outcome = + self.handle_raw_events(events.into_iter().map(RawInputEvent::Mouse).collect()); + self.replaying_url_click = false; + outcome + } + + pub(super) fn handle_raw_events(&mut self, events: Vec<RawInputEvent>) -> ClientShellInput { + let mut outcome = ClientShellInput::default(); + if !events.is_empty() && self.endpoint_error.take().is_some() { + outcome.repaint = true; + } + for event in events { + if let Some(update) = host_theme_update(&event) { + outcome + .requests + .push(ClientMessage::ClientShellHostTheme { update }); + } + match event { + RawInputEvent::Key(key) => self.handle_key(key, &mut outcome), + RawInputEvent::Text(text) => { + let text = text.into_string(); + if matches!( + self.overlay, + Some( + ClientShellOverlay::Onboarding + | ClientShellOverlay::ProductAnnouncement(_) + | ClientShellOverlay::ReleaseNotes(_) + ) + ) { + self.reconcile_input_source(); + continue; + } + if self.prepare_committed_text(&text, &mut outcome) { + self.reconcile_input_source(); + continue; + } + if let Some(target) = self.popup_input_target() { + super::push_target_event( + target, + ClientPaneInputEvent::TextCommit(text), + &mut outcome, + ); + } else if !self.popup_pending { + if self.insert_overlay_text(&text) { + outcome.repaint = true; + } else if self.overlay.is_none() && self.mode == ClientShellMode::Terminal { + self.push_focused_pane_event( + ClientPaneInputEvent::TextCommit(text), + &mut outcome, + ); + } + } + } + RawInputEvent::Paste(text) => { + if matches!( + self.overlay, + Some( + ClientShellOverlay::Onboarding + | ClientShellOverlay::ProductAnnouncement(_) + | ClientShellOverlay::ReleaseNotes(_) + ) + ) { + self.reconcile_input_source(); + continue; + } + if self.prepare_committed_text(&text, &mut outcome) { + self.reconcile_input_source(); + continue; + } + if let Some(target) = self.popup_input_target() { + super::push_target_event( + target, + ClientPaneInputEvent::Paste(text), + &mut outcome, + ); + } else if !self.popup_pending { + if self.insert_overlay_text(&text) { + outcome.repaint = true; + } else if self.overlay.is_none() && self.mode == ClientShellMode::Terminal { + self.push_focused_pane_event( + ClientPaneInputEvent::Paste(text), + &mut outcome, + ); + } + } + } + RawInputEvent::Mouse(mouse) => self.handle_mouse(mouse, &mut outcome), + RawInputEvent::OuterFocusGained => { + self.outer_focused = Some(true); + outcome.query_host_appearance = true; + outcome.repaint |= self.config.redraw_on_focus_gained; + outcome + .requests + .push(ClientMessage::ClientShellFocus { focused: true }); + } + RawInputEvent::OuterFocusLost => { + self.outer_focused = Some(false); + self.release_input_leases(&mut outcome); + outcome + .requests + .push(ClientMessage::ClientShellFocus { focused: false }); + } + RawInputEvent::HostColorSchemeChanged(appearance) => { + self.host_appearance = Some(appearance); + self.host_appearance_explicit = true; + outcome.query_host_theme = true; + if self.config.theme_runtime.auto_switch { + self.config.palette = crate::app::client_palette_for_appearance( + &self.config.theme_runtime, + appearance, + ); + outcome.repaint = true; + } + } + RawInputEvent::HostDefaultColor { + kind: crate::terminal_theme::DefaultColorKind::Background, + color, + } if !self.host_appearance_explicit => { + let appearance = color.inferred_appearance(); + self.host_appearance = Some(appearance); + if self.config.theme_runtime.auto_switch { + self.config.palette = crate::app::client_palette_for_appearance( + &self.config.theme_runtime, + appearance, + ); + outcome.repaint = true; + } + } + RawInputEvent::HostDefaultColor { .. } + | RawInputEvent::HostPaletteColors { .. } + | RawInputEvent::HostCellSizeReport { .. } + | RawInputEvent::Unsupported => {} + } + self.reconcile_input_source(); + } + outcome.repaint |= self.resume_mobile_switcher_if_ready(); + outcome + } + + pub(super) fn handle_key( + &mut self, + key: crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + if self.copy_operation_in_flight { + self.copy_input_queue.push_back(key); + return; + } + let lease_key = crate::input::InputLeaseKey::new(LOCAL_INPUT_SOURCE, &key); + let key = self.input_leases.normalize_press(&lease_key, key); + match key.kind { + KeyEventKind::Press => { + let initial_context = self.input_context(); + let target = self.route_key_press(&key, outcome); + if let Some(target) = target.as_ref() { + self.push_pane_key(target.clone(), key.clone(), outcome); + } + let resulting_context = self.input_context(); + let plan = self.input_leases.complete_press( + lease_key, + &key, + Some(&initial_context), + Some(&resulting_context), + target, + ); + self.execute_repeat_plan(lease_key, key, plan, outcome); + } + KeyEventKind::Repeat => { + let context = self.input_context(); + let plan = self + .input_leases + .plan_repeat(lease_key, &key, Some(&context)); + self.execute_repeat_plan(lease_key, key, plan, outcome); + } + KeyEventKind::Release => { + if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) { + let release = lease + .key + .with_modifiers(key.modifiers) + .with_kind(KeyEventKind::Release); + self.push_pane_key(lease.target, release, outcome); + } else { + let _ = self.input_leases.remove(&lease_key); + } + } + } + } + + fn release_input_leases(&mut self, outcome: &mut ClientShellInput) { + for lease in self.input_leases.remove_source(LOCAL_INPUT_SOURCE) { + self.push_pane_key( + lease.target, + lease.key.with_kind(KeyEventKind::Release), + outcome, + ); + } + if let Some(gesture) = self.pane_mouse_gesture.take() { + let modifiers = gesture + .last_event + .modifiers + .difference(gesture.stripped_modifiers); + let geometry = matches!( + gesture.last_position, + crate::protocol::ClientMousePosition::Pixels { .. } + ) + .then_some(crate::protocol::ClientMouseGeometry { + cols: gesture.hit.inner_rect.width, + rows: gesture.hit.inner_rect.height, + width_px: gesture.hit.pixel_width, + height_px: gesture.hit.pixel_height, + }); + let target = if gesture.hit.popup { + ClientInputTarget::Popup(gesture.hit.pane_id) + } else { + ClientInputTarget::Pane(gesture.hit.pane_id) + }; + super::push_target_event( + target, + ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::from_crossterm(gesture.button), + ), + position: gesture.last_position, + geometry, + modifiers: modifiers.bits(), + lines: self.config.mouse_scroll_lines.min(u16::MAX as usize) as u16, + }, + outcome, + ); + } + self.copy_input_queue.clear(); + } + + fn execute_repeat_plan( + &mut self, + lease_key: crate::input::InputLeaseKey<u8>, + key: crate::input::TerminalKey, + plan: crate::input::RepeatPlan<ClientInputContext, ClientInputTarget>, + outcome: &mut ClientShellInput, + ) { + match plan { + crate::input::RepeatPlan::Forwarded(target) => { + let pane_blocked_by_popup = matches!(&target, ClientInputTarget::Pane(_)) + && (self.popup_pending || self.popup_terminal_id.is_some()); + if !pane_blocked_by_popup { + self.push_pane_key(target, key, outcome); + } + } + crate::input::RepeatPlan::Reprocess { + context, + repetitions, + tracked, + } => { + for _ in 0..repetitions { + let current = self.input_context(); + if !self.input_leases.reprocess_allowed( + lease_key, + &context, + Some(¤t), + tracked, + ) { + break; + } + let repeated = key + .clone() + .with_repeat_count(1) + .with_kind(KeyEventKind::Repeat); + if let Some(target) = self.route_key_press(&repeated, outcome) { + self.push_pane_key(target, repeated, outcome); + } + } + } + crate::input::RepeatPlan::Ignore => {} + } + } + + pub(super) fn modal_paste_target_active(&self) -> bool { + if self.popup_pending || self.popup_input_target().is_some() { + return false; + } + if self + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.search_prompt.is_some()) + { + return self.overlay.is_none(); + } + matches!( + self.overlay.as_ref(), + Some(ClientShellOverlay::Rename(_)) + | Some(ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { + creating: false, + .. + } + )) + | Some(ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { + search_focused: true, + opening: false, + .. + } + )) + | Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { + search_focused: true, + .. + })) + | Some(ClientShellOverlay::Help(ClientHelpOverlay { + search_focused: true, + .. + })) + ) + } + + pub(super) fn handle_modal_paste_shortcut_with( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + read_clipboard_text: impl FnOnce() -> Option<String>, + ) -> bool { + if !is_modal_paste_shortcut(key) || !self.modal_paste_target_active() { + return false; + } + if let Some(text) = read_clipboard_text() { + let inserted = self.insert_copy_search_text(&text) || self.insert_overlay_text(&text); + outcome.repaint |= inserted; + } + true + } + + fn route_key_press( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) -> Option<ClientInputTarget> { + if self.handle_modal_paste_shortcut_with(key, outcome, crate::platform::read_clipboard_text) + { + return None; + } + if matches!( + self.overlay, + Some( + ClientShellOverlay::Onboarding + | ClientShellOverlay::ProductAnnouncement(_) + | ClientShellOverlay::ReleaseNotes(_) + ) + ) { + if key.kind == KeyEventKind::Press { + self.route_overlay_key(key, outcome); + } + return None; + } + if let Some(target) = self.popup_input_target() { + return Some(target); + } + if self.popup_pending { + return None; + } + if self.overlay.is_some() { + self.route_overlay_key(key, outcome); + return None; + } + if matches!(key.code, KeyCode::Modifier(_)) { + return None; + } + self.pending_word_selection = None; + if self.mode != ClientShellMode::Copy + && self.copy_or_terminal_mode() != ClientShellMode::Copy + && !self.config.copy_on_select + && is_retained_selection_copy_key(key) + && self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible) + { + self.request_selection_copy_with_fallback(outcome, Some(key.clone())); + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + outcome.repaint = true; + return None; + } + if self.mode != ClientShellMode::Copy + && self.copy_or_terminal_mode() != ClientShellMode::Copy + && self.selection.take().is_some() + { + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + outcome.repaint = true; + } + + match self.mode { + ClientShellMode::Terminal => { + if let Some(binding) = + crate::input::resolve_direct_binding(&self.config.keybinds.keybinds, key) + { + self.record_binding(binding, outcome); + return None; + } + if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { + self.mode = ClientShellMode::Prefix; + outcome.repaint = true; + return None; + } + self.focused_pane_id().map(ClientInputTarget::Pane) + } + ClientShellMode::Prefix => { + let return_mode = if self.copy_mode.as_ref().is_some_and(|copy_mode| { + self.focused_pane_id().as_deref() == Some(copy_mode.pane_id.as_str()) + }) { + ClientShellMode::Copy + } else { + ClientShellMode::Terminal + }; + if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { + self.mode = return_mode; + outcome.repaint = true; + return self.focused_pane_id().map(ClientInputTarget::Pane); + } + if key.code == KeyCode::Esc { + self.mode = return_mode; + outcome.repaint = true; + return None; + } + if let Some(binding) = + crate::input::resolve_prefix_binding(&self.config.keybinds.keybinds, key) + { + self.mode = return_mode; + outcome.repaint = true; + self.record_binding(binding, outcome); + return None; + } + self.mode = return_mode; + outcome.repaint = true; + None + } + ClientShellMode::Navigate => { + self.route_navigate_key(key, outcome); + None + } + ClientShellMode::Resize => { + self.route_resize_key(key, outcome); + None + } + ClientShellMode::Copy => { + if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { + self.mode = ClientShellMode::Prefix; + outcome.repaint = true; + } else { + self.route_copy_mode_key(key, outcome); + } + None + } + } + } + + pub(super) fn copy_or_terminal_mode(&self) -> ClientShellMode { + if self.copy_mode.as_ref().is_some_and(|copy_mode| { + self.focused_pane_id().as_deref() == Some(copy_mode.pane_id.as_str()) + }) { + ClientShellMode::Copy + } else { + ClientShellMode::Terminal + } + } + + fn route_navigate_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + use crate::input::{KeybindAction, KeybindDispatch, KeybindMatch}; + + if key.code == KeyCode::Esc + || crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) + { + self.mode = self.copy_or_terminal_mode(); + self.navigate_workspace_id = None; + outcome.repaint = true; + return; + } + + if self + .config + .keybinds + .keybinds + .navigate + .workspace_up + .matches_direct_key(key) + { + self.move_navigate_workspace(-1); + outcome.repaint = true; + return; + } + if self + .config + .keybinds + .keybinds + .navigate + .workspace_down + .matches_direct_key(key) + { + self.move_navigate_workspace(1); + outcome.repaint = true; + return; + } + + if let Some(index) = ('1'..='9').position(|digit| { + crate::config::terminal_key_matches_combo( + key, + (KeyCode::Char(digit), KeyModifiers::empty()), + ) + }) { + let valid = self.snapshot.as_deref().is_some_and(|snapshot| { + self.navigation_workspace_entries(snapshot) + .get(index) + .is_some() + }); + if valid { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + self.record_binding( + KeybindMatch::Action(KeybindAction::SwitchWorkspace(index)), + outcome, + ); + outcome.repaint = true; + } + return; + } + + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + if modifiers.is_empty() { + match code { + KeyCode::Enter => { + let selected = self.navigate_workspace_id.clone(); + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + if let Some(workspace_id) = selected { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { workspace_id }, + ), + outcome, + ); + } + outcome.repaint = true; + return; + } + KeyCode::Tab => { + self.record_navigate_binding( + KeybindMatch::Action(KeybindAction::CyclePaneNext), + false, + outcome, + ); + return; + } + KeyCode::BackTab => { + self.record_navigate_binding( + KeybindMatch::Action(KeybindAction::CyclePanePrevious), + false, + outcome, + ); + return; + } + KeyCode::Left => { + self.record_navigate_binding( + KeybindMatch::Action(KeybindAction::FocusPaneLeft), + true, + outcome, + ); + return; + } + KeyCode::Right => { + self.record_navigate_binding( + KeybindMatch::Action(KeybindAction::FocusPaneRight), + true, + outcome, + ); + return; + } + _ => {} + } + } + + let pane_action = [ + ( + &self.config.keybinds.keybinds.navigate.pane_left, + KeybindAction::FocusPaneLeft, + ), + ( + &self.config.keybinds.keybinds.navigate.pane_down, + KeybindAction::FocusPaneDown, + ), + ( + &self.config.keybinds.keybinds.navigate.pane_up, + KeybindAction::FocusPaneUp, + ), + ( + &self.config.keybinds.keybinds.navigate.pane_right, + KeybindAction::FocusPaneRight, + ), + ] + .into_iter() + .find_map(|(bindings, action)| bindings.matches_direct_key(key).then_some(action)); + if let Some(action) = pane_action { + self.record_navigate_binding(KeybindMatch::Action(action), true, outcome); + return; + } + + let binding = crate::input::resolve_non_indexed_action( + &self.config.keybinds.keybinds, + key, + KeybindDispatch::Prefix, + ) + .filter(|action| { + !matches!( + action, + KeybindAction::FocusPaneLeft + | KeybindAction::FocusPaneDown + | KeybindAction::FocusPaneUp + | KeybindAction::FocusPaneRight + ) + }) + .map(KeybindMatch::Action) + .or_else(|| { + crate::input::resolve_custom_command( + &self.config.keybinds.keybinds, + key, + KeybindDispatch::Prefix, + ) + .map(KeybindMatch::Command) + }) + .or_else(|| { + crate::input::resolve_indexed_action( + &self.config.keybinds.keybinds, + key, + KeybindDispatch::Prefix, + ) + .map(KeybindMatch::Action) + }); + if let Some(binding) = binding { + self.record_navigate_binding(binding, false, outcome); + } + } + + fn record_navigate_binding( + &mut self, + binding: crate::input::KeybindMatch, + preserve_navigate: bool, + outcome: &mut ClientShellInput, + ) { + use crate::input::{KeybindAction, KeybindMatch}; + + let indexed_target_exists = match &binding { + KeybindMatch::Action(KeybindAction::SwitchWorkspace(index)) => { + self.snapshot.as_deref().is_some_and(|snapshot| { + self.navigation_workspace_entries(snapshot) + .get(*index) + .is_some() + }) + } + KeybindMatch::Action(KeybindAction::SwitchTab(index)) => self + .snapshot + .as_deref() + .and_then(|snapshot| { + let workspace_id = snapshot.focused_workspace_id.as_deref()?; + snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .nth(*index) + }) + .is_some(), + KeybindMatch::Action(KeybindAction::FocusAgent(index)) => { + self.snapshot.as_deref().is_some_and(|snapshot| { + super::agent_sidebar::ordered_agent_pane_ids( + snapshot, + self.config.agent_panel_sort, + ) + .get(*index) + .is_some() + }) + } + _ => true, + }; + if !indexed_target_exists { + return; + } + + if let KeybindMatch::Action(KeybindAction::CyclePaneNext) = binding { + self.cycle_pane(false, outcome); + } else if let KeybindMatch::Action(KeybindAction::CyclePanePrevious) = binding { + self.cycle_pane(true, outcome); + } else { + if !preserve_navigate { + self.mode = ClientShellMode::Terminal; + } + self.record_binding(binding, outcome); + } + if !preserve_navigate { + if self.mode == ClientShellMode::Navigate { + self.mode = self.copy_or_terminal_mode(); + } + self.navigate_workspace_id = None; + } + outcome.repaint = true; + } + + fn move_navigate_workspace(&mut self, delta: isize) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let mobile = self.mobile_layout_active(); + let entries = self.navigation_workspace_entries(snapshot); + if entries.is_empty() { + return; + } + let current = self + .navigate_workspace_id + .as_deref() + .and_then(|selected| { + entries + .iter() + .position(|entry| snapshot.workspaces[entry.index].workspace_id == selected) + }) + .unwrap_or(0); + let next = if mobile { + (current as isize + delta).clamp(0, entries.len().saturating_sub(1) as isize) as usize + } else { + (current as isize + delta).rem_euclid(entries.len() as isize) as usize + }; + let workspace_id = snapshot.workspaces[entries[next].index] + .workspace_id + .clone(); + self.navigate_workspace_id = Some(workspace_id.clone()); + self.reveal_mobile_workspace = mobile; + if !mobile { + self.reveal_workspace(&workspace_id); + } + } + + fn cycle_pane(&mut self, reverse: bool, outcome: &mut ClientShellInput) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(surface) = self.pane_surface.as_ref() else { + return; + }; + if surface.panes.is_empty() { + return; + } + let current = snapshot + .focused_pane_id + .as_deref() + .and_then(|focused| { + surface + .panes + .iter() + .position(|pane| pane.pane_id == focused) + }) + .unwrap_or(0); + let next = if reverse { + (current + surface.panes.len() - 1) % surface.panes.len() + } else { + (current + 1) % surface.panes.len() + }; + let pane_id = surface.panes[next].pane_id.clone(); + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }), + outcome, + ); + } + + fn route_resize_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + let resize_bindings = &self.config.keybinds.keybinds.resize_mode; + if key.code == KeyCode::Esc + || key.code == KeyCode::Enter + || resize_bindings.matches_prefix_key(key) + || resize_bindings.matches_direct_key(key) + { + self.mode = self.copy_or_terminal_mode(); + outcome.repaint = true; + return; + } + + let action = match key.code { + KeyCode::Char('h') | KeyCode::Left => Some(crate::input::KeybindAction::ResizePaneLeft), + KeyCode::Char('j') | KeyCode::Down => Some(crate::input::KeybindAction::ResizePaneDown), + KeyCode::Char('k') | KeyCode::Up => Some(crate::input::KeybindAction::ResizePaneUp), + KeyCode::Char('l') | KeyCode::Right => { + Some(crate::input::KeybindAction::ResizePaneRight) + } + _ => None, + }; + if let Some(action) = action { + self.record_binding(crate::input::KeybindMatch::Action(action), outcome); + } + } + + fn input_context(&self) -> ClientInputContext { + ClientInputContext { + mode: self.mode, + overlay: self.overlay.as_ref().map(ClientShellOverlay::kind), + popup_terminal_id: self.popup_input_target().and_then(|target| match target { + ClientInputTarget::Popup(terminal_id) => Some(terminal_id), + ClientInputTarget::Pane(_) => None, + }), + popup_pending: self.popup_pending, + retained_selection: self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible), + } + } + + pub(super) fn focused_pane_id(&self) -> Option<String> { + self.snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_pane_id.clone()) + } + + pub(crate) fn clipboard_image_target( + &self, + ) -> Option<crate::protocol::ClientClipboardImageTarget> { + if matches!( + self.overlay, + Some( + ClientShellOverlay::Onboarding + | ClientShellOverlay::ProductAnnouncement(_) + | ClientShellOverlay::ReleaseNotes(_) + ) + ) || self + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.search_prompt.is_some()) + { + return None; + } + if let Some(terminal_id) = self.popup_input_target().and_then(|target| match target { + ClientInputTarget::Popup(terminal_id) => Some(terminal_id), + ClientInputTarget::Pane(_) => None, + }) { + return Some(crate::protocol::ClientClipboardImageTarget::Popup( + terminal_id, + )); + } + if self.popup_pending || self.overlay.is_some() || self.mode != ClientShellMode::Terminal { + return None; + } + self.focused_pane_id() + .map(crate::protocol::ClientClipboardImageTarget::Pane) + } + + fn popup_input_target(&self) -> Option<ClientInputTarget> { + self.popup_terminal_id + .as_ref() + .map(|terminal_id| ClientInputTarget::Popup(terminal_id.clone())) + } + + fn push_pane_key( + &self, + target: ClientInputTarget, + key: crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + if let Some(event) = ClientPaneInputEvent::from_terminal_key(key) { + super::push_target_event(target, event, outcome); + } + } + + fn push_focused_pane_event(&self, event: ClientPaneInputEvent, outcome: &mut ClientShellInput) { + if let Some(pane_id) = self.focused_pane_id() { + super::push_target_event(ClientInputTarget::Pane(pane_id), event, outcome); + } + } +} diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs new file mode 100644 index 00000000..014f51a0 --- /dev/null +++ b/src/client/shell/mobile.rs @@ -0,0 +1,999 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph, Widget}, +}; + +use super::render::{display_width, put_segment, put_text}; +use super::*; + +const MOBILE_BUTTON_WIDTH: u16 = 10; + +struct MobileItem { + lines: Vec<Line<'static>>, + background: Color, + target: Option<ClientMobileTarget>, +} + +impl MobileItem { + fn section(label: impl Into<String>, palette: &Palette) -> Self { + Self { + lines: vec![Line::from(Span::styled( + format!(" {} ", label.into()), + Style::default() + .fg(palette.overlay1) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + ))], + background: palette.panel_bg, + target: None, + } + } + + fn action(label: &'static str, target: ClientMobileTarget, palette: &Palette) -> Self { + Self { + lines: vec![Line::from(Span::styled( + label, + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ))], + background: palette.panel_bg, + target: Some(target), + } + } +} + +pub(super) fn render_mobile_header( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + hits: &mut ShellHitMap, +) { + if area.is_empty() { + return; + } + let palette = &config.palette; + buffer.set_style(area, Style::default().bg(palette.panel_bg)); + let button_width = MOBILE_BUTTON_WIDTH.min(area.width); + let button = Rect::new( + area.right().saturating_sub(button_width), + area.y, + button_width, + area.height, + ); + hits.mobile_switch = button; + let status_width = button.x.saturating_sub(area.x).saturating_sub(1); + let status = Rect::new(area.x, area.y, status_width, area.height); + render_header_status(buffer, status, snapshot, config); + render_header_button(buffer, button, snapshot, config); +} + +fn render_header_status( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, +) { + if area.is_empty() { + return; + } + let palette = &config.palette; + let Some(workspace) = snapshot.focused_workspace_id.as_deref().and_then(|id| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == id) + }) else { + put_text( + buffer, + area.x, + area.y, + area.width, + " no workspace", + Style::default().fg(palette.text).bg(palette.panel_bg), + ); + return; + }; + let tab_status = compact_tab_status(snapshot, workspace); + let tab_width = display_width(&tab_status).saturating_add(1).min(area.width); + let name_width = area.width.saturating_sub(tab_width); + put_text( + buffer, + area.x, + area.y, + name_width.min(3), + &format!( + " {} ", + status_icon(workspace.agent_status, config.status_indicators) + ), + Style::default() + .fg(status_color(workspace.agent_status, palette)) + .bg(palette.panel_bg), + ); + put_text( + buffer, + area.x.saturating_add(3), + area.y, + name_width.saturating_sub(3), + &crate::ui::truncate_end(&workspace.label, usize::from(name_width.saturating_sub(4))), + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + area.right().saturating_sub(tab_width).saturating_add(1), + area.y, + tab_width.saturating_sub(1), + &tab_status, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + if area.height > 1 { + render_agent_summary( + buffer, + Rect::new(area.x, area.y + 1, area.width, 1), + snapshot, + config, + ); + } +} + +fn render_header_button( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, +) { + if area.is_empty() { + return; + } + let palette = &config.palette; + buffer.set_style(area, Style::default().bg(palette.surface0)); + for y in area.y..area.bottom() { + put_text( + buffer, + area.x, + y, + 1, + "│", + Style::default() + .fg(palette.surface_dim) + .bg(palette.surface0), + ); + } + let label_y = if area.height > 1 { area.y + 1 } else { area.y }; + let label = "switch"; + let label_width = display_width(label); + put_text( + buffer, + area.x + .saturating_add(1) + .saturating_add(area.width.saturating_sub(1 + label_width) / 2), + label_y, + area.width.saturating_sub(1), + label, + Style::default() + .fg(palette.text) + .bg(palette.surface0) + .add_modifier(Modifier::BOLD), + ); + if snapshot + .agents + .iter() + .any(|agent| agent.agent_status == crate::api::schema::AgentStatus::Blocked) + { + put_text( + buffer, + area.right().saturating_sub(1), + area.y, + 1, + status_icon( + crate::api::schema::AgentStatus::Blocked, + config.status_indicators, + ), + Style::default().fg(palette.red).bg(palette.surface0), + ); + } +} + +fn compact_tab_status(snapshot: &ClientShellSnapshot, workspace: &ClientShellWorkspace) -> String { + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace.workspace_id) + .collect::<Vec<_>>(); + let active = tabs + .iter() + .position(|tab| tab.tab_id == workspace.active_tab_id) + .unwrap_or(0); + let label = tabs + .get(active) + .map(|tab| tab.label.as_str()) + .unwrap_or("1"); + if tabs.len() <= 1 { + format!("tab {label}") + } else { + format!("tab {label} · {}/{}", active + 1, tabs.len()) + } +} + +fn render_agent_summary( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, +) { + use crate::api::schema::AgentStatus; + let counts = [ + (AgentStatus::Blocked, "blocked"), + (AgentStatus::Done, "done"), + (AgentStatus::Working, "working"), + (AgentStatus::Idle, "idle"), + ] + .map(|(status, label)| { + ( + status, + label, + snapshot + .agents + .iter() + .filter(|agent| agent.agent_status == status) + .count(), + ) + }); + let total = counts.iter().map(|(_, _, count)| count).sum::<usize>(); + let pending = counts[..3].iter().map(|(_, _, count)| count).sum::<usize>(); + if total == 0 { + put_text( + buffer, + area.x, + area.y, + area.width, + " no agents", + Style::default() + .fg(config.palette.overlay1) + .bg(config.palette.panel_bg), + ); + return; + } + if pending == 0 { + put_text( + buffer, + area.x, + area.y, + area.width, + " all idle", + Style::default() + .fg(config.palette.overlay1) + .bg(config.palette.panel_bg), + ); + return; + } + + let mut x = area.x.saturating_add(1); + let mut shown = 0usize; + let mut omitted = false; + for (status, label, count) in counts { + if count == 0 { + continue; + } + let symbol = match (config.status_indicators, status) { + (crate::config::StatusIndicatorStyle::Dots, AgentStatus::Blocked) => Some("◉"), + (crate::config::StatusIndicatorStyle::Dots, AgentStatus::Done) => Some("●"), + (crate::config::StatusIndicatorStyle::Dots, _) => None, + _ => Some(status_icon(status, config.status_indicators)), + }; + let text = symbol.map_or_else( + || format!("{count} {label}"), + |symbol| format!("{symbol} {count} {label}"), + ); + let separator = if shown == 0 { "" } else { " · " }; + let needed = display_width(separator).saturating_add(display_width(&text)); + if area.right().saturating_sub(x) < needed { + omitted = true; + break; + } + if !separator.is_empty() { + x = put_segment( + buffer, + x, + area.y, + area.right(), + separator, + Style::default() + .fg(config.palette.overlay0) + .bg(config.palette.panel_bg), + ); + } + let color = if shown == 0 { + match status { + AgentStatus::Done => config.palette.blue, + _ => status_color(status, &config.palette), + } + } else { + config.palette.overlay1 + }; + x = put_segment( + buffer, + x, + area.y, + area.right(), + &text, + Style::default() + .fg(color) + .bg(config.palette.panel_bg) + .add_modifier(if shown == 0 { + Modifier::BOLD + } else { + Modifier::empty() + }), + ); + shown += 1; + } + if omitted && area.right().saturating_sub(x) >= 2 { + put_text( + buffer, + x, + area.y, + 2, + " …", + Style::default() + .fg(config.palette.overlay0) + .bg(config.palette.panel_bg), + ); + } +} + +pub(super) fn render_mobile_switcher( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + selected_workspace_id: Option<&str>, + scroll: &mut usize, + reveal_workspace: &mut bool, + hits: &mut ShellHitMap, +) { + if area.is_empty() { + return; + } + let palette = &config.palette; + Clear.render(area, buffer); + buffer.set_style(area, Style::default().bg(palette.panel_bg)); + hits.mobile_switch = Rect::default(); + if area.height <= 2 { + *scroll = 0; + put_text( + buffer, + area.x, + area.y, + area.width, + &"─".repeat(usize::from(area.width)), + Style::default() + .fg(palette.surface_dim) + .bg(palette.panel_bg), + ); + return; + } + let header_height = area.height.min(2); + let close_width = MOBILE_BUTTON_WIDTH.min(area.width); + let close = Rect::new( + area.right().saturating_sub(close_width), + area.y, + close_width, + header_height, + ); + hits.mobile_close = close; + put_text( + buffer, + area.x, + area.y, + close.x.saturating_sub(area.x), + " switch", + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + render_close_button(buffer, close, palette); + let rule_y = area.y + header_height; + put_text( + buffer, + area.x, + rule_y, + area.width, + &"─".repeat(usize::from(area.width)), + Style::default() + .fg(palette.surface_dim) + .bg(palette.panel_bg), + ); + let viewport = Rect::new( + area.x, + rule_y.saturating_add(1), + area.width, + area.height.saturating_sub(header_height + 1), + ); + if viewport.is_empty() { + *scroll = 0; + return; + } + + let items = mobile_items( + snapshot, + config, + selected_workspace_id, + viewport.width.saturating_sub(1), + ); + let total_rows = items.iter().map(|item| item.lines.len()).sum::<usize>(); + let max_scroll = total_rows.saturating_sub(usize::from(viewport.height)); + *scroll = (*scroll).min(max_scroll); + if *reveal_workspace { + if let Some(selected_workspace_id) = selected_workspace_id { + let mut start = 0usize; + for item in &items { + let end = start.saturating_add(item.lines.len()); + if matches!( + item.target.as_ref(), + Some(ClientMobileTarget::Workspace(workspace_id)) + if workspace_id == selected_workspace_id + ) { + if start < *scroll { + *scroll = start; + } else if end > (*scroll).saturating_add(usize::from(viewport.height)) { + *scroll = end + .saturating_sub(usize::from(viewport.height)) + .min(max_scroll); + } + break; + } + start = end; + } + } + *reveal_workspace = false; + } + hits.mobile_max_scroll = max_scroll; + let content = if viewport.width > 1 { + Rect::new( + viewport.x + 1, + viewport.y, + viewport.width - 1, + viewport.height, + ) + } else { + Rect::default() + }; + if max_scroll > 0 { + render_left_scrollbar(buffer, viewport, total_rows, *scroll, palette); + } + if content.is_empty() { + return; + } + + let viewport_start = *scroll; + let viewport_end = viewport_start.saturating_add(usize::from(viewport.height)); + let mut document_row = 0usize; + for item in items { + let item_start = document_row; + let item_end = item_start.saturating_add(item.lines.len()); + let visible_start = item_start.max(viewport_start); + let visible_end = item_end.min(viewport_end); + if visible_start < visible_end { + let y = viewport.y + u16::try_from(visible_start - viewport_start).unwrap_or(u16::MAX); + let height = u16::try_from(visible_end - visible_start).unwrap_or(u16::MAX); + let rect = Rect::new(content.x, y, content.width, height); + buffer.set_style(rect, Style::default().bg(item.background)); + for row in visible_start..visible_end { + let line = item.lines[row - item_start].clone(); + Paragraph::new(line).render( + Rect::new( + content.x, + viewport.y + u16::try_from(row - viewport_start).unwrap_or(u16::MAX), + content.width, + 1, + ), + buffer, + ); + } + if let Some(target) = item.target { + hits.mobile_targets.push((rect, target)); + } + } + document_row = item_end; + } +} + +fn render_close_button(buffer: &mut Buffer, area: Rect, palette: &Palette) { + if area.is_empty() { + return; + } + buffer.set_style(area, Style::default().bg(palette.surface0)); + for y in area.y..area.bottom() { + put_text( + buffer, + area.x, + y, + 1, + "│", + Style::default() + .fg(palette.surface_dim) + .bg(palette.surface0), + ); + } + let label_width = 5; + let label_x = area + .x + .saturating_add(1) + .saturating_add(area.width.saturating_sub(1 + label_width) / 2); + put_text( + buffer, + label_x, + area.y, + area.width.saturating_sub(1), + "close", + Style::default() + .fg(palette.overlay1) + .bg(palette.surface0) + .add_modifier(Modifier::BOLD), + ); + if area.height > 1 { + put_text( + buffer, + area.x.saturating_add(area.width / 2), + area.y + 1, + 1, + "×", + Style::default() + .fg(palette.text) + .bg(palette.surface0) + .add_modifier(Modifier::BOLD), + ); + } +} + +fn mobile_items( + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + selected_workspace_id: Option<&str>, + content_width: u16, +) -> Vec<MobileItem> { + let palette = &config.palette; + let mut items = Vec::new(); + let ordered_agents = + super::agent_sidebar::ordered_agent_pane_ids(snapshot, config.agent_panel_sort); + if !ordered_agents.is_empty() || snapshot.agent_view_label.is_some() { + let title = snapshot + .agent_view_label + .as_deref() + .map(|label| format!("agents · {label}")) + .unwrap_or_else(|| "agents".to_owned()); + items.push(MobileItem::section(title, palette)); + if ordered_agents.is_empty() { + items.push(MobileItem { + lines: vec![Line::from(Span::styled( + " no matching agents", + Style::default() + .fg(palette.overlay0) + .bg(palette.panel_bg) + .add_modifier(Modifier::DIM), + ))], + background: palette.panel_bg, + target: None, + }); + } + for pane_id in ordered_agents { + let Some(agent) = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id) + else { + continue; + }; + let workspace = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == agent.workspace_id); + let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id); + let agent_label = agent + .display_agent + .as_deref() + .or(agent.name.as_deref()) + .or(agent.agent.as_deref()) + .unwrap_or("agent"); + let primary = workspace + .map(|workspace| workspace.label.as_str()) + .unwrap_or(agent_label); + let mut detail = Vec::new(); + let workspace_tab_count = snapshot + .tabs + .iter() + .filter(|candidate| candidate.workspace_id == agent.workspace_id) + .count(); + if let Some(tab) = tab.filter(|tab| tab.custom_label || workspace_tab_count > 1) { + detail.push(tab.label.clone()); + } + let status_key = status_text(agent.agent_status); + detail.push( + agent + .state_labels + .iter() + .find(|(key, _)| key == status_key) + .map(|(_, label)| label.clone()) + .unwrap_or_else(|| { + if agent.agent_status == crate::api::schema::AgentStatus::Unknown { + "idle".to_owned() + } else { + status_key.to_owned() + } + }), + ); + detail.push(agent_label.to_owned()); + let background = if agent.focused { + palette.surface_dim + } else { + palette.panel_bg + }; + items.push(MobileItem { + lines: vec![ + Line::from(vec![ + Span::styled(" ", Style::default().bg(background)), + Span::styled( + status_icon(agent.agent_status, config.status_indicators), + Style::default() + .fg(status_color(agent.agent_status, palette)) + .bg(background), + ), + Span::styled(" ", Style::default().bg(background)), + Span::styled( + crate::ui::truncate_end( + primary, + usize::from(content_width.saturating_sub(5)), + ), + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(Span::styled( + crate::ui::truncate_end( + &format!(" {}", detail.join(" · ")), + usize::from(content_width), + ), + Style::default().fg(palette.overlay0).bg(background), + )), + ], + background, + target: Some(ClientMobileTarget::Agent(agent.pane_id.clone())), + }); + } + } + + items.push(MobileItem::section("spaces", palette)); + items.push(MobileItem::action( + " + new workspace", + ClientMobileTarget::NewWorkspace, + palette, + )); + for entry in super::render::workspace_entries(snapshot, &HashSet::new()) { + let Some(workspace) = snapshot.workspaces.get(entry.index) else { + continue; + }; + let selected = selected_workspace_id == Some(workspace.workspace_id.as_str()); + let background = if selected { + palette.surface0 + } else if workspace.focused { + palette.surface_dim + } else { + palette.panel_bg + }; + let connector = if entry.indented { + if entry.last_child { + "└─ " + } else { + "├─ " + } + } else { + "" + }; + let name = if entry.indented && !workspace.custom_label { + workspace + .branch + .as_deref() + .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch))) + .unwrap_or(&workspace.label) + } else { + &workspace.label + }; + let branch = workspace.branch.as_deref().unwrap_or("shell"); + let detail_prefix = if entry.indented { + if entry.last_child { + " " + } else { + " │ " + } + } else { + " " + }; + items.push(MobileItem { + lines: vec![ + Line::from(vec![ + Span::styled( + format!(" {connector}"), + Style::default().fg(palette.overlay0).bg(background), + ), + Span::styled( + status_icon(workspace.agent_status, config.status_indicators), + Style::default() + .fg(status_color(workspace.agent_status, palette)) + .bg(background), + ), + Span::styled(" ", Style::default().bg(background)), + Span::styled( + crate::ui::truncate_end( + name, + usize::from(content_width.saturating_sub(if entry.indented { + 8 + } else { + 5 + })), + ), + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(Span::styled( + crate::ui::truncate_end( + &format!( + "{detail_prefix}{branch} · {}", + compact_tab_status(snapshot, workspace) + ), + usize::from(content_width), + ), + Style::default().fg(palette.overlay0).bg(background), + )), + ], + background, + target: Some(ClientMobileTarget::Workspace( + workspace.workspace_id.clone(), + )), + }); + } + + if let Some(workspace_id) = snapshot.focused_workspace_id.as_deref() { + items.push(MobileItem::section("tabs", palette)); + items.push(MobileItem::action( + " + new tab", + ClientMobileTarget::NewTab, + palette, + )); + for (index, tab) in snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .enumerate() + { + let background = if tab.focused { + palette.surface_dim + } else { + palette.panel_bg + }; + let label = if tab.custom_label { + format!("{} · {}", index + 1, tab.label) + } else { + format!("tab {}", tab.label) + }; + let label = format!( + " {}", + crate::ui::truncate_end(&label, usize::from(content_width.saturating_sub(3)),) + ); + items.push(MobileItem { + lines: vec![Line::from(Span::styled( + label, + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ))], + background, + target: Some(ClientMobileTarget::Tab(tab.tab_id.clone())), + }); + } + } + + items.push(MobileItem::section("menu", palette)); + for (index, (label, _)) in super::global_menu::global_menu_items(snapshot) + .into_iter() + .enumerate() + { + items.push(MobileItem { + lines: vec![Line::from(Span::styled( + format!(" {label}"), + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ))], + background: palette.panel_bg, + target: Some(ClientMobileTarget::Menu(index)), + }); + } + items +} + +impl ClientShellState { + pub(super) fn handle_mobile_mouse( + &mut self, + mouse: crossterm::event::MouseEvent, + outcome: &mut ClientShellInput, + ) -> bool { + let mobile = self + .last_composed_size + .is_some_and(|(cols, rows)| !self.layout(cols, rows).mobile_header.is_empty()); + if !mobile || self.overlay.is_some() { + return false; + } + use crossterm::event::{MouseButton, MouseEventKind}; + let point = (mouse.column, mouse.row); + if self.mode != ClientShellMode::Navigate { + if matches!( + self.mode, + ClientShellMode::Terminal | ClientShellMode::Resize + ) && mouse.kind == MouseEventKind::Down(MouseButton::Left) + && super::contains(self.hits.mobile_switch, point) + { + self.mobile_switcher_scroll = 0; + self.reveal_mobile_workspace = false; + self.mode = ClientShellMode::Navigate; + self.navigate_workspace_id = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.clone()); + outcome.repaint = true; + return true; + } + return false; + } + + match mouse.kind { + MouseEventKind::ScrollUp => { + self.mobile_switcher_scroll = self.mobile_switcher_scroll.saturating_sub(2); + outcome.repaint = true; + return true; + } + MouseEventKind::ScrollDown => { + self.mobile_switcher_scroll = self + .mobile_switcher_scroll + .saturating_add(2) + .min(self.hits.mobile_max_scroll); + outcome.repaint = true; + return true; + } + MouseEventKind::Down(MouseButton::Left) => {} + _ => return true, + } + + if super::contains(self.hits.mobile_close, point) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + outcome.repaint = true; + return true; + } + let target = self + .hits + .mobile_targets + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .map(|(_, target)| target.clone()); + match target { + Some(ClientMobileTarget::NewWorkspace) => { + self.mobile_switcher_suspended = true; + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorkspace), + outcome, + ); + } + Some( + target @ (ClientMobileTarget::Workspace(_) + | ClientMobileTarget::Tab(_) + | ClientMobileTarget::Agent(_)), + ) => { + let method = match target { + ClientMobileTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { workspace_id }, + ) + } + ClientMobileTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { + tab_id, + }) + } + ClientMobileTarget::Agent(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id, + }) + } + ClientMobileTarget::NewWorkspace + | ClientMobileTarget::NewTab + | ClientMobileTarget::Menu(_) => return true, + }; + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + self.push_endpoint_method(method, outcome); + } + Some(ClientMobileTarget::NewTab) => { + self.mobile_switcher_suspended = true; + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewTab), + outcome, + ); + } + Some(ClientMobileTarget::Menu(index)) => { + let actionable = self.snapshot.as_deref().is_some_and(|snapshot| { + super::global_menu::global_menu_items(snapshot) + .get(index) + .is_some_and(|(_, action)| { + *action != super::global_menu::ClientGlobalMenuAction::WhatsNew + || snapshot.release_notes.is_some() + }) + }); + if actionable { + self.mobile_switcher_suspended = true; + self.activate_global_menu_item(index, outcome); + } + } + None => {} + } + outcome.repaint = true; + true + } +} + +fn render_left_scrollbar( + buffer: &mut Buffer, + viewport: Rect, + total_rows: usize, + scroll: usize, + palette: &Palette, +) { + if viewport.is_empty() { + return; + } + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: total_rows + .saturating_sub(usize::from(viewport.height)) + .saturating_sub(scroll), + max_offset_from_bottom: total_rows.saturating_sub(usize::from(viewport.height)), + viewport_rows: usize::from(viewport.height), + }; + let track = Rect::new(viewport.x, viewport.y, 1, viewport.height); + for y in track.y..track.bottom() { + put_text( + buffer, + track.x, + y, + 1, + "│", + Style::default() + .fg(palette.surface_dim) + .bg(palette.panel_bg), + ); + } + if let Some(thumb) = crate::ui::scrollbar_thumb(metrics, track) { + for y in thumb.top..thumb.top.saturating_add(thumb.len) { + put_text( + buffer, + track.x, + y, + 1, + "▌", + Style::default().fg(palette.accent).bg(palette.panel_bg), + ); + } + } +} diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs new file mode 100644 index 00000000..437ec4ed --- /dev/null +++ b/src/client/shell/mouse.rs @@ -0,0 +1,2304 @@ +use super::*; +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; + +const SELECTION_AUTOSCROLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(30); + +impl ClientShellState { + fn set_sidebar_width_from_column(&mut self, column: u16, outcome: &mut ClientShellInput) { + let (min, max) = crate::config::validated_sidebar_bounds( + self.config.sidebar_min_width, + self.config.sidebar_max_width, + ) + .unwrap_or((18, 36)); + let width = column.saturating_add(1).clamp(min, max); + if self.sidebar_width != width { + self.sidebar_width = width; + self.sidebar_width_manual = true; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + } + } + + fn set_sidebar_section_from_row(&mut self, row: u16, outcome: &mut ClientShellInput) { + let divider = self.hits.sidebar_divider; + if divider.height == 0 { + return; + } + let ratio = row.saturating_sub(divider.y) as f32 / divider.height as f32; + let ratio = ratio.clamp(0.1, 0.9); + if (self.sidebar_section_split - ratio).abs() > f32::EPSILON { + self.sidebar_section_split = ratio; + self.sidebar_section_split_manual = true; + outcome.repaint = true; + } + } + + fn pane_scrollbar_offset( + hit: &PaneHit, + row: u16, + grab_row_offset: Option<u16>, + ) -> Option<usize> { + let track = hit.scrollbar_rect?; + let metrics = hit.scroll?; + (metrics.max_offset_from_bottom > 0).then(|| match grab_row_offset { + Some(grab_row_offset) => { + crate::ui::scrollbar_offset_from_drag_row(metrics, track, row, grab_row_offset) + } + None => crate::ui::scrollbar_offset_from_row(metrics, track, row), + }) + } + + pub(super) fn push_pane_scroll_offset( + &mut self, + pane_id: String, + offset_from_bottom: usize, + outcome: &mut ClientShellInput, + ) { + self.pane_scroll_targets + .insert(pane_id.clone(), offset_from_bottom); + if self.pane_scroll_in_flight.contains_key(&pane_id) { + self.pane_scroll_queued.insert(pane_id, offset_from_bottom); + return; + } + self.dispatch_pane_scroll_offset(pane_id, offset_from_bottom, outcome); + } + + fn dispatch_pane_scroll_offset( + &mut self, + pane_id: String, + offset_from_bottom: usize, + outcome: &mut ClientShellInput, + ) { + if self.snapshot.is_none() { + return; + } + self.next_scroll_serial = self.next_scroll_serial.saturating_add(1); + let serial = self.next_scroll_serial; + self.pane_scroll_targets + .insert(pane_id.clone(), offset_from_bottom); + self.pane_scroll_in_flight.insert(pane_id.clone(), serial); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneScroll(crate::api::schema::PaneScrollParams { + pane_id: pane_id.clone(), + offset_from_bottom: offset_from_bottom as u64, + }), + PendingEndpointKind::PaneScroll { pane_id, serial }, + outcome, + ); + } + + pub(super) fn complete_pane_scroll( + &mut self, + pane_id: String, + serial: u64, + result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>, + outcome: &mut ClientShellInput, + ) -> bool { + if self.pane_scroll_in_flight.get(&pane_id).copied() != Some(serial) { + return false; + } + self.pane_scroll_in_flight.remove(&pane_id); + let repaint = match result { + Ok(crate::api::schema::ResponseResult::PaneInfo { pane }) + if pane.pane_id == pane_id => + { + if let Some(scroll) = pane.scroll { + if self.pane_scroll_targets.contains_key(&pane_id) { + self.pane_scroll_targets.insert( + pane_id.clone(), + usize::try_from(scroll.offset_from_bottom).unwrap_or(usize::MAX), + ); + } + } + false + } + Ok(_) => { + self.pane_scroll_queued.remove(&pane_id); + self.pane_scroll_targets.remove(&pane_id); + self.endpoint_error = + Some("endpoint returned an unexpected pane-scroll result".to_owned()); + true + } + Err(error) => { + self.pane_scroll_queued.remove(&pane_id); + self.pane_scroll_targets.remove(&pane_id); + self.endpoint_error = Some(error.message); + true + } + }; + if let Some(offset) = self.pane_scroll_queued.remove(&pane_id) { + self.dispatch_pane_scroll_offset(pane_id, offset, outcome); + } + repaint + } + + pub(super) fn stop_selection_autoscroll(&mut self) { + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + } + + fn selection_edge_scroll_lines(distance: u16) -> usize { + usize::from(distance).saturating_mul(3).clamp(3, 15) + } + + fn selection_scroll_metrics(&self, hit: &PaneHit) -> Option<crate::pane::ScrollMetrics> { + let metrics = hit.scroll?; + Some( + self.selection_autoscroll + .as_ref() + .filter(|autoscroll| autoscroll.pane_id == hit.pane_id) + .map_or(metrics, |autoscroll| crate::pane::ScrollMetrics { + offset_from_bottom: autoscroll.offset_from_bottom, + max_offset_from_bottom: autoscroll.max_offset_from_bottom, + viewport_rows: metrics.viewport_rows, + }), + ) + } + + fn update_selection_cursor_with_metrics( + &mut self, + hit: &PaneHit, + column: u16, + row: u16, + metrics: Option<crate::pane::ScrollMetrics>, + ) { + if let Some(selection) = self.selection.as_mut() { + selection.drag(column, row, hit.inner_rect, metrics); + } + } + + fn update_selection_drag( + &mut self, + hit: &PaneHit, + column: u16, + row: u16, + outcome: &mut ClientShellInput, + ) { + let metrics = self.selection_scroll_metrics(hit); + let was_dragging = self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_dragging); + let moved_from_anchor = self.selection.as_ref().is_some_and(|selection| { + let (anchor_row, anchor_col) = selection.anchor_screen_pos(hit.inner_rect, metrics); + anchor_row != row || anchor_col != column + }); + let is_dragging = was_dragging || moved_from_anchor; + self.update_selection_cursor_with_metrics(hit, column, row, metrics); + if is_dragging { + if let Some(selection) = self.selection.as_mut() { + if selection.is_just_click() { + selection.force_dragging(); + } + } + self.last_pane_click = None; + } + if !is_dragging { + self.stop_selection_autoscroll(); + return; + } + + let Some(metrics) = metrics else { + self.stop_selection_autoscroll(); + return; + }; + let top = hit.inner_rect.y; + let bottom = hit.inner_rect.y + hit.inner_rect.height.saturating_sub(1); + let (direction, immediate_lines) = if row < top { + ( + ClientSelectionAutoscrollDirection::Up, + Self::selection_edge_scroll_lines(top - row), + ) + } else if row > bottom { + ( + ClientSelectionAutoscrollDirection::Down, + Self::selection_edge_scroll_lines(row - bottom), + ) + } else if row == top { + (ClientSelectionAutoscrollDirection::Up, 0) + } else if row == bottom { + (ClientSelectionAutoscrollDirection::Down, 0) + } else { + self.stop_selection_autoscroll(); + return; + }; + + let offset_from_bottom = match direction { + ClientSelectionAutoscrollDirection::Up => metrics + .offset_from_bottom + .saturating_add(immediate_lines) + .min(metrics.max_offset_from_bottom), + ClientSelectionAutoscrollDirection::Down => { + metrics.offset_from_bottom.saturating_sub(immediate_lines) + } + }; + if offset_from_bottom != metrics.offset_from_bottom { + let projected = crate::pane::ScrollMetrics { + offset_from_bottom, + ..metrics + }; + self.update_selection_cursor_with_metrics(hit, column, row, Some(projected)); + self.push_pane_scroll_offset(hit.pane_id.clone(), offset_from_bottom, outcome); + } + self.selection_autoscroll = Some(ClientSelectionAutoscroll { + pane_id: hit.pane_id.clone(), + direction, + last_mouse_column: column, + last_mouse_row: row, + inner_rect: hit.inner_rect, + offset_from_bottom, + max_offset_from_bottom: metrics.max_offset_from_bottom, + }); + self.selection_autoscroll_deadline = + Some(std::time::Instant::now() + SELECTION_AUTOSCROLL_INTERVAL); + } + + fn scroll_in_progress_selection( + &mut self, + mouse: MouseEvent, + outcome: &mut ClientShellInput, + ) -> bool { + if !matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) || !self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_in_progress) + { + return false; + } + let Some(hit) = self.selection.as_ref().and_then(|selection| { + self.hits + .panes + .iter() + .find(|hit| hit.pane_id == selection.pane_id) + .cloned() + }) else { + return false; + }; + let Some(metrics) = self.selection_scroll_metrics(&hit) else { + return false; + }; + let offset_from_bottom = match mouse.kind { + MouseEventKind::ScrollUp => metrics + .offset_from_bottom + .saturating_add(self.config.mouse_scroll_lines) + .min(metrics.max_offset_from_bottom), + MouseEventKind::ScrollDown => metrics + .offset_from_bottom + .saturating_sub(self.config.mouse_scroll_lines), + _ => unreachable!(), + }; + if offset_from_bottom != metrics.offset_from_bottom { + let projected = crate::pane::ScrollMetrics { + offset_from_bottom, + ..metrics + }; + self.update_selection_cursor_with_metrics( + &hit, + mouse.column, + mouse.row, + Some(projected), + ); + self.push_pane_scroll_offset(hit.pane_id, offset_from_bottom, outcome); + outcome.repaint = true; + } + true + } + + pub(crate) fn tick_selection_autoscroll( + &mut self, + now: std::time::Instant, + ) -> ClientShellInput { + let mut outcome = ClientShellInput::default(); + if self + .selection_autoscroll_deadline + .is_none_or(|deadline| now < deadline) + { + return outcome; + } + let Some(mut autoscroll) = self.selection_autoscroll.clone() else { + self.selection_autoscroll_deadline = None; + return outcome; + }; + if !self.selection.as_ref().is_some_and(|selection| { + selection.pane_id == autoscroll.pane_id && selection.is_dragging() + }) { + self.stop_selection_autoscroll(); + return outcome; + } + let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| hit.pane_id == autoscroll.pane_id) + .cloned() + else { + self.stop_selection_autoscroll(); + return outcome; + }; + if hit.inner_rect != autoscroll.inner_rect { + self.stop_selection_autoscroll(); + return outcome; + } + let next_offset = match autoscroll.direction { + ClientSelectionAutoscrollDirection::Up => autoscroll + .offset_from_bottom + .saturating_add(1) + .min(autoscroll.max_offset_from_bottom), + ClientSelectionAutoscrollDirection::Down => { + autoscroll.offset_from_bottom.saturating_sub(1) + } + }; + if next_offset == autoscroll.offset_from_bottom { + self.stop_selection_autoscroll(); + return outcome; + } + autoscroll.offset_from_bottom = next_offset; + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: next_offset, + max_offset_from_bottom: autoscroll.max_offset_from_bottom, + viewport_rows: hit.scroll.map_or(0, |metrics| metrics.viewport_rows), + }; + self.update_selection_cursor_with_metrics( + &hit, + autoscroll.last_mouse_column, + autoscroll.last_mouse_row, + Some(metrics), + ); + self.push_pane_scroll_offset(autoscroll.pane_id.clone(), next_offset, &mut outcome); + self.selection_autoscroll = Some(autoscroll); + self.selection_autoscroll_deadline = Some(now + SELECTION_AUTOSCROLL_INTERVAL); + outcome.repaint = true; + outcome + } + + fn pane_split_target_is_current(&self, hit: &PaneSplitHit, tab_id: &str) -> Option<bool> { + let snapshot = self.snapshot.as_deref()?; + let surface = self.pane_surface.as_ref()?; + if snapshot.revision != surface.projection_revision { + return None; + } + Some( + snapshot.focused_tab_id.as_deref() == Some(tab_id) + && pane_surface_topology_signature(surface) == hit.topology_signature, + ) + } + + fn pane_split_ratio(hit: &PaneSplitHit, grab_offset: i32, point: (u16, u16)) -> f32 { + let (pointer, origin, length) = match hit.direction { + crate::protocol::PaneSurfaceSplitDirection::Horizontal => { + (i32::from(point.0), i32::from(hit.area.x), hit.area.width) + } + crate::protocol::PaneSurfaceSplitDirection::Vertical => { + (i32::from(point.1), i32::from(hit.area.y), hit.area.height) + } + }; + ((pointer + grab_offset - origin) as f32 / f32::from(length.max(1))).clamp(0.1, 0.9) + } + + fn tab_drop_index_at(&self, point: (u16, u16)) -> Option<usize> { + let snapshot = self.snapshot.as_deref()?; + let workspace_id = snapshot.focused_workspace_id.as_deref()?; + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .collect::<Vec<_>>(); + let visible = self + .hits + .tabs + .iter() + .filter_map(|(rect, tab_id)| { + tabs.iter() + .position(|tab| tab.tab_id == *tab_id) + .map(|index| (index, *rect)) + }) + .collect::<Vec<_>>(); + let (first_index, first_rect) = *visible.first()?; + let (last_index, last_rect) = *visible.last()?; + let on_tab_row = point.1 == first_rect.y; + if !on_tab_row { + return None; + } + if super::contains(self.hits.tab_scroll_left, point) { + return Some(0); + } + if super::contains(self.hits.tab_scroll_right, point) { + return Some(tabs.len()); + } + let left_edge = if first_index == 0 { + first_rect.x + } else { + self.hits.tab_scroll_left.right() + }; + let right_edge = if last_index + 1 >= tabs.len() { + last_rect.right() + } else { + self.hits.tab_scroll_right.x.saturating_sub(1) + }; + if point.0 <= left_edge { + return Some(first_index); + } + if point.0 >= right_edge { + return Some(last_index + 1); + } + for (index, rect) in visible { + let midpoint = rect.x + rect.width / 2; + if point.0 < midpoint { + return Some(index); + } + if point.0 < rect.right() { + return Some(index + 1); + } + } + Some(last_index + 1) + } + + fn workspace_drop_target_at(&self, point: (u16, u16)) -> Option<(Option<String>, u16)> { + if self.hits.workspace_body.height == 0 + || point.1 < self.hits.workspace_body.y.saturating_sub(1) + || point.1 >= self.hits.new_workspace.y + { + return None; + } + let mut slots = self + .hits + .workspaces + .iter() + .filter(|hit| !hit.indented) + .map(|hit| (Some(hit.workspace_id.clone()), hit.rect.y.saturating_sub(1))) + .collect::<Vec<_>>(); + let snapshot = self.snapshot.as_deref()?; + let entries = render::workspace_entries(snapshot, &self.collapsed_groups); + let last_hit = self.hits.workspaces.last()?; + let last_position = entries.iter().position(|entry| { + snapshot + .workspaces + .get(entry.index) + .is_some_and(|workspace| workspace.workspace_id == last_hit.workspace_id) + })?; + let next = entries.get(last_position + 1); + if !next.is_some_and(|entry| entry.indented) { + let before = next.and_then(|entry| { + snapshot + .workspaces + .get(entry.index) + .map(|workspace| workspace.workspace_id.clone()) + }); + let row = last_hit.rect.bottom(); + if row < self.hits.new_workspace.y { + slots.push((before, row)); + } + } + slots + .into_iter() + .enumerate() + .min_by_key(|(index, (_, row))| (point.1.abs_diff(*row), *index)) + .map(|(_, target)| target) + } + + fn workspace_move_method( + &self, + source_workspace_id: &str, + before_workspace_id: Option<&str>, + ) -> Option<crate::api::schema::Method> { + let snapshot = self.snapshot.as_deref()?; + let source = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == source_workspace_id)?; + if source + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + { + return None; + } + if before_workspace_id == Some(source_workspace_id) { + return None; + } + let roots = snapshot + .workspaces + .iter() + .filter(|workspace| { + !workspace + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + }) + .collect::<Vec<_>>(); + let source_position = roots + .iter() + .position(|workspace| workspace.workspace_id == source_workspace_id)?; + let remaining = roots + .iter() + .copied() + .filter(|workspace| workspace.workspace_id != source_workspace_id) + .collect::<Vec<_>>(); + let insert_position = match before_workspace_id { + Some(target) => remaining + .iter() + .position(|workspace| workspace.workspace_id == target)?, + None => remaining.len(), + }; + if insert_position == source_position { + return None; + } + + if let Some(worktree) = source.worktree.as_ref() { + let workspace_ids = std::iter::once(source.workspace_id.clone()) + .chain( + snapshot + .workspaces + .iter() + .filter(|workspace| workspace.workspace_id != source.workspace_id) + .filter(|workspace| { + workspace + .worktree + .as_ref() + .is_some_and(|candidate| candidate.key == worktree.key) + }) + .map(|workspace| workspace.workspace_id.clone()), + ) + .collect(); + Some(crate::api::schema::Method::WorkspaceMoveBlock( + crate::api::schema::WorkspaceMoveBlockParams { + workspace_ids, + before_workspace_id: before_workspace_id.map(str::to_owned), + }, + )) + } else { + let insert_index = before_workspace_id + .and_then(|target| { + snapshot + .workspaces + .iter() + .position(|workspace| workspace.workspace_id == target) + }) + .unwrap_or(snapshot.workspaces.len()); + Some(crate::api::schema::Method::WorkspaceMove( + crate::api::schema::WorkspaceMoveParams { + workspace_id: source.workspace_id.clone(), + insert_index, + }, + )) + } + } + + pub(super) fn handle_mouse(&mut self, mouse: MouseEvent, outcome: &mut ClientShellInput) { + let point = (mouse.column, mouse.row); + if matches!(self.overlay, Some(ClientShellOverlay::Onboarding)) { + if mouse.kind == MouseEventKind::Down(MouseButton::Left) + && super::contains(self.hits.overlay_primary, point) + { + self.complete_onboarding(outcome); + } + return; + } + if matches!( + self.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + ) { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) + if super::contains(self.hits.overlay_primary, point) => + { + self.dismiss_product_announcement(outcome); + } + MouseEventKind::Down(MouseButton::Left) + if super::contains(self.hits.product_announcement_scrollbar, point) => + { + if let Some(metrics) = self.hits.product_announcement_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.product_announcement_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::ProductAnnouncementScrollbar { + grab_row_offset, + }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.product_announcement_scrollbar, + mouse.row, + ); + self.set_product_announcement_offset_from_bottom(offset); + outcome.repaint = true; + } + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let ( + Some(ClientChromeDrag::ProductAnnouncementScrollbar { grab_row_offset }), + Some(metrics), + ) = ( + self.chrome_drag.as_ref(), + self.hits.product_announcement_scroll_metrics, + ) { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.product_announcement_scrollbar, + mouse.row, + *grab_row_offset, + ); + self.set_product_announcement_offset_from_bottom(offset); + outcome.repaint = true; + } + } + MouseEventKind::Up(MouseButton::Left) => { + self.chrome_drag = None; + } + MouseEventKind::ScrollUp => { + self.scroll_product_announcement(-3); + outcome.repaint = true; + } + MouseEventKind::ScrollDown => { + self.scroll_product_announcement(3); + outcome.repaint = true; + } + _ => {} + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::ReleaseNotes(_))) { + let (close, track, metrics) = self + .current_release_notes_input_geometry() + .map(|(close, track, metrics)| (close, track, Some(metrics))) + .unwrap_or(( + self.hits.overlay_primary, + (!self.hits.release_notes_scrollbar.is_empty()) + .then_some(self.hits.release_notes_scrollbar), + self.hits.release_notes_scroll_metrics, + )); + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) if super::contains(close, point) => { + self.dismiss_release_notes(outcome); + } + MouseEventKind::Down(MouseButton::Left) + if track.is_some_and(|track| super::contains(track, point)) => + { + if let (Some(track), Some(metrics)) = (track, metrics) { + if let Some(grab_row_offset) = + crate::ui::scrollbar_thumb_grab_offset(metrics, track, mouse.row) + { + self.chrome_drag = + Some(ClientChromeDrag::ReleaseNotesScrollbar { grab_row_offset }); + } else { + let offset = + crate::ui::scrollbar_offset_from_row(metrics, track, mouse.row); + self.set_release_notes_offset_from_bottom(offset); + outcome.repaint = true; + } + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let ( + Some(ClientChromeDrag::ReleaseNotesScrollbar { grab_row_offset }), + Some(track), + Some(metrics), + ) = (self.chrome_drag.as_ref(), track, metrics) + { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + track, + mouse.row, + *grab_row_offset, + ); + self.set_release_notes_offset_from_bottom(offset); + outcome.repaint = true; + } + } + MouseEventKind::Up(MouseButton::Left) => { + self.chrome_drag = None; + } + MouseEventKind::ScrollUp => { + self.scroll_release_notes(-3); + outcome.repaint = true; + } + MouseEventKind::ScrollDown => { + self.scroll_release_notes(3); + outcome.repaint = true; + } + _ => {} + } + return; + } + if self.url_click_consumes_until_up { + match mouse.kind { + MouseEventKind::Drag(MouseButton::Left) => return, + MouseEventKind::Up(MouseButton::Left) => { + self.url_click_consumes_until_up = false; + return; + } + MouseEventKind::Down(MouseButton::Left) => { + self.url_click_consumes_until_up = false; + } + _ => {} + } + } + if !self.replaying_url_click + && matches!( + mouse.kind, + MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) + ) + { + if let Some(fallback_events) = + self.pending_requests + .values_mut() + .find_map(|pending| match &mut pending.kind { + PendingEndpointKind::PaneLinkActivate { + fallback_events, .. + } if !fallback_events + .iter() + .any(|event| event.kind == MouseEventKind::Up(MouseButton::Left)) => + { + Some(fallback_events) + } + _ => None, + }) + { + fallback_events.push(mouse); + return; + } + } + if let Some(gesture) = self.pane_mouse_gesture.as_ref() { + let gesture_event = matches!( + mouse.kind, + MouseEventKind::Drag(button) | MouseEventKind::Up(button) + if button == gesture.button + ); + if gesture_event { + let button = gesture.button; + let modifiers = mouse.modifiers.difference(gesture.stripped_modifiers); + let hit = if gesture.hit.popup { + self.hits + .popup + .as_ref() + .filter(|hit| hit.pane_id == gesture.hit.pane_id) + .cloned() + } else { + self.hits + .panes + .iter() + .find(|hit| hit.pane_id == gesture.hit.pane_id) + .cloned() + } + .unwrap_or_else(|| gesture.hit.clone()); + let position = self.pane_mouse_position(&hit, mouse); + if let Some(gesture) = self.pane_mouse_gesture.as_mut() { + gesture.last_event = mouse; + gesture.last_position = position; + } + self.push_pane_mouse_event(&hit, mouse, modifiers, outcome); + if mouse.kind == MouseEventKind::Up(button) { + self.pane_mouse_gesture = None; + } + return; + } + if matches!( + mouse.kind, + MouseEventKind::Down(_) | MouseEventKind::Drag(_) | MouseEventKind::Up(_) + ) { + return; + } + } + if self.popup_pending { + return; + } + if let Some(hit) = self.hits.popup.clone() { + if super::contains(hit.inner_rect, point) { + match mouse.kind { + MouseEventKind::Down(button) => { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + if hit.mouse_reporting { + self.pane_mouse_gesture = Some(ClientPaneMouseGesture { + last_position: self.pane_mouse_position(&hit, mouse), + hit, + button, + stripped_modifiers: crossterm::event::KeyModifiers::empty(), + last_event: mouse, + }); + } + } + MouseEventKind::Moved if hit.mouse_reporting => { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + } + MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight => { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + } + MouseEventKind::Up(_) | MouseEventKind::Drag(_) | MouseEventKind::Moved => {} + } + } + return; + } + if self.popup_terminal_id.is_some() { + return; + } + if !self.replaying_url_click + && self.overlay.is_none() + && self.mode == ClientShellMode::Terminal + && mouse.kind == MouseEventKind::Down(MouseButton::Left) + && mouse + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) + { + if let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point)) + .cloned() + { + let viewport_row = mouse.row.saturating_sub(hit.inner_rect.y); + let col = mouse.column.saturating_sub(hit.inner_rect.x); + let content_revision = self + .pane_surface + .as_ref() + .and_then(|surface| { + surface + .panes + .iter() + .find(|pane| pane.pane_id == hit.pane_id) + }) + .map(|pane| pane.content_revision); + self.last_pane_click = None; + let pane_id = hit.pane_id.clone(); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneLinkActivate( + crate::api::schema::PaneLinkActivateParams { + pane_id: pane_id.clone(), + viewport_row, + col, + content_revision, + offset_from_bottom: hit + .scroll + .map(|metrics| metrics.offset_from_bottom as u64), + }, + ), + PendingEndpointKind::PaneLinkActivate { + pane_id, + inner_rect: hit.inner_rect, + fallback_events: vec![mouse], + }, + outcome, + ); + return; + } + } + if self.overlay.is_none() + && self.mode == ClientShellMode::Terminal + && self + .visible_notification + .as_ref() + .is_some_and(|notification| notification.event.pane_id.is_some()) + && mouse.kind == MouseEventKind::Down(MouseButton::Left) + && super::contains(self.hits.notification_toast, point) + { + self.focus_visible_notification(outcome); + return; + } + if self.handle_mobile_mouse(mouse, outcome) { + return; + } + if mouse.kind == MouseEventKind::Drag(MouseButton::Left) { + match self.chrome_drag.as_ref() { + Some(ClientChromeDrag::SidebarWidth) => { + self.set_sidebar_width_from_column(mouse.column, outcome); + return; + } + Some(ClientChromeDrag::SidebarSection) => { + self.set_sidebar_section_from_row(mouse.row, outcome); + return; + } + Some(ClientChromeDrag::WorkspaceScrollbar { grab_row_offset }) => { + if let Some(metrics) = self.hits.workspace_scroll_metrics { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.workspace_scrollbar, + mouse.row, + *grab_row_offset, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != self.workspace_scroll { + self.workspace_scroll = next; + outcome.repaint = true; + } + } + return; + } + Some(ClientChromeDrag::AgentScrollbar { grab_row_offset }) => { + if let Some(metrics) = self.hits.agent_scroll_metrics { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.agent_scrollbar, + mouse.row, + *grab_row_offset, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != self.agent_scroll { + self.agent_scroll = next; + outcome.repaint = true; + } + } + return; + } + Some(ClientChromeDrag::HelpScrollbar { grab_row_offset }) => { + if let (Some(metrics), Some(ClientShellOverlay::Help(help))) = + (self.hits.help_scroll_metrics, self.overlay.as_mut()) + { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.help_scrollbar, + mouse.row, + *grab_row_offset, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != help.scroll { + help.scroll = next; + outcome.repaint = true; + } + } + return; + } + Some( + ClientChromeDrag::ProductAnnouncementScrollbar { .. } + | ClientChromeDrag::ReleaseNotesScrollbar { .. }, + ) => { + self.chrome_drag = None; + return; + } + Some(ClientChromeDrag::PaneScrollbar { + hit, + grab_row_offset, + last_sent_offset, + last_sent_at, + }) => { + let current_hit = self + .hits + .panes + .iter() + .find(|current| current.pane_id == hit.pane_id) + .cloned() + .unwrap_or_else(|| hit.clone()); + let Some(offset) = Self::pane_scrollbar_offset( + ¤t_hit, + mouse.row, + Some(*grab_row_offset), + ) else { + self.chrome_drag = None; + return; + }; + let now = std::time::Instant::now(); + let should_send = *last_sent_offset != Some(offset) + && last_sent_at.is_none_or(|last| { + now.duration_since(last) >= std::time::Duration::from_millis(33) + }); + if should_send { + if let Some(ClientChromeDrag::PaneScrollbar { + last_sent_offset, + last_sent_at, + .. + }) = self.chrome_drag.as_mut() + { + *last_sent_offset = Some(offset); + *last_sent_at = Some(now); + } + self.push_pane_scroll_offset(current_hit.pane_id, offset, outcome); + } + return; + } + Some(ClientChromeDrag::PaneSplit { + hit, + tab_id, + grab_offset, + last_sent_at, + .. + }) => { + let hit = hit.clone(); + let tab_id = tab_id.clone(); + let grab_offset = *grab_offset; + match self.pane_split_target_is_current(&hit, &tab_id) { + Some(true) => {} + Some(false) => { + self.chrome_drag = None; + return; + } + None => return, + } + let ratio = Self::pane_split_ratio(&hit, grab_offset, point); + let now = std::time::Instant::now(); + let should_send = last_sent_at.is_none_or(|last| { + now.duration_since(last) >= std::time::Duration::from_millis(33) + }); + if let Some(ClientChromeDrag::PaneSplit { + last_sent_ratio, + last_sent_at, + .. + }) = self.chrome_drag.as_mut() + { + if should_send { + *last_sent_ratio = Some(ratio); + *last_sent_at = Some(now); + } + } + if should_send { + self.push_endpoint_method( + crate::api::schema::Method::LayoutSetSplitRatio( + crate::api::schema::LayoutSetSplitRatioParams { + tab_id: Some(tab_id), + pane_id: None, + path: hit.path, + ratio, + }, + ), + outcome, + ); + } + return; + } + Some(ClientChromeDrag::Tab { .. }) => { + let insert_index = self.tab_drop_index_at(point); + if let Some(ClientChromeDrag::Tab { + insert_index: current, + .. + }) = self.chrome_drag.as_mut() + { + *current = insert_index; + } + outcome.repaint = true; + return; + } + Some(ClientChromeDrag::Workspace { .. }) => { + let target = self.workspace_drop_target_at(point); + if let Some(ClientChromeDrag::Workspace { + target: current, .. + }) = self.chrome_drag.as_mut() + { + *current = target; + } + outcome.repaint = true; + return; + } + None => {} + } + if let Some(press) = self.workspace_press.as_ref() { + let delta = mouse + .column + .abs_diff(press.start_column) + .max(mouse.row.abs_diff(press.start_row)); + if delta >= 1 { + let source_workspace_id = press.workspace_id.clone(); + let draggable = self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == source_workspace_id) + }) + .is_some_and(|workspace| { + !workspace + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + }); + if draggable { + if let Some(target) = self.workspace_drop_target_at(point) { + self.chrome_drag = Some(ClientChromeDrag::Workspace { + source_workspace_id, + target: Some(target), + }); + outcome.repaint = true; + } + } + } + return; + } + if let Some(press) = self.tab_press.as_ref() { + let delta = mouse + .column + .abs_diff(press.start_column) + .max(mouse.row.abs_diff(press.start_row)); + if delta >= 1 { + if let Some(insert_index) = self.tab_drop_index_at(point) { + self.chrome_drag = Some(ClientChromeDrag::Tab { + tab_id: press.tab_id.clone(), + workspace_id: press.workspace_id.clone(), + insert_index: Some(insert_index), + }); + outcome.repaint = true; + } + } + return; + } + } + if mouse.kind == MouseEventKind::Up(MouseButton::Left) { + if let Some(drag) = self.chrome_drag.take() { + self.workspace_press = None; + self.tab_press = None; + match drag { + ClientChromeDrag::Tab { + tab_id, + workspace_id, + .. + } => { + let insert_index = self.tab_drop_index_at(point); + let valid_drop = self.snapshot.as_deref().is_some_and(|snapshot| { + snapshot.focused_workspace_id.as_deref() == Some(workspace_id.as_str()) + && snapshot.tabs.iter().any(|tab| { + tab.tab_id == tab_id && tab.workspace_id == workspace_id + }) + && insert_index.is_some_and(|index| { + index + <= snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .count() + }) + }); + if valid_drop { + self.push_endpoint_method( + crate::api::schema::Method::TabMove( + crate::api::schema::TabMoveParams { + tab_id, + insert_index: insert_index.unwrap_or_default(), + }, + ), + outcome, + ); + } + outcome.repaint = true; + } + ClientChromeDrag::Workspace { + source_workspace_id, + target, + } => { + if let Some((before_workspace_id, _)) = target { + if let Some(method) = self.workspace_move_method( + &source_workspace_id, + before_workspace_id.as_deref(), + ) { + self.push_endpoint_method(method, outcome); + } + } + outcome.repaint = true; + } + ClientChromeDrag::PaneScrollbar { + hit, + grab_row_offset, + last_sent_offset, + .. + } => { + let current_hit = self + .hits + .panes + .iter() + .find(|current| current.pane_id == hit.pane_id) + .cloned() + .unwrap_or(hit); + if let Some(offset) = Self::pane_scrollbar_offset( + ¤t_hit, + mouse.row, + Some(grab_row_offset), + ) { + if last_sent_offset != Some(offset) { + self.push_pane_scroll_offset(current_hit.pane_id, offset, outcome); + } + } + } + ClientChromeDrag::PaneSplit { + hit, + tab_id, + grab_offset, + last_sent_ratio, + .. + } => { + let target_is_current = + self.pane_split_target_is_current(&hit, &tab_id) == Some(true); + let ratio = Self::pane_split_ratio(&hit, grab_offset, point); + if target_is_current + && last_sent_ratio + .is_none_or(|sent| (sent - ratio).abs() > f32::EPSILON) + { + self.push_endpoint_method( + crate::api::schema::Method::LayoutSetSplitRatio( + crate::api::schema::LayoutSetSplitRatioParams { + tab_id: Some(tab_id), + pane_id: None, + path: hit.path, + ratio, + }, + ), + outcome, + ); + } + } + ClientChromeDrag::SidebarWidth | ClientChromeDrag::SidebarSection => { + self.persist_chrome_preferences(outcome); + } + ClientChromeDrag::WorkspaceScrollbar { .. } + | ClientChromeDrag::AgentScrollbar { .. } + | ClientChromeDrag::HelpScrollbar { .. } + | ClientChromeDrag::ProductAnnouncementScrollbar { .. } + | ClientChromeDrag::ReleaseNotesScrollbar { .. } => {} + } + return; + } + if let Some(press) = self.workspace_press.take() { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { + workspace_id: press.workspace_id, + }, + ), + outcome, + ); + return; + } + if let Some(press) = self.tab_press.take() { + self.push_endpoint_method( + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { + tab_id: press.tab_id, + }), + outcome, + ); + return; + } + } + if matches!(self.overlay, Some(ClientShellOverlay::GlobalMenu(_))) { + let row_hit = self + .hits + .global_menu_rows + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied(); + match mouse.kind { + MouseEventKind::Moved => { + if let (Some((_, index)), Some(ClientShellOverlay::GlobalMenu(menu))) = + (row_hit, self.overlay.as_mut()) + { + menu.highlighted = index; + outcome.repaint = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if super::contains(self.hits.global_launcher, point) { + self.toggle_global_menu(); + outcome.repaint = true; + } else if let Some((_, index)) = row_hit { + self.activate_global_menu_item(index, outcome); + } else { + self.overlay = None; + outcome.repaint = true; + } + } + _ => {} + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::ContextMenu(_))) { + let row_hit = self + .hits + .context_menu_rows + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied(); + match mouse.kind { + MouseEventKind::Moved => { + if let (Some((_, index)), Some(ClientShellOverlay::ContextMenu(menu))) = + (row_hit, self.overlay.as_mut()) + { + menu.highlighted = index; + outcome.repaint = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some((_, index)) = row_hit { + self.activate_context_menu_item(index, outcome); + } else { + self.overlay = None; + outcome.repaint = true; + } + } + _ => {} + } + return; + } + if matches!( + self.overlay, + Some( + ClientShellOverlay::WorktreeCreate(_) + | ClientShellOverlay::WorktreeOpen(_) + | ClientShellOverlay::WorktreeRemove(_) + ) + ) { + match mouse.kind { + MouseEventKind::ScrollUp + if matches!(self.overlay, Some(ClientShellOverlay::WorktreeOpen(_))) => + { + self.move_worktree_open_selection(-1); + outcome.repaint = true; + } + MouseEventKind::ScrollDown + if matches!(self.overlay, Some(ClientShellOverlay::WorktreeOpen(_))) => + { + self.move_worktree_open_selection(1); + outcome.repaint = true; + } + MouseEventKind::Down(MouseButton::Left) => { + if super::contains(self.hits.overlay_cancel, point) { + let busy = + matches!( + self.overlay, + Some( + ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { creating: true, .. } + ) | ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { opening: true, .. } + ) | ClientShellOverlay::WorktreeRemove( + ClientWorktreeRemoveOverlay { removing: true, .. } + ) + ) + ); + if !busy { + self.overlay = None; + outcome.repaint = true; + } + } else if super::contains(self.hits.worktree_search, point) { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() + { + open.search_focused = true; + outcome.repaint = true; + } + } else if let Some((_, index)) = self + .hits + .worktree_rows + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied() + { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() + { + open.selected = index; + } + self.submit_worktree_open(outcome); + } else if super::contains(self.hits.overlay_primary, point) { + match self.overlay.as_ref() { + Some(ClientShellOverlay::WorktreeCreate(_)) => { + self.submit_worktree_create(outcome) + } + Some(ClientShellOverlay::WorktreeOpen(_)) => { + self.submit_worktree_open(outcome) + } + Some(ClientShellOverlay::WorktreeRemove(_)) => { + self.submit_worktree_remove(outcome) + } + _ => {} + } + } + } + _ => {} + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) { + if mouse.kind == MouseEventKind::Down(MouseButton::Left) { + if let Some((_, section)) = self + .hits + .settings_tabs + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied() + { + self.select_settings_section(section, outcome); + } else if let Some((_, index)) = self + .hits + .settings_choices + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied() + { + self.select_settings_choice(index); + let immediate = matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Indicators + | ClientSettingsSection::Sound + | ClientSettingsSection::Toast, + .. + })) + ); + if immediate { + self.apply_settings_choice(outcome); + } + outcome.repaint = true; + } else if super::contains(self.hits.overlay_primary, point) { + self.apply_settings_choice(outcome); + } else if super::contains(self.hits.overlay_cancel, point) + || !super::contains(self.hits.settings_popup, point) + { + let installing = matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + installing_integrations: true, + .. + })) + ); + if !installing { + self.cancel_settings_overlay(); + outcome.repaint = true; + } + } + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::Help(_))) { + match mouse.kind { + MouseEventKind::ScrollUp => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + let next = help.scroll.saturating_sub(3); + if next != help.scroll { + help.scroll = next; + outcome.repaint = true; + } + } + } + MouseEventKind::ScrollDown => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + let next = help.scroll.saturating_add(3).min(self.hits.help_max_scroll); + if next != help.scroll { + help.scroll = next; + outcome.repaint = true; + } + } + } + MouseEventKind::Down(MouseButton::Left) => { + if super::contains(self.hits.help_scrollbar, point) { + if let Some(metrics) = self.hits.help_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.help_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::HelpScrollbar { grab_row_offset }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.help_scrollbar, + mouse.row, + ); + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() + { + help.scroll = + metrics.max_offset_from_bottom.saturating_sub(offset); + outcome.repaint = true; + } + } + } + } else if super::contains(self.hits.overlay_cancel, point) { + let search_focused = matches!( + self.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { + search_focused: true, + .. + })) + ); + if search_focused { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.search_focused = false; + help.query.clear(); + help.scroll = 0; + } + } else { + self.overlay = None; + } + outcome.repaint = true; + } else if !super::contains(self.hits.help_popup, point) { + self.overlay = None; + outcome.repaint = true; + } + } + _ => {} + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::Navigator(_))) { + let row_hit = self + .hits + .navigator_rows + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied(); + match mouse.kind { + MouseEventKind::Moved => { + if let Some((_, index)) = row_hit { + if let Some(ClientShellOverlay::Navigator(navigator)) = + self.overlay.as_mut() + { + navigator.selected = index; + } + outcome.repaint = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if super::contains(self.hits.navigator_search, point) { + if let Some(ClientShellOverlay::Navigator(navigator)) = + self.overlay.as_mut() + { + navigator.search_focused = true; + navigator.filter = None; + } + outcome.repaint = true; + } else if let Some((rect, index)) = row_hit { + if let Some(ClientShellOverlay::Navigator(navigator)) = + self.overlay.as_mut() + { + navigator.selected = index; + } + let workspace = self + .snapshot + .as_deref() + .zip(self.overlay.as_ref()) + .and_then(|(snapshot, overlay)| match overlay { + ClientShellOverlay::Navigator(navigator) => { + render::client_navigator_rows(snapshot, navigator) + .get(index) + .map(|row| { + matches!( + row.target, + ClientNavigatorTarget::Workspace(_) + ) + }) + } + _ => None, + }) + .unwrap_or(false); + if workspace && mouse.column <= rect.x.saturating_add(3) { + self.toggle_selected_navigator_workspace(); + outcome.repaint = true; + } else { + self.accept_navigator_selection(outcome); + } + } else if !super::contains(self.hits.navigator_popup, point) { + self.overlay = None; + outcome.repaint = true; + } + } + MouseEventKind::ScrollUp => { + self.move_navigator_selection(-3); + outcome.repaint = true; + } + MouseEventKind::ScrollDown => { + self.move_navigator_selection(3); + outcome.repaint = true; + } + _ => {} + } + return; + } + if self.overlay.is_some() { + if mouse.kind != MouseEventKind::Down(MouseButton::Left) { + return; + } + if super::contains(self.hits.overlay_primary, point) { + match self.overlay.as_ref() { + Some(ClientShellOverlay::Rename(_)) => self.save_rename_overlay(outcome), + Some(ClientShellOverlay::ConfirmClose(_)) => { + let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take() + else { + return; + }; + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceClose( + crate::api::schema::WorkspaceCloseParams { + workspace_id: confirm.workspace_id, + close_group: true, + }, + ), + outcome, + ); + outcome.repaint = true; + } + _ => {} + } + } else if super::contains(self.hits.overlay_clear, point) { + if let Some(ClientShellOverlay::Rename(rename)) = self.overlay.as_mut() { + rename.input.clear(); + rename.replace_on_type = false; + outcome.repaint = true; + } + } else { + self.overlay = None; + outcome.repaint = true; + } + return; + } + + if mouse.kind == MouseEventKind::Drag(MouseButton::Left) { + let selection_hit = self.selection.as_ref().and_then(|selection| { + self.hits + .panes + .iter() + .find(|hit| hit.pane_id == selection.pane_id) + .cloned() + }); + if let Some(hit) = selection_hit { + self.update_selection_drag(&hit, mouse.column, mouse.row, outcome); + outcome.repaint = true; + return; + } + } + if mouse.kind == MouseEventKind::Up(MouseButton::Left) && self.selection.is_some() { + self.stop_selection_autoscroll(); + let copied = self + .selection + .as_mut() + .is_some_and(crate::selection::Selection::finish); + if copied && self.config.copy_on_select { + self.request_selection_copy(outcome); + self.selection = None; + } else if !copied { + self.selection = None; + } + if copied { + self.last_pane_click = None; + } + outcome.repaint = true; + return; + } + if self.scroll_in_progress_selection(mouse, outcome) { + return; + } + + match mouse.kind { + MouseEventKind::Down(MouseButton::Right) => { + let pane_hit = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point)) + .cloned(); + if let Some(hit) = pane_hit { + let pane_owns_right_click = self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot + .panes + .iter() + .find(|pane| pane.pane_id == hit.pane_id) + }) + .is_some_and(|pane| pane.right_click_passthrough) + && mouse.modifiers.is_empty(); + let configured_modifiers = self + .config + .right_click_passthrough_modifiers + .filter(|modifiers| *modifiers == mouse.modifiers); + if hit.mouse_reporting + && (pane_owns_right_click || configured_modifiers.is_some()) + { + let stripped_modifiers = + configured_modifiers.unwrap_or(crossterm::event::KeyModifiers::empty()); + self.push_pane_mouse_event( + &hit, + mouse, + mouse.modifiers.difference(stripped_modifiers), + outcome, + ); + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: hit.pane_id.clone(), + }), + outcome, + ); + self.pane_mouse_gesture = Some(ClientPaneMouseGesture { + last_position: self.pane_mouse_position(&hit, mouse), + hit, + button: MouseButton::Right, + stripped_modifiers, + last_event: mouse, + }); + return; + } + } + if !self.config.mouse_capture { + return; + } + let workspace_id = (!self.sidebar_collapsed) + .then(|| { + self.hits + .workspaces + .iter() + .find(|hit| super::contains(hit.rect, point)) + .map(|hit| hit.workspace_id.clone()) + }) + .flatten(); + if let Some(workspace_id) = workspace_id { + self.open_workspace_context_menu(workspace_id, mouse.column, mouse.row); + outcome.repaint = true; + return; + } + let tab_id = self + .hits + .tabs + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .map(|(_, tab_id)| tab_id.clone()); + if let Some(tab_id) = tab_id { + self.open_tab_context_menu(tab_id, mouse.column, mouse.row); + outcome.repaint = true; + return; + } + let pane_id = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.rect, point)) + .map(|hit| hit.pane_id.clone()); + if let Some(pane_id) = pane_id { + self.open_pane_context_menu(pane_id, mouse.column, mouse.row); + outcome.repaint = true; + } + } + MouseEventKind::ScrollUp + if self + .hits + .tabs + .iter() + .any(|(rect, _)| super::contains(*rect, point)) + || super::contains(self.hits.tab_scroll_left, point) + || super::contains(self.hits.tab_scroll_right, point) + || super::contains(self.hits.new_tab, point) => + { + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::PreviousTab), + outcome, + ); + } + MouseEventKind::ScrollDown + if self + .hits + .tabs + .iter() + .any(|(rect, _)| super::contains(*rect, point)) + || super::contains(self.hits.tab_scroll_left, point) + || super::contains(self.hits.tab_scroll_right, point) + || super::contains(self.hits.new_tab, point) => + { + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NextTab), + outcome, + ); + } + MouseEventKind::ScrollUp if super::contains(self.hits.agent_body, point) => { + let next = self.agent_scroll.saturating_sub(1); + if next != self.agent_scroll { + self.agent_scroll = next; + outcome.repaint = true; + } + } + MouseEventKind::ScrollDown if super::contains(self.hits.agent_body, point) => { + let next = self + .agent_scroll + .saturating_add(1) + .min(self.hits.agent_max_scroll); + if next != self.agent_scroll { + self.agent_scroll = next; + outcome.repaint = true; + } + } + MouseEventKind::ScrollUp if super::contains(self.hits.workspace_body, point) => { + let next = self.workspace_scroll.saturating_sub(1); + if next != self.workspace_scroll { + self.workspace_scroll = next; + outcome.repaint = true; + } + } + MouseEventKind::ScrollDown if super::contains(self.hits.workspace_body, point) => { + let next = self + .workspace_scroll + .saturating_add(1) + .min(self.hits.workspace_max_scroll); + if next != self.workspace_scroll { + self.workspace_scroll = next; + outcome.repaint = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if self.selection.take().is_some() { + outcome.repaint = true; + } + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + self.pending_word_selection = None; + let previous_pane_click = self.last_pane_click.take(); + self.workspace_press = None; + self.tab_press = None; + self.chrome_drag = None; + if super::contains(self.hits.sidebar_divider, point) + && !super::contains(self.hits.sidebar_toggle, point) + { + let now = std::time::Instant::now(); + let double_click = self.last_sidebar_divider_click.is_some_and(|last| { + now.duration_since(last) <= std::time::Duration::from_millis(350) + }); + self.last_sidebar_divider_click = Some(now); + if double_click { + self.sidebar_width = self.config.sidebar_width; + self.sidebar_width_manual = false; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + self.persist_chrome_preferences(outcome); + } else { + self.chrome_drag = Some(ClientChromeDrag::SidebarWidth); + self.set_sidebar_width_from_column(mouse.column, outcome); + } + return; + } + if super::contains(self.hits.sidebar_section_divider, point) { + self.chrome_drag = Some(ClientChromeDrag::SidebarSection); + self.set_sidebar_section_from_row(mouse.row, outcome); + return; + } + if super::contains(self.hits.workspace_scrollbar, point) { + if let Some(metrics) = self.hits.workspace_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.workspace_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::WorkspaceScrollbar { grab_row_offset }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.workspace_scrollbar, + mouse.row, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != self.workspace_scroll { + self.workspace_scroll = next; + outcome.repaint = true; + } + } + } + return; + } + if super::contains(self.hits.agent_scrollbar, point) { + if let Some(metrics) = self.hits.agent_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.agent_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::AgentScrollbar { grab_row_offset }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.agent_scrollbar, + mouse.row, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != self.agent_scroll { + self.agent_scroll = next; + outcome.repaint = true; + } + } + } + return; + } + if super::contains(self.hits.agent_sort_toggle, point) { + let sort = match self.config.agent_panel_sort { + crate::config::AgentPanelSortConfig::Spaces => { + crate::config::AgentPanelSortConfig::Priority + } + crate::config::AgentPanelSortConfig::Priority => { + crate::config::AgentPanelSortConfig::Spaces + } + }; + self.config.agent_panel_sort = sort; + self.agent_panel_sort_manual = true; + self.agent_scroll = 0; + self.persist_chrome_preferences(outcome); + outcome.repaint = true; + return; + } + if super::contains(self.hits.global_launcher, point) { + self.toggle_global_menu(); + outcome.repaint = true; + return; + } + if super::contains(self.hits.new_workspace, point) { + self.record_binding( + crate::input::KeybindMatch::Action( + crate::input::KeybindAction::NewWorkspace, + ), + outcome, + ); + return; + } + if super::contains(self.hits.new_tab, point) { + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewTab), + outcome, + ); + return; + } + if super::contains(self.hits.tab_scroll_left, point) { + self.tab_scroll = self.tab_scroll.saturating_sub(1); + outcome.repaint = true; + return; + } + if super::contains(self.hits.tab_scroll_right, point) { + let tab_count = self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot.focused_workspace_id.as_deref().map(|id| { + snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == id) + .count() + }) + }) + .unwrap_or(0); + self.tab_scroll = self + .tab_scroll + .saturating_add(1) + .min(tab_count.saturating_sub(1)); + outcome.repaint = true; + return; + } + if super::contains(self.hits.sidebar_toggle, point) { + self.sidebar_collapsed = !self.sidebar_collapsed; + self.sidebar_collapsed_manual = true; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + self.persist_chrome_preferences(outcome); + return; + } + for hit in &self.hits.workspaces { + if let Some((rect, key)) = &hit.group_toggle { + if super::contains(*rect, point) { + if !self.collapsed_groups.remove(key) { + self.collapsed_groups.insert(key.clone()); + } + outcome.repaint = true; + self.persist_chrome_preferences(outcome); + return; + } + } + } + let workspace_press = self + .hits + .workspaces + .iter() + .find(|hit| super::contains(hit.rect, point)) + .map(|hit| ClientWorkspacePress { + workspace_id: hit.workspace_id.clone(), + start_column: mouse.column, + start_row: mouse.row, + }); + if let Some(workspace_press) = workspace_press { + self.workspace_press = Some(workspace_press); + return; + } + let tab_press = self + .config + .mouse_capture + .then(|| { + self.hits + .tabs + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .and_then(|(_, tab_id)| { + let tab = self + .snapshot + .as_deref()? + .tabs + .iter() + .find(|tab| tab.tab_id == *tab_id)?; + Some(ClientTabPress { + tab_id: tab.tab_id.clone(), + workspace_id: tab.workspace_id.clone(), + start_column: mouse.column, + start_row: mouse.row, + }) + }) + }) + .flatten(); + if let Some(tab_press) = tab_press { + self.tab_press = Some(tab_press); + return; + } + let agent_pane_id = self + .hits + .agents + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .map(|(_, pane_id)| pane_id.clone()); + if let Some(pane_id) = agent_pane_id { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id, + }), + outcome, + ); + return; + } + let scrollbar_hit = self + .hits + .panes + .iter() + .find(|hit| { + hit.scrollbar_rect + .is_some_and(|rect| super::contains(rect, point)) + && hit + .scroll + .is_some_and(|metrics| metrics.max_offset_from_bottom > 0) + }) + .cloned(); + if let Some(hit) = scrollbar_hit { + self.mode = ClientShellMode::Terminal; + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: hit.pane_id.clone(), + }), + outcome, + ); + let (Some(track), Some(metrics)) = (hit.scrollbar_rect, hit.scroll) else { + return; + }; + if let Some(grab_row_offset) = + crate::ui::scrollbar_thumb_grab_offset(metrics, track, mouse.row) + { + self.chrome_drag = Some(ClientChromeDrag::PaneScrollbar { + hit, + grab_row_offset, + last_sent_offset: None, + last_sent_at: None, + }); + } else if let Some(offset) = Self::pane_scrollbar_offset(&hit, mouse.row, None) + { + self.push_pane_scroll_offset(hit.pane_id, offset, outcome); + } + return; + } + let split_hit = self + .hits + .pane_splits + .iter() + .find(|hit| super::contains(hit.hit_rect, point)) + .cloned(); + if let Some(hit) = split_hit { + let Some(tab_id) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_tab_id.clone()) + else { + return; + }; + let pointer = match hit.direction { + crate::protocol::PaneSurfaceSplitDirection::Horizontal => mouse.column, + crate::protocol::PaneSurfaceSplitDirection::Vertical => mouse.row, + }; + self.chrome_drag = Some(ClientChromeDrag::PaneSplit { + grab_offset: i32::from(hit.pos) - i32::from(pointer), + last_sent_ratio: None, + last_sent_at: None, + hit, + tab_id, + }); + return; + } + let pane_hit = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.rect, point)) + .cloned(); + if let Some(hit) = pane_hit { + if hit.mouse_reporting && super::contains(hit.inner_rect, point) { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + self.pane_mouse_gesture = Some(ClientPaneMouseGesture { + last_position: self.pane_mouse_position(&hit, mouse), + hit: hit.clone(), + button: MouseButton::Left, + stripped_modifiers: crossterm::event::KeyModifiers::empty(), + last_event: mouse, + }); + } else if super::contains(hit.inner_rect, point) { + let click = ClientPaneClick { + pane_id: hit.pane_id.clone(), + viewport_row: mouse.row.saturating_sub(hit.inner_rect.y), + col: mouse.column.saturating_sub(hit.inner_rect.x), + at: std::time::Instant::now(), + }; + if mouse.modifiers.is_empty() + && previous_pane_click + .as_ref() + .is_some_and(|previous| previous.is_double_click_for(&click)) + { + self.request_word_selection( + &hit, + click.viewport_row, + click.col, + outcome, + ); + } else { + if mouse.modifiers.is_empty() { + self.last_pane_click = Some(click); + } + self.selection = Some(crate::selection::Selection::anchor( + hit.pane_id.clone(), + mouse.row.saturating_sub(hit.inner_rect.y), + mouse.column.saturating_sub(hit.inner_rect.x), + hit.scroll, + )); + } + } + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: hit.pane_id, + }), + outcome, + ); + } + } + MouseEventKind::Down(MouseButton::Middle) => { + if let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point) && hit.mouse_reporting) + .cloned() + { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + self.pane_mouse_gesture = Some(ClientPaneMouseGesture { + last_position: self.pane_mouse_position(&hit, mouse), + hit, + button: MouseButton::Middle, + stripped_modifiers: crossterm::event::KeyModifiers::empty(), + last_event: mouse, + }); + } + } + MouseEventKind::Up(MouseButton::Left | MouseButton::Middle) + | MouseEventKind::Drag(MouseButton::Left | MouseButton::Middle) => {} + MouseEventKind::Moved => { + if let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point) && hit.mouse_reporting) + .cloned() + { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + } + } + MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight => { + if let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point)) + .cloned() + { + if self.focused_pane_id().as_deref() != Some(hit.pane_id.as_str()) { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: hit.pane_id.clone(), + }), + outcome, + ); + } + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + } + } + _ => {} + } + } + + fn pane_mouse_position(&self, hit: &PaneHit, mouse: MouseEvent) -> ClientMousePosition { + let cell = ClientMousePosition::Cell { + column: mouse.column.saturating_sub(hit.inner_rect.x), + row: mouse.row.saturating_sub(hit.inner_rect.y), + }; + if hit.sgr_pixel_mouse && hit.pixel_width > 0 && hit.pixel_height > 0 { + self.host_mouse_pixels + .and_then(|pixels| { + pixels + .pane_position(hit.inner_rect, hit.pixel_width, hit.pixel_height) + .and_then(|position| match position { + crate::input::mouse::Position::Pixels { x, y } => { + Some(ClientMousePosition::Pixels { + x, + y, + column: mouse.column.saturating_sub(hit.inner_rect.x), + row: mouse.row.saturating_sub(hit.inner_rect.y), + }) + } + crate::input::mouse::Position::Cell { .. } => None, + }) + }) + .unwrap_or(cell) + } else { + cell + } + } + + pub(super) fn push_pane_mouse_event( + &self, + hit: &PaneHit, + mouse: MouseEvent, + modifiers: crossterm::event::KeyModifiers, + outcome: &mut ClientShellInput, + ) { + let Some(kind) = crate::protocol::ClientMouseKind::from_crossterm(mouse.kind) else { + return; + }; + let position = self.pane_mouse_position(hit, mouse); + let geometry = matches!(position, ClientMousePosition::Pixels { .. }).then_some( + crate::protocol::ClientMouseGeometry { + cols: hit.inner_rect.width, + rows: hit.inner_rect.height, + width_px: hit.pixel_width, + height_px: hit.pixel_height, + }, + ); + let target = if hit.popup { + ClientInputTarget::Popup(hit.pane_id.clone()) + } else { + ClientInputTarget::Pane(hit.pane_id.clone()) + }; + push_target_event( + target, + ClientPaneInputEvent::Mouse { + kind, + position, + geometry, + modifiers: modifiers.bits(), + lines: self.config.mouse_scroll_lines.min(u16::MAX as usize) as u16, + }, + outcome, + ); + } +} diff --git a/src/client/shell/notifications.rs b/src/client/shell/notifications.rs new file mode 100644 index 00000000..0310e57d --- /dev/null +++ b/src/client/shell/notifications.rs @@ -0,0 +1,418 @@ +use super::*; +use ratatui::{ + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Widget}, +}; + +pub(super) fn render_mobile_notification_banner( + buffer: &mut Buffer, + area: Rect, + notification: &ClientVisibleNotification, + offset_for_warning: bool, + palette: &Palette, +) -> Rect { + if area.is_empty() { + return Rect::default(); + } + let warning_offset = u16::from(offset_for_warning); + let y = area.y + + area + .height + .saturating_sub(1u16.saturating_add(warning_offset)); + let rect = Rect::new(area.x, y, area.width, 1); + let background = palette.surface0; + Clear.render(rect, buffer); + buffer.set_style(rect, Style::default().bg(background)); + let event = ¬ification.event; + let title = match event.kind { + SemanticNotificationKind::NeedsAttention => event + .title + .strip_suffix(" needs attention") + .map(|agent| format!("{agent} waiting")) + .unwrap_or_else(|| event.title.clone()), + SemanticNotificationKind::Finished => event + .title + .strip_suffix(" finished") + .map(|agent| format!("{agent} done")) + .unwrap_or_else(|| event.title.clone()), + SemanticNotificationKind::UpdateInstalled => "update ready".to_owned(), + SemanticNotificationKind::Custom => event.title.clone(), + }; + let dot_color = match event.kind { + SemanticNotificationKind::NeedsAttention => palette.red, + SemanticNotificationKind::Finished => palette.blue, + SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => { + palette.accent + } + }; + let mut x = rect.x; + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + " ", + Style::default().bg(background), + ); + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + "●", + Style::default().fg(dot_color).bg(background), + ); + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + " ", + Style::default().bg(background), + ); + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + &title, + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ); + if let Some(body) = event.body.as_deref().filter(|body| !body.is_empty()) { + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + " · ", + Style::default().fg(palette.overlay0).bg(background), + ); + super::render::put_text( + buffer, + x, + rect.y, + rect.right().saturating_sub(x), + body, + Style::default().fg(palette.overlay0).bg(background), + ); + } + rect +} + +pub(super) fn render_visible_notification( + buffer: &mut Buffer, + area: Rect, + notification: &ClientVisibleNotification, + default_position: crate::config::ToastHerdrPosition, + top_offset: u16, + palette: &Palette, +) -> Rect { + if area.is_empty() { + return Rect::default(); + } + let event = ¬ification.event; + let body = event.body.as_deref().unwrap_or_default(); + let content_width = unicode_width::UnicodeWidthStr::width(event.title.as_str()) + .max(unicode_width::UnicodeWidthStr::width(body)) + .saturating_add(6); + let width = u16::try_from(content_width) + .unwrap_or(u16::MAX) + .min(area.width); + let height: u16 = if body.is_empty() { 3 } else { 4 }.min(area.height); + let position = event.position.unwrap_or(default_position); + let x = match position { + crate::config::ToastHerdrPosition::TopLeft + | crate::config::ToastHerdrPosition::BottomLeft => area.x, + crate::config::ToastHerdrPosition::TopRight + | crate::config::ToastHerdrPosition::BottomRight => area.right().saturating_sub(width), + }; + let max_y = area.bottom().saturating_sub(height).max(area.y); + let y = match position { + crate::config::ToastHerdrPosition::TopLeft + | crate::config::ToastHerdrPosition::TopRight => area.y.saturating_add(top_offset), + crate::config::ToastHerdrPosition::BottomLeft + | crate::config::ToastHerdrPosition::BottomRight => area + .bottom() + .saturating_sub(height.saturating_add(top_offset)), + } + .clamp(area.y, max_y); + let rect = Rect::new(x, y, width, height); + Clear.render(rect, buffer); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(palette.overlay0)) + .style(Style::default().bg(palette.panel_bg)); + let inner = block.inner(rect); + block.render(rect, buffer); + Paragraph::new(Line::from(vec![ + Span::styled( + "●", + Style::default().fg(match event.kind { + SemanticNotificationKind::NeedsAttention => palette.red, + SemanticNotificationKind::Finished => palette.blue, + SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => { + palette.accent + } + }), + ), + Span::raw(" "), + Span::styled( + event.title.as_str(), + Style::default() + .fg(palette.text) + .add_modifier(Modifier::BOLD), + ), + ])) + .render(Rect::new(inner.x, inner.y, inner.width, 1), buffer); + if !body.is_empty() && inner.height > 1 { + Paragraph::new(Line::from(Span::styled( + body, + Style::default().fg(palette.overlay0), + ))) + .render( + Rect::new( + inner.x.saturating_add(2), + inner.y + 1, + inner.width.saturating_sub(2), + 1, + ), + buffer, + ); + } + rect +} + +impl ClientShellState { + pub(super) fn focus_visible_notification(&mut self, outcome: &mut ClientShellInput) { + let Some(notification) = self.visible_notification.take() else { + return; + }; + outcome.repaint = true; + if let Some(pane_id) = notification.event.pane_id { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }), + outcome, + ); + } + } + + pub(crate) fn receive_notification( + &mut self, + event: SemanticNotification, + now: std::time::Instant, + ) -> (Vec<ClientShellNotificationEffect>, bool) { + let delay = if event.kind == SemanticNotificationKind::Custom { + 0 + } else { + self.config.toast_delay_seconds + }; + let deadline = now + .checked_add(std::time::Duration::from_secs(delay)) + .unwrap_or(now); + let cleared_visible = event.pane_id.as_deref().is_some_and(|pane_id| { + self.visible_notification + .as_ref() + .is_some_and(|visible| visible.event.pane_id.as_deref() == Some(pane_id)) + }); + if let Some(pane_id) = event.pane_id.as_deref() { + self.pending_notifications + .retain(|pending| pending.event.pane_id.as_deref() != Some(pane_id)); + if cleared_visible { + self.visible_notification = None; + } + } + self.pending_notifications.push(ClientPendingNotification { + event, + deadline, + validate_state: delay > 0, + }); + let (effects, repaint) = self.tick_notifications(now); + (effects, repaint || cleared_visible) + } + + pub(crate) fn tick_notifications( + &mut self, + now: std::time::Instant, + ) -> (Vec<ClientShellNotificationEffect>, bool) { + let mut repaint = false; + if self + .visible_notification + .as_ref() + .is_some_and(|visible| now >= visible.deadline) + { + self.visible_notification = None; + repaint = true; + } + + let pending = std::mem::take(&mut self.pending_notifications); + let mut effects = Vec::new(); + for pending in pending { + if pending.deadline > now { + self.pending_notifications.push(pending); + continue; + } + if pending.validate_state && !self.notification_still_current(&pending.event) { + continue; + } + let target_active = self.notification_target_is_active(&pending.event); + let suppress_external = target_active && self.outer_focused != Some(false); + if let Some(sound) = pending.event.sound { + let suppress_sound = + pending.event.kind == SemanticNotificationKind::Finished && suppress_external; + if !suppress_sound { + effects.push(ClientShellNotificationEffect::Sound { + sound: match sound { + SemanticNotificationSound::Done => crate::sound::Sound::Done, + SemanticNotificationSound::Request => crate::sound::Sound::Request, + }, + agent: pending.event.agent.clone(), + }); + } + } + + match self.config.toast_delivery { + crate::config::ToastDelivery::Off => {} + crate::config::ToastDelivery::Herdr if !target_active => { + let duration = match pending.event.kind { + SemanticNotificationKind::NeedsAttention => 8, + SemanticNotificationKind::Finished => 5, + SemanticNotificationKind::UpdateInstalled => 3, + SemanticNotificationKind::Custom => 5, + }; + self.visible_notification = Some(ClientVisibleNotification { + event: pending.event, + deadline: now + std::time::Duration::from_secs(duration), + }); + repaint = true; + } + crate::config::ToastDelivery::Herdr => {} + crate::config::ToastDelivery::Terminal if !suppress_external => { + effects.push(ClientShellNotificationEffect::Terminal { + title: pending.event.title, + body: pending.event.body, + }); + } + crate::config::ToastDelivery::System if !suppress_external => { + effects.push(ClientShellNotificationEffect::System { + title: pending.event.title, + body: pending.event.body, + }); + } + crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {} + } + } + (effects, repaint) + } + + fn notification_target_is_active(&self, event: &SemanticNotification) -> bool { + let Some(snapshot) = self.snapshot.as_deref() else { + return false; + }; + if let Some(tab_id) = event.tab_id.as_deref() { + return snapshot.focused_tab_id.as_deref() == Some(tab_id); + } + event.workspace_id.as_deref().is_some_and(|workspace_id| { + snapshot.focused_workspace_id.as_deref() == Some(workspace_id) + }) + } + + fn notification_still_current(&self, event: &SemanticNotification) -> bool { + let Some(pane_id) = event.pane_id.as_deref() else { + return true; + }; + let Some(agent) = self.snapshot.as_deref().and_then(|snapshot| { + snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id) + }) else { + return false; + }; + match event.kind { + SemanticNotificationKind::NeedsAttention => { + agent.agent_status == crate::api::schema::AgentStatus::Blocked + } + SemanticNotificationKind::Finished => matches!( + agent.agent_status, + crate::api::schema::AgentStatus::Idle | crate::api::schema::AgentStatus::Done + ), + SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn notification() -> ClientVisibleNotification { + ClientVisibleNotification { + event: SemanticNotification { + kind: SemanticNotificationKind::Custom, + title: "notice".into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: None, + }, + deadline: std::time::Instant::now(), + } + } + + #[test] + fn mobile_notification_is_a_bottom_banner_with_released_title() { + let palette = crate::app::client_palette_from_config(&Config::default()); + let mut notification = notification(); + notification.event.kind = SemanticNotificationKind::NeedsAttention; + notification.event.title = "pi needs attention".into(); + notification.event.body = Some("workspace · tab 1".into()); + let area = Rect::new(0, 0, 44, 20); + let mut buffer = Buffer::empty(area); + for cell in &mut buffer.content { + cell.set_symbol("X"); + } + let rect = + render_mobile_notification_banner(&mut buffer, area, ¬ification, true, &palette); + assert_eq!(rect, Rect::new(0, 18, 44, 1)); + let text = buffer + .content + .iter() + .map(|cell| cell.symbol()) + .collect::<String>(); + assert!(text.contains("pi waiting")); + assert!(text.contains("workspace · tab 1")); + assert!(buffer.content[18 * 44..19 * 44] + .iter() + .all(|cell| cell.symbol() != "X")); + } + + #[test] + fn notification_rect_stays_inside_short_nonzero_area() { + let palette = crate::app::client_palette_from_config(&Config::default()); + for height in [1, 2] { + let area = Rect::new(3, 4, 8, height); + for position in [ + crate::config::ToastHerdrPosition::TopRight, + crate::config::ToastHerdrPosition::BottomRight, + ] { + let mut buffer = Buffer::empty(Rect::new(0, 0, 20, 10)); + let rect = render_visible_notification( + &mut buffer, + area, + ¬ification(), + position, + 1, + &palette, + ); + assert!(rect.y >= area.y); + assert!(rect.bottom() <= area.bottom()); + } + } + } +} diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs new file mode 100644 index 00000000..154b00b6 --- /dev/null +++ b/src/client/shell/overlay_input.rs @@ -0,0 +1,1091 @@ +use super::*; + +impl ClientShellState { + pub(super) fn dismiss_product_announcement(&mut self, outcome: &mut ClientShellInput) { + let announcement = match self.overlay.take() { + Some(ClientShellOverlay::ProductAnnouncement(announcement)) => announcement, + other => { + self.overlay = other; + return; + } + }; + if self.snapshot.is_none() { + self.overlay = Some(ClientShellOverlay::ProductAnnouncement(announcement)); + return; + } + self.dismissed_product_announcement = + Some((announcement.version.clone(), announcement.id.clone())); + self.chrome_drag = None; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ProductAnnouncementDismiss( + crate::api::schema::ProductAnnouncementDismissParams { + version: announcement.version.clone(), + id: announcement.id.clone(), + }, + ), + PendingEndpointKind::ProductAnnouncementDismiss { + version: announcement.version, + id: announcement.id, + }, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn scroll_product_announcement(&mut self, delta: isize) { + let max_scroll = self.hits.product_announcement_max_scroll; + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = self.overlay.as_mut() { + let current = usize::from(announcement.scroll); + let next = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta as usize) + } + .min(max_scroll); + announcement.scroll = u16::try_from(next).unwrap_or(u16::MAX); + } + } + + pub(super) fn set_product_announcement_offset_from_bottom( + &mut self, + offset_from_bottom: usize, + ) { + let max_scroll = self.hits.product_announcement_max_scroll; + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = self.overlay.as_mut() { + announcement.scroll = + u16::try_from(max_scroll.saturating_sub(offset_from_bottom.min(max_scroll))) + .unwrap_or(u16::MAX); + } + } + + pub(super) fn open_release_notes(&mut self) { + let Some(notes) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.release_notes.as_ref()) + else { + return; + }; + self.overlay = Some(ClientShellOverlay::ReleaseNotes(release_notes_state(notes))); + self.chrome_drag = None; + } + + pub(super) fn dismiss_release_notes(&mut self, outcome: &mut ClientShellInput) { + let notes = match self.overlay.take() { + Some(ClientShellOverlay::ReleaseNotes(notes)) => notes, + other => { + self.overlay = other; + return; + } + }; + self.chrome_drag = None; + self.mode = if self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.as_deref()) + .is_some() + { + ClientShellMode::Terminal + } else { + ClientShellMode::Navigate + }; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ReleaseNotesDismiss( + crate::api::schema::ReleaseNotesDismissParams { + version: notes.version.clone(), + }, + ), + PendingEndpointKind::ReleaseNotesDismiss, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn current_release_notes_input_geometry( + &self, + ) -> Option<(Rect, Option<Rect>, crate::pane::ScrollMetrics)> { + let notes = match self.overlay.as_ref()? { + ClientShellOverlay::ReleaseNotes(notes) => notes, + _ => return None, + }; + let (cols, rows) = self.last_composed_size?; + let outer = crate::ui::centered_popup_rect( + Rect::new(0, 0, cols, rows), + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + )?; + let inner = Rect::new( + outer.x.saturating_add(1), + outer.y.saturating_add(1), + outer.width.saturating_sub(2), + outer.height.saturating_sub(2), + ); + if inner.height < 8 || inner.width < 20 { + return None; + } + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + let close = crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )); + let install_command = self + .snapshot + .as_deref() + .map(|snapshot| snapshot.update_install_command.as_str()) + .unwrap_or_default(); + let metrics = crate::ui::release_notes_scroll_metrics( + notes, + install_command, + stack.content, + &self.config.palette, + ); + let track = crate::ui::release_notes_scrollbar_rect(stack.content, metrics); + Some((close, track, metrics)) + } + + fn current_release_notes_max_scroll(&self) -> usize { + self.current_release_notes_input_geometry() + .map(|(_, _, metrics)| metrics.max_offset_from_bottom) + .unwrap_or(self.hits.release_notes_max_scroll) + } + + pub(super) fn scroll_release_notes(&mut self, delta: isize) { + let max_scroll = self.current_release_notes_max_scroll(); + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + let current = usize::from(notes.scroll); + let next = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta as usize) + } + .min(max_scroll); + notes.scroll = u16::try_from(next).unwrap_or(u16::MAX); + } + } + + pub(super) fn set_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) { + let max_scroll = self.current_release_notes_max_scroll(); + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + notes.scroll = + u16::try_from(max_scroll.saturating_sub(offset_from_bottom.min(max_scroll))) + .unwrap_or(u16::MAX); + } + } + + pub(super) fn complete_onboarding(&mut self, outcome: &mut ClientShellInput) { + if self.snapshot.is_none() { + return; + } + if let Err(error) = crate::config::update_file_at( + &self.config.local_config_path, + "onboarding setting", + |content| crate::config::upsert_top_level_bool(content, "onboarding", false), + ) { + self.set_local_config_diagnostic(Some(error)); + } + self.config.startup_onboarding = false; + self.open_settings_overlay(); + self.select_settings_section(ClientSettingsSection::Integrations, outcome); + } + + pub(super) fn open_navigator_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let expanded_workspaces = snapshot + .workspaces + .iter() + .map(|workspace| workspace.workspace_id.clone()) + .collect(); + let mut navigator = ClientNavigatorOverlay { + query: String::new(), + search_focused: false, + selected: 0, + scroll: 0, + filter: None, + expanded_workspaces, + }; + let rows = render::client_navigator_rows(snapshot, &navigator); + navigator.selected = rows.iter().position(|row| row.current).unwrap_or(0); + self.overlay = Some(ClientShellOverlay::Navigator(navigator)); + } + + pub(super) fn move_navigator_selection(&mut self, delta: isize) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else { + return; + }; + let rows = render::client_navigator_rows(snapshot, navigator); + if rows.is_empty() { + navigator.selected = 0; + return; + } + navigator.selected = (navigator.selected as isize + delta) + .clamp(0, rows.len().saturating_sub(1) as isize) as usize; + } + + pub(super) fn accept_navigator_selection(&mut self, outcome: &mut ClientShellInput) { + let target = { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else { + return; + }; + render::client_navigator_rows(snapshot, navigator) + .get(navigator.selected) + .map(|row| row.target.clone()) + }; + let Some(target) = target else { + return; + }; + self.overlay = None; + let method = match target { + ClientNavigatorTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id, + }) + } + ClientNavigatorTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id }) + } + ClientNavigatorTarget::Pane(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }) + } + }; + self.push_endpoint_method(method, outcome); + outcome.repaint = true; + } + + pub(super) fn toggle_selected_navigator_workspace(&mut self) { + let workspace_id = { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else { + return; + }; + render::client_navigator_rows(snapshot, navigator) + .get(navigator.selected) + .and_then(|row| match &row.target { + ClientNavigatorTarget::Workspace(workspace_id) => Some(workspace_id.clone()), + _ => None, + }) + }; + if let (Some(workspace_id), Some(ClientShellOverlay::Navigator(navigator))) = + (workspace_id, self.overlay.as_mut()) + { + if !navigator.expanded_workspaces.remove(&workspace_id) { + navigator.expanded_workspaces.insert(workspace_id); + } + navigator.selected = 0; + navigator.scroll = 0; + } + } + + pub(super) fn workspace_action_id(&self) -> Option<String> { + self.navigate_workspace_id.clone().or_else(|| { + self.snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.clone()) + }) + } + + pub(super) fn open_new_workspace_overlay(&mut self) { + let source_workspace_id = self.workspace_action_id(); + let cwd = self.snapshot.as_deref().and_then(|snapshot| { + let workspace_id = source_workspace_id.as_deref()?; + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + .map(|workspace| workspace.new_workspace_cwd.clone()) + }); + let suggested_name = cwd + .as_deref() + .map(std::path::Path::new) + .map(crate::workspace::derive_label_from_cwd) + .unwrap_or_else(|| "workspace".to_owned()); + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "new workspace", + input: suggested_name.clone(), + replace_on_type: true, + target: ClientRenameTarget::NewWorkspace { + source_workspace_id, + cwd, + suggested_name, + }, + })); + } + + pub(super) fn open_rename_workspace_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(workspace_id) = self.workspace_action_id() else { + return; + }; + let Some(workspace) = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + else { + return; + }; + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename workspace", + input: workspace.label.clone(), + replace_on_type: false, + target: ClientRenameTarget::Workspace { workspace_id }, + })); + } + + pub(super) fn open_new_tab_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(workspace_id) = snapshot.focused_workspace_id.clone() else { + return; + }; + let default_name = (snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .count() + + 1) + .to_string(); + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "new tab", + input: default_name.clone(), + replace_on_type: true, + target: ClientRenameTarget::NewTab { + workspace_id, + default_name, + }, + })); + } + + pub(super) fn open_rename_tab_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(tab_id) = snapshot.focused_tab_id.as_deref() else { + return; + }; + let Some(tab) = snapshot.tabs.iter().find(|tab| tab.tab_id == tab_id) else { + return; + }; + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename tab", + input: tab.label.clone(), + replace_on_type: false, + target: ClientRenameTarget::Tab { + tab_id: tab.tab_id.clone(), + auto_name: !tab.custom_label, + original_name: tab.label.clone(), + }, + })); + } + + pub(super) fn open_rename_pane_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(pane_id) = snapshot.focused_pane_id.as_deref() else { + return; + }; + let Some(pane) = snapshot.panes.iter().find(|pane| pane.pane_id == pane_id) else { + return; + }; + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename pane", + input: pane.label.clone().unwrap_or_default(), + replace_on_type: pane.label.is_none(), + target: ClientRenameTarget::Pane { + pane_id: pane.pane_id.clone(), + }, + })); + } + + pub(super) fn insert_overlay_text(&mut self, text: &str) -> bool { + if self.insert_worktree_overlay_text(text) { + return true; + } + match self.overlay.as_mut() { + Some(ClientShellOverlay::Rename(rename)) => { + if rename.replace_on_type { + rename.input.clear(); + rename.replace_on_type = false; + } + rename.input.push_str(text); + true + } + Some(ClientShellOverlay::Help(help)) if help.search_focused => { + help.query + .extend(text.chars().filter(|character| !character.is_control())); + help.scroll = 0; + true + } + Some(ClientShellOverlay::Navigator(navigator)) if navigator.search_focused => { + navigator.query.push_str(text); + navigator.filter = None; + navigator.selected = 0; + true + } + _ => false, + } + } + + pub(super) fn route_overlay_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + use crossterm::event::KeyModifiers; + + if matches!(self.overlay, Some(ClientShellOverlay::Onboarding)) { + if matches!( + key.code, + KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') + ) { + self.complete_onboarding(outcome); + } + return; + } + + if matches!( + self.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + ) { + match key.code { + KeyCode::Enter | KeyCode::Esc => self.dismiss_product_announcement(outcome), + KeyCode::Up | KeyCode::Char('k') => { + self.scroll_product_announcement(-1); + outcome.repaint = true; + } + KeyCode::Down | KeyCode::Char('j') => { + self.scroll_product_announcement(1); + outcome.repaint = true; + } + KeyCode::PageUp => { + self.scroll_product_announcement(-8); + outcome.repaint = true; + } + KeyCode::PageDown => { + self.scroll_product_announcement(8); + outcome.repaint = true; + } + KeyCode::Home => { + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = + self.overlay.as_mut() + { + announcement.scroll = 0; + } + outcome.repaint = true; + } + KeyCode::End => { + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = + self.overlay.as_mut() + { + announcement.scroll = + u16::try_from(self.hits.product_announcement_max_scroll) + .unwrap_or(u16::MAX); + } + outcome.repaint = true; + } + _ => {} + } + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::ReleaseNotes(_))) { + match key.code { + KeyCode::Enter | KeyCode::Esc => self.dismiss_release_notes(outcome), + KeyCode::Up | KeyCode::Char('k') => { + self.scroll_release_notes(-1); + outcome.repaint = true; + } + KeyCode::Down | KeyCode::Char('j') => { + self.scroll_release_notes(1); + outcome.repaint = true; + } + KeyCode::PageUp => { + self.scroll_release_notes(-8); + outcome.repaint = true; + } + KeyCode::PageDown => { + self.scroll_release_notes(8); + outcome.repaint = true; + } + KeyCode::Home => { + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + notes.scroll = 0; + } + outcome.repaint = true; + } + KeyCode::End => { + let max_scroll = self.current_release_notes_max_scroll(); + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + notes.scroll = u16::try_from(max_scroll).unwrap_or(u16::MAX); + } + outcome.repaint = true; + } + _ => {} + } + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::GlobalMenu(_))) { + match key.code { + KeyCode::Esc => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Up | KeyCode::Char('k') => { + self.move_global_menu_selection(-1); + outcome.repaint = true; + } + KeyCode::Down | KeyCode::Char('j') => { + self.move_global_menu_selection(1); + outcome.repaint = true; + } + KeyCode::Enter => { + let highlighted = match self.overlay.as_ref() { + Some(ClientShellOverlay::GlobalMenu(menu)) => menu.highlighted, + _ => return, + }; + self.activate_global_menu_item(highlighted, outcome); + } + _ => {} + } + return; + } + + if self.route_settings_key(key, outcome) { + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::ContextMenu(_))) { + match key.code { + KeyCode::Esc => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Up => { + self.move_context_menu_selection(-1); + outcome.repaint = true; + } + KeyCode::Down => { + self.move_context_menu_selection(1); + outcome.repaint = true; + } + KeyCode::Enter => { + let highlighted = match self.overlay.as_ref() { + Some(ClientShellOverlay::ContextMenu(menu)) => menu.highlighted, + _ => return, + }; + self.activate_context_menu_item(highlighted, outcome); + } + _ => {} + } + return; + } + + if self.route_worktree_overlay_key(key, outcome) { + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::Navigator(_))) { + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + let search_focused = matches!( + self.overlay, + Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { + search_focused: true, + .. + })) + ); + if code == KeyCode::Esc { + if search_focused { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.search_focused = false; + } + } else { + self.overlay = None; + } + outcome.repaint = true; + return; + } + if code == KeyCode::Enter { + self.accept_navigator_selection(outcome); + return; + } + if search_focused { + if code == KeyCode::Up + || code == KeyCode::Char('p') && modifiers.contains(KeyModifiers::CONTROL) + { + self.move_navigator_selection(-1); + outcome.repaint = true; + return; + } + if code == KeyCode::Down + || code == KeyCode::Char('n') && modifiers.contains(KeyModifiers::CONTROL) + { + self.move_navigator_selection(1); + outcome.repaint = true; + return; + } + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + if code == KeyCode::Char('u') && modifiers.contains(KeyModifiers::CONTROL) { + navigator.query.clear(); + navigator.filter = None; + navigator.selected = 0; + } else if code == KeyCode::Backspace { + navigator.query.pop(); + navigator.filter = None; + navigator.selected = 0; + } else if let KeyCode::Char(character) = code { + if modifiers.difference(KeyModifiers::SHIFT).is_empty() { + navigator.filter = None; + if let Some(text) = key.generated_text.as_deref() { + navigator.query.push_str(text); + } else { + navigator.query.push(character); + } + navigator.selected = 0; + } + } + outcome.repaint = true; + } + return; + } + if code == KeyCode::Backspace && modifiers.is_empty() { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + if navigator.filter.take().is_some() { + navigator.selected = 0; + } + } + outcome.repaint = true; + return; + } + if code == KeyCode::Home && modifiers.is_empty() { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.selected = 0; + navigator.scroll = 0; + } + outcome.repaint = true; + return; + } + if matches!(code, KeyCode::End | KeyCode::Char('G')) && modifiers.is_empty() { + let last = self + .snapshot + .as_deref() + .zip(self.overlay.as_ref()) + .and_then(|(snapshot, overlay)| match overlay { + ClientShellOverlay::Navigator(navigator) => { + Some(render::client_navigator_rows(snapshot, navigator).len()) + } + _ => None, + }) + .unwrap_or(0) + .saturating_sub(1); + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.selected = last; + } + outcome.repaint = true; + return; + } + if code == KeyCode::Char('/') && modifiers.is_empty() { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.search_focused = true; + navigator.filter = None; + } + outcome.repaint = true; + return; + } + if matches!(code, KeyCode::Down | KeyCode::Char('j')) && modifiers.is_empty() { + self.move_navigator_selection(1); + outcome.repaint = true; + return; + } + if matches!(code, KeyCode::Up | KeyCode::Char('k')) && modifiers.is_empty() { + self.move_navigator_selection(-1); + outcome.repaint = true; + return; + } + if code == KeyCode::Char('d') && modifiers.contains(KeyModifiers::CONTROL) { + self.move_navigator_selection(8); + outcome.repaint = true; + return; + } + if code == KeyCode::Char('u') && modifiers.contains(KeyModifiers::CONTROL) { + self.move_navigator_selection(-8); + outcome.repaint = true; + return; + } + if let Some(filter) = match code { + KeyCode::Char('b') if modifiers.is_empty() => Some(ClientNavigatorFilter::Blocked), + KeyCode::Char('w') if modifiers.is_empty() => Some(ClientNavigatorFilter::Working), + KeyCode::Char('i') if modifiers.is_empty() => Some(ClientNavigatorFilter::Idle), + KeyCode::Char('d') if modifiers.is_empty() => Some(ClientNavigatorFilter::Done), + _ => None, + } { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.query.clear(); + navigator.filter = Some(filter); + navigator.selected = 0; + } + outcome.repaint = true; + return; + } + if code == KeyCode::Char('a') && modifiers.is_empty() { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.query.clear(); + navigator.filter = None; + navigator.selected = 0; + } + outcome.repaint = true; + return; + } + if code == KeyCode::Char(' ') && modifiers.is_empty() { + self.toggle_selected_navigator_workspace(); + outcome.repaint = true; + return; + } + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::Help(_))) { + let text_character = crate::input::keybind_help_text_char(key); + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + let search_focused = matches!( + self.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { + search_focused: true, + .. + })) + ); + if search_focused { + match code { + KeyCode::Esc => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.search_focused = false; + help.query.clear(); + help.scroll = 0; + } + } + KeyCode::Enter => self.overlay = None, + KeyCode::Home => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = 0; + } + } + KeyCode::End => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = self.hits.help_max_scroll; + } + } + KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown => { + let delta = match code { + KeyCode::Up => -1, + KeyCode::Down => 1, + KeyCode::PageUp => -8, + KeyCode::PageDown => 8, + _ => unreachable!(), + }; + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = help + .scroll + .saturating_add_signed(delta) + .min(self.hits.help_max_scroll); + } + } + KeyCode::Backspace => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.query.pop(); + help.scroll = 0; + } + } + KeyCode::Char('u') if modifiers == KeyModifiers::CONTROL => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.query.clear(); + help.scroll = 0; + } + } + _ => { + if let Some(character) = text_character { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.query.push(character); + help.scroll = 0; + } + } + } + } + outcome.repaint = true; + return; + } + + match code { + KeyCode::Esc | KeyCode::Enter => self.overlay = None, + KeyCode::Home => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = 0; + } + } + KeyCode::End => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = self.hits.help_max_scroll; + } + } + KeyCode::Up + | KeyCode::Char('k') + | KeyCode::Down + | KeyCode::Char('j') + | KeyCode::PageUp + | KeyCode::PageDown => { + let delta = match code { + KeyCode::Up | KeyCode::Char('k') => -1, + KeyCode::Down | KeyCode::Char('j') => 1, + KeyCode::PageUp => -8, + KeyCode::PageDown => 8, + _ => unreachable!(), + }; + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = help + .scroll + .saturating_add_signed(delta) + .min(self.hits.help_max_scroll); + } + } + _ if text_character == Some('/') => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.search_focused = true; + help.scroll = 0; + } + } + _ if text_character == Some('?') => self.overlay = None, + _ => {} + } + outcome.repaint = true; + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::ConfirmClose(_))) { + if key.code == KeyCode::Enter { + let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take() else { + return; + }; + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceClose( + crate::api::schema::WorkspaceCloseParams { + workspace_id: confirm.workspace_id, + close_group: true, + }, + ), + outcome, + ); + outcome.repaint = true; + } else if key.code == KeyCode::Esc { + self.overlay = None; + self.mode = ClientShellMode::Navigate; + self.navigate_workspace_id = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.clone()); + outcome.repaint = true; + } + return; + } + + let Some(ClientShellOverlay::Rename(rename)) = self.overlay.as_mut() else { + return; + }; + if key.code == KeyCode::Enter { + self.save_rename_overlay(outcome); + return; + } + if key.code == KeyCode::Esc { + self.overlay = None; + outcome.repaint = true; + return; + } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + rename.input.clear(); + rename.replace_on_type = false; + outcome.repaint = true; + return; + } + if (key.code == KeyCode::Char('u') && key.modifiers.contains(KeyModifiers::CONTROL)) + || (key.code == KeyCode::Backspace && key.modifiers.contains(KeyModifiers::SUPER)) + { + rename.input.clear(); + rename.replace_on_type = false; + outcome.repaint = true; + return; + } + if key.code == KeyCode::Backspace + && (key.modifiers.contains(KeyModifiers::CONTROL) + || key.modifiers.contains(KeyModifiers::ALT)) + || matches!(key.code, KeyCode::Char('h' | 'w')) + && key.modifiers.contains(KeyModifiers::CONTROL) + { + delete_overlay_word(rename); + outcome.repaint = true; + return; + } + if key.code == KeyCode::Backspace { + if rename.replace_on_type { + rename.input.clear(); + rename.replace_on_type = false; + } else { + rename.input.pop(); + } + outcome.repaint = true; + return; + } + if let KeyCode::Char(character) = key.code { + if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { + if rename.replace_on_type { + rename.input.clear(); + rename.replace_on_type = false; + } + if let Some(text) = key.generated_text.as_deref() { + rename.input.push_str(text); + } else { + rename.input.push(character); + } + outcome.repaint = true; + } + } + } + + pub(super) fn save_rename_overlay(&mut self, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::Rename(rename)) = self.overlay.take() else { + return; + }; + let trimmed = rename.input.trim(); + let method = match rename.target { + ClientRenameTarget::NewWorkspace { + source_workspace_id, + cwd, + suggested_name, + } => Some(crate::api::schema::Method::WorkspaceCreate( + crate::api::schema::WorkspaceCreateParams { + source_workspace_id, + cwd, + focus: true, + label: (!trimmed.is_empty() && trimmed != suggested_name) + .then(|| trimmed.to_owned()), + env: Default::default(), + }, + )), + ClientRenameTarget::Workspace { workspace_id } => (!trimmed.is_empty()).then(|| { + crate::api::schema::Method::WorkspaceRename( + crate::api::schema::WorkspaceRenameParams { + workspace_id, + label: trimmed.to_owned(), + }, + ) + }), + ClientRenameTarget::NewTab { + workspace_id, + default_name, + } => Some(crate::api::schema::Method::TabCreate( + crate::api::schema::TabCreateParams { + workspace_id: Some(workspace_id), + cwd: None, + focus: true, + label: (!trimmed.is_empty() && trimmed != default_name) + .then(|| trimmed.to_owned()), + env: Default::default(), + }, + )), + ClientRenameTarget::Tab { + tab_id, + auto_name, + original_name, + } => (!(trimmed.is_empty() || auto_name && trimmed == original_name)).then(|| { + crate::api::schema::Method::TabRename(crate::api::schema::TabRenameParams { + tab_id, + label: trimmed.to_owned(), + }) + }), + ClientRenameTarget::Pane { pane_id } => Some(crate::api::schema::Method::PaneRename( + crate::api::schema::PaneRenameParams { + pane_id, + label: Some(trimmed.to_owned()), + }, + )), + }; + if let Some(method) = method { + self.push_endpoint_method(method, outcome); + } + outcome.repaint = true; + } + + pub(super) fn open_confirm_close_overlay(&mut self, workspace_id: String) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(workspace) = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + else { + return; + }; + let group_key = workspace + .worktree + .as_ref() + .filter(|worktree| !worktree.is_linked_worktree) + .map(|worktree| worktree.key.as_str()); + let group = group_key + .map(|key| { + snapshot + .workspaces + .iter() + .filter(|member| { + member + .worktree + .as_ref() + .is_some_and(|worktree| worktree.key == key) + }) + .collect::<Vec<_>>() + }) + .unwrap_or_else(|| vec![workspace]); + let closes_group = group.len() > 1; + let pane_count = group + .iter() + .map(|member| { + snapshot + .panes + .iter() + .filter(|pane| pane.workspace_id == member.workspace_id) + .count() + }) + .sum::<usize>(); + let panes = if pane_count == 1 { + "1 pane".to_owned() + } else { + format!("{pane_count} panes") + }; + let scope = if closes_group { + format!("{} workspaces, {panes}", group.len()) + } else { + panes + }; + self.overlay = Some(ClientShellOverlay::ConfirmClose( + ClientConfirmCloseOverlay { + workspace_id, + title: if closes_group { + "Close worktree group?".to_owned() + } else { + "Close workspace?".to_owned() + }, + detail: format!("{} — {scope}", workspace.label), + }, + )); + } +} diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs new file mode 100644 index 00000000..30ac78b9 --- /dev/null +++ b/src/client/shell/overlays.rs @@ -0,0 +1,1169 @@ +use super::*; + +mod settings_overlay; +mod worktree_overlays; + +#[derive(Default)] +pub(crate) struct OverlayRender { + pub(crate) primary: Rect, + pub(crate) clear: Rect, + pub(crate) cancel: Rect, + pub(crate) navigator_popup: Rect, + pub(crate) navigator_search: Rect, + pub(crate) navigator_rows: Vec<(Rect, usize)>, + pub(crate) worktree_search: Rect, + pub(crate) worktree_rows: Vec<(Rect, usize)>, + pub(crate) help_popup: Rect, + pub(crate) help_scrollbar: Rect, + pub(crate) help_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(crate) help_max_scroll: usize, + pub(crate) settings_popup: Rect, + pub(crate) settings_tabs: Vec<(Rect, ClientSettingsSection)>, + pub(crate) settings_choices: Vec<(Rect, usize)>, + pub(crate) product_announcement_scrollbar: Rect, + pub(crate) product_announcement_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(crate) product_announcement_max_scroll: usize, + pub(crate) release_notes_scrollbar: Rect, + pub(crate) release_notes_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(crate) release_notes_max_scroll: usize, + pub(crate) cursor: Option<crate::protocol::CursorState>, +} + +pub(crate) fn render_client_overlay( + b: &mut Buffer, + o: &ClientShellOverlay, + s: &ClientShellSnapshot, + k: &LiveKeybindConfig, + p: &Palette, +) -> Option<OverlayRender> { + if !matches!( + o, + ClientShellOverlay::Navigator(_) + | ClientShellOverlay::ContextMenu(_) + | ClientShellOverlay::GlobalMenu(_) + ) { + for y in b.area.y..b.area.bottom() { + for x in b.area.x..b.area.right() { + let c = &mut b[(x, y)]; + c.set_style(c.style().add_modifier(Modifier::DIM)); + } + } + } + match o { + ClientShellOverlay::Onboarding => render_onboarding_overlay(b, p), + ClientShellOverlay::ProductAnnouncement(v) => render_product_announcement_overlay(b, v, p), + ClientShellOverlay::ReleaseNotes(v) => { + render_release_notes_overlay(b, v, &s.update_install_command, p) + } + ClientShellOverlay::Rename(v) => render_rename_overlay(b, v, p), + ClientShellOverlay::ConfirmClose(v) => render_confirm_close_overlay(b, v, p), + ClientShellOverlay::Help(v) => render_help_overlay(b, v, k, p), + ClientShellOverlay::Navigator(v) => render_navigator_overlay(b, v, s, p), + ClientShellOverlay::Settings(v) => { + settings_overlay::render_settings_overlay(b, v, s.integration_updates_available, p) + } + ClientShellOverlay::WorktreeCreate(v) => { + worktree_overlays::render_worktree_create_overlay(b, v, p) + } + ClientShellOverlay::WorktreeOpen(v) => { + worktree_overlays::render_worktree_open_overlay(b, v, p) + } + ClientShellOverlay::WorktreeRemove(v) => { + worktree_overlays::render_worktree_remove_overlay(b, v, p) + } + ClientShellOverlay::ContextMenu(_) | ClientShellOverlay::GlobalMenu(_) => None, + } +} + +pub(crate) fn render_global_menu( + buffer: &mut Buffer, + launcher: Rect, + menu: &ClientGlobalMenuOverlay, + snapshot: &ClientShellSnapshot, + palette: &Palette, +) -> Option<Vec<(Rect, usize)>> { + let items = super::super::global_menu::global_menu_items(snapshot); + let screen = buffer.area; + let width = items + .iter() + .map(|(label, action)| { + display_width(label) + + u16::from(super::super::global_menu::global_menu_item_has_badge( + snapshot, *action, + )) * 2 + }) + .max() + .unwrap_or(8) + .saturating_add(4) + .min(screen.width.max(1)); + let height = (items.len() as u16) + .saturating_add(2) + .min(screen.height.max(1)); + let x = launcher + .right() + .saturating_sub(width) + .min(screen.right().saturating_sub(width)); + let y = launcher.y.saturating_sub(height).max(screen.y); + let inner = panel( + buffer, + Rect::new(x, y, width, height), + palette.accent, + palette.panel_bg, + )?; + let mut rows = Vec::new(); + for (index, (label, action)) in items.iter().enumerate() { + let row_y = inner.y.saturating_add(index as u16); + if row_y >= inner.bottom() { + break; + } + let row = Rect::new(inner.x, row_y, inner.width, 1); + let highlighted = index == menu.highlighted; + let style = if highlighted { + Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text).bg(palette.panel_bg) + }; + buffer.set_style(row, style); + let has_badge = super::super::global_menu::global_menu_item_has_badge(snapshot, *action); + if has_badge { + let badge_style = if highlighted { + style + } else { + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD) + }; + put_text(buffer, row.x, row.y, row.width.min(2), " ●", badge_style); + put_text( + buffer, + row.x.saturating_add(2), + row.y, + row.width.saturating_sub(2), + &format!(" {label}"), + style, + ); + } else { + put_text(buffer, row.x, row.y, row.width, &format!(" {label}"), style); + } + rows.push((row, index)); + } + Some(rows) +} + +pub(crate) fn render_context_menu( + buffer: &mut Buffer, + menu: &ClientContextMenuOverlay, + palette: &Palette, +) -> Option<Vec<(Rect, usize)>> { + let items = menu.items(); + let screen = buffer.area; + let max_item_width = items + .iter() + .map(|item| display_width(item.label)) + .max() + .unwrap_or(0); + let width = max_item_width + .saturating_add(4) + .max(14) + .min(screen.width.max(1)); + let height = (items.len() as u16) + .saturating_add(2) + .min(screen.height.max(1)); + let x = menu + .x + .min(screen.x.saturating_add(screen.width.saturating_sub(width))); + let y = menu.y.min( + screen + .y + .saturating_add(screen.height.saturating_sub(height)), + ); + let rect = Rect::new(x, y, width, height); + let inner = panel(buffer, rect, palette.accent, palette.panel_bg)?; + let mut rows = Vec::new(); + for (index, item) in items.iter().enumerate() { + let row_y = inner.y.saturating_add(index as u16); + if row_y >= inner.bottom() { + break; + } + let row = Rect::new(inner.x, row_y, inner.width, 1); + let highlighted = index == menu.highlighted; + let style = if highlighted { + Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text).bg(palette.panel_bg) + }; + buffer.set_style(row, style); + put_text(buffer, row.x, row.y, row.width, item.label, style); + rows.push((row, index)); + } + Some(rows) +} + +fn panel( + b: &mut Buffer, + a: Rect, + c: ratatui::style::Color, + bg: ratatui::style::Color, +) -> Option<Rect> { + if a.width < 2 || a.height < 2 { + return None; + } + let background = Style::default().bg(bg).remove_modifier(Modifier::DIM); + let border = Style::default().fg(c).bg(bg).remove_modifier(Modifier::DIM); + for y in a.y..a.bottom() { + for x in a.x..a.right() { + b[(x, y)].set_symbol(" ").set_style(background); + } + } + for x in a.x..a.right() { + b[(x, a.y)] + .set_symbol(if x == a.x { + "┌" + } else if x + 1 == a.right() { + "┐" + } else { + "─" + }) + .set_style(border); + let y = a.bottom() - 1; + b[(x, y)] + .set_symbol(if x == a.x { + "└" + } else if x + 1 == a.right() { + "┘" + } else { + "─" + }) + .set_style(border); + } + for y in a.y + 1..a.bottom() - 1 { + b[(a.x, y)].set_symbol("│").set_style(border); + b[(a.right() - 1, y)].set_symbol("│").set_style(border); + } + Some(Rect::new(a.x + 1, a.y + 1, a.width - 2, a.height - 2)) +} +fn popup(a: Rect, w: u16, h: u16) -> Option<Rect> { + let w = w.min(a.width.saturating_sub(4)); + let h = h.min(a.height.saturating_sub(2)); + if w < 4 || h < 4 { + return None; + } + Some(Rect::new( + a.x + (a.width - w) / 2, + a.y + (a.height - h) / 2, + w, + h, + )) +} +fn button(b: &mut Buffer, r: Rect, t: &str, s: Style) { + b.set_style(r, s); + let w = display_width(t).min(r.width); + put_text(b, r.x + (r.width - w) / 2, r.y, w, t, s) +} +fn row(i: Rect, ws: &[u16], gap: u16, off: u16) -> Vec<Rect> { + let total = ws.iter().sum::<u16>() + gap * (ws.len().saturating_sub(1) as u16); + let mut x = i.x + i.width.saturating_sub(total) / 2; + ws.iter() + .map(|w| { + let r = Rect::new( + x, + i.y + off.min(i.height.saturating_sub(1)), + (*w).min(i.width.saturating_sub(x - i.x)), + 1, + ); + x += *w + gap; + r + }) + .collect() +} +fn contrast(p: &Palette) -> ratatui::style::Color { + match p.panel_bg { + ratatui::style::Color::Reset => p.surface_dim, + c => c, + } +} +fn render_release_notes_overlay( + b: &mut Buffer, + notes: &crate::app::state::ReleaseNotesState, + install_command: &str, + p: &Palette, +) -> Option<OverlayRender> { + let outer = popup( + b.area, + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + )?; + let inner = panel(b, outer, p.accent, p.panel_bg)?; + if inner.height < 8 || inner.width < 20 { + return Some(OverlayRender::default()); + } + + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + let title_area = Rect::new( + stack.header.x.saturating_add(1), + stack.header.y, + stack.header.width.saturating_sub(2), + 1, + ); + let subtitle_area = Rect::new( + stack.header.x.saturating_add(1), + stack.header.y.saturating_add(1), + stack.header.width.saturating_sub(2), + 1, + ); + let base = Style::default() + .bg(p.panel_bg) + .remove_modifier(Modifier::DIM); + put_text( + b, + title_area.x, + title_area.y, + title_area.width, + &format!("v{}", notes.version), + base.fg(p.text).add_modifier(Modifier::BOLD), + ); + put_text( + b, + subtitle_area.x, + subtitle_area.y, + subtitle_area.width, + if notes.preview { + "update ready" + } else { + "what's new in this release" + }, + base.fg(p.overlay1), + ); + let close = crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )); + button( + b, + close, + " esc close ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + .remove_modifier(Modifier::DIM), + ); + + let body = stack.content; + let lines = crate::ui::release_notes_display_lines(notes, install_command, p); + let metrics = crate::ui::release_notes_scroll_metrics(notes, install_command, body, p); + let max_scroll = metrics.max_offset_from_bottom; + let scroll = usize::from(notes.scroll).min(max_scroll); + let track = crate::ui::release_notes_scrollbar_rect(body, metrics); + let text_area = track + .map(|_| Rect::new(body.x, body.y, body.width.saturating_sub(1), body.height)) + .unwrap_or(body); + let paragraph = ratatui::widgets::Paragraph::new( + lines.into_iter().map(|(_, line)| line).collect::<Vec<_>>(), + ) + .wrap(ratatui::widgets::Wrap { trim: false }) + .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)); + ratatui::widgets::Widget::render(paragraph, text_area, b); + if let Some(track) = track { + crate::ui::render_scrollbar_buffer(b, metrics, track, p.overlay0, p.overlay1, "▐"); + } + + if let Some(footer_area) = stack.footer { + let footer_line = ratatui::text::Line::from(vec![ + ratatui::text::Span::styled(" scroll ", base.fg(p.overlay0)), + ratatui::text::Span::styled("wheel ↑↓", base.fg(p.text)), + ratatui::text::Span::styled(" · ", base.fg(p.overlay0)), + ratatui::text::Span::styled("close", base.fg(p.overlay0)), + ratatui::text::Span::styled(" esc / enter ", base.fg(p.text)), + ]); + ratatui::widgets::Widget::render( + ratatui::widgets::Paragraph::new(footer_line), + footer_area, + b, + ); + } + + Some(OverlayRender { + primary: close, + release_notes_scrollbar: track.unwrap_or_default(), + release_notes_scroll_metrics: Some(metrics), + release_notes_max_scroll: max_scroll, + ..OverlayRender::default() + }) +} + +fn render_product_announcement_overlay( + b: &mut Buffer, + announcement: &crate::app::state::ProductAnnouncementState, + p: &Palette, +) -> Option<OverlayRender> { + let outer = popup( + b.area, + crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0, + crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1, + )?; + let inner = panel(b, outer, p.accent, p.panel_bg)?; + if inner.height < 8 || inner.width < 20 { + return Some(OverlayRender::default()); + } + + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + let title_area = Rect::new( + stack.header.x.saturating_add(1), + stack.header.y, + stack.header.width.saturating_sub(2), + 1, + ); + let subtitle_area = Rect::new( + stack.header.x.saturating_add(1), + stack.header.y.saturating_add(1), + stack.header.width.saturating_sub(2), + 1, + ); + let base = Style::default() + .bg(p.panel_bg) + .remove_modifier(Modifier::DIM); + put_text( + b, + title_area.x, + title_area.y, + title_area.width, + &announcement.title, + base.fg(p.text).add_modifier(Modifier::BOLD), + ); + let subtitle = if announcement.preview { + "product announcement preview" + } else { + "product announcement" + }; + put_text( + b, + subtitle_area.x, + subtitle_area.y, + subtitle_area.width, + &format!("{subtitle} · v{}", announcement.version), + base.fg(p.overlay1), + ); + let close = crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )); + button( + b, + close, + " esc close ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + .remove_modifier(Modifier::DIM), + ); + + let body = stack.content; + let lines = crate::ui::product_announcement_display_lines(announcement, p); + let metrics = crate::ui::product_announcement_scroll_metrics(announcement, body, p); + let max_scroll = metrics.max_offset_from_bottom; + let scroll = usize::from(announcement.scroll).min(max_scroll); + let track = crate::ui::release_notes_scrollbar_rect(body, metrics); + let text_area = track + .map(|_| Rect::new(body.x, body.y, body.width.saturating_sub(1), body.height)) + .unwrap_or(body); + let paragraph = ratatui::widgets::Paragraph::new( + lines.into_iter().map(|(_, line)| line).collect::<Vec<_>>(), + ) + .wrap(ratatui::widgets::Wrap { trim: false }) + .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)); + ratatui::widgets::Widget::render(paragraph, text_area, b); + if let Some(track) = track { + crate::ui::render_scrollbar_buffer(b, metrics, track, p.overlay0, p.overlay1, "▐"); + } + + if let Some(footer_area) = stack.footer { + let footer_line = ratatui::text::Line::from(vec![ + ratatui::text::Span::styled(" scroll ", base.fg(p.overlay0)), + ratatui::text::Span::styled("wheel ↑↓", base.fg(p.text)), + ratatui::text::Span::styled(" · ", base.fg(p.overlay0)), + ratatui::text::Span::styled("close", base.fg(p.overlay0)), + ratatui::text::Span::styled(" esc / enter ", base.fg(p.text)), + ]); + ratatui::widgets::Widget::render( + ratatui::widgets::Paragraph::new(footer_line), + footer_area, + b, + ); + } + + Some(OverlayRender { + primary: close, + product_announcement_scrollbar: track.unwrap_or_default(), + product_announcement_scroll_metrics: Some(metrics), + product_announcement_max_scroll: max_scroll, + ..OverlayRender::default() + }) +} + +fn render_onboarding_overlay(b: &mut Buffer, p: &Palette) -> Option<OverlayRender> { + let outer = popup(b.area, 64, 16)?; + let inner = panel(b, outer, p.accent, p.panel_bg)?; + if inner.height < 11 { + return Some(OverlayRender::default()); + } + let stack = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1); + let base = Style::default() + .bg(p.panel_bg) + .remove_modifier(Modifier::DIM); + let title = base.fg(p.text).add_modifier(Modifier::BOLD); + let muted = base.fg(p.overlay0); + let text = base.fg(p.overlay1); + let accent = base.fg(p.accent).add_modifier(Modifier::BOLD); + + put_text( + b, + stack.header.x, + stack.header.y, + stack.header.width, + crate::ui::ONBOARDING_TITLE, + title, + ); + put_text( + b, + stack.header.x, + stack.header.y.saturating_add(1), + stack.header.width, + crate::ui::ONBOARDING_SUBTITLE, + muted, + ); + + let content = stack.content; + for (offset, line) in crate::ui::ONBOARDING_DESCRIPTION.iter().enumerate() { + put_text( + b, + content.x, + content.y.saturating_add(offset as u16), + content.width, + line, + text, + ); + } + + let key_y = content.y.saturating_add(4); + let mut key_x = content.x; + for (value, style) in [ + (" ", base), + (crate::ui::ONBOARDING_PREFIX_LABEL, accent), + (crate::ui::ONBOARDING_PREFIX_SUFFIX, text), + (crate::ui::ONBOARDING_HELP_LABEL, accent), + (crate::ui::ONBOARDING_HELP_SUFFIX, text), + ] { + let width = display_width(value); + put_text( + b, + key_x, + key_y, + content.right().saturating_sub(key_x), + value, + style, + ); + key_x = key_x.saturating_add(width); + } + put_text( + b, + content.x, + content.y.saturating_add(5), + content.width, + crate::ui::ONBOARDING_NEXT, + text, + ); + + let primary = crate::ui::onboarding_welcome_continue_rect(stack.actions.unwrap_or_default()); + button( + b, + primary, + " ↵ continue ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + .remove_modifier(Modifier::DIM), + ); + Some(OverlayRender { + primary, + ..OverlayRender::default() + }) +} + +fn render_rename_overlay( + b: &mut Buffer, + v: &ClientRenameOverlay, + p: &Palette, +) -> Option<OverlayRender> { + let q = popup(b.area, 56, 7)?; + let i = panel(b, q, p.accent, p.panel_bg)?; + put_text( + b, + i.x, + i.y, + i.width, + v.title, + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + let input = Rect::new(i.x, i.y + 2, i.width, 1); + b.set_style(input, Style::default().fg(p.text).bg(p.surface0)); + put_text( + b, + input.x, + input.y, + input.width.saturating_sub(1), + &format!(" {}", v.input), + Style::default().fg(p.text).bg(p.surface0), + ); + let rs = row(i, &[8, 10, 12], 2, 3); + let [save, clear, cancel] = rs.as_slice() else { + return None; + }; + button( + b, + *save, + " ↵ save ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + let n = Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD); + button(b, *clear, " ^c clear ", n); + button(b, *cancel, " esc cancel ", n); + Some(OverlayRender { + primary: *save, + clear: *clear, + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: Some(crate::protocol::CursorState { + x: (input.x + 1 + display_width(&v.input)).min(input.right() - 1), + y: input.y, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} + +pub(crate) fn client_navigator_rows( + s: &ClientShellSnapshot, + n: &ClientNavigatorOverlay, +) -> Vec<ClientNavigatorRow> { + let q = n.query.trim().to_lowercase(); + let filter = |st| match n.filter { + Some(ClientNavigatorFilter::Blocked) => st == crate::api::schema::AgentStatus::Blocked, + Some(ClientNavigatorFilter::Working) => st == crate::api::schema::AgentStatus::Working, + Some(ClientNavigatorFilter::Idle) => st == crate::api::schema::AgentStatus::Idle, + Some(ClientNavigatorFilter::Done) => st == crate::api::schema::AgentStatus::Done, + None => true, + }; + let text = |v: &str| q.is_empty() || v.to_lowercase().contains(&q); + let filtering = n.filter.is_some() || !q.is_empty(); + let mut out = Vec::new(); + for w in &s.workspaces { + let mut children = Vec::new(); + for t in s.tabs.iter().filter(|t| t.workspace_id == w.workspace_id) { + let mut panes = Vec::new(); + for (ix, p) in s.panes.iter().filter(|p| p.tab_id == t.tab_id).enumerate() { + let a = s.agents.iter().find(|a| a.pane_id == p.pane_id); + let st = a + .map(|a| a.agent_status) + .unwrap_or(crate::api::schema::AgentStatus::Unknown); + let label = p + .label + .clone() + .or_else(|| a.and_then(|a| a.name.clone())) + .or_else(|| a.and_then(|a| a.display_agent.clone())) + .or_else(|| a.and_then(|a| a.title.clone())) + .unwrap_or_else(|| format!("pane {}", ix + 1)); + let meta = p + .foreground_cwd + .clone() + .or_else(|| p.cwd.clone()) + .unwrap_or_default(); + if !filtering || filter(st) && (text(&label) || text(&meta)) { + panes.push(ClientNavigatorRow { + depth: 2, + label, + meta, + status: st, + current: s.focused_pane_id.as_deref() == Some(&p.pane_id), + target: ClientNavigatorTarget::Pane(p.pane_id.clone()), + }) + } + } + if !filtering || filter(t.agent_status) && text(&t.label) || !panes.is_empty() { + children.push(ClientNavigatorRow { + depth: 1, + label: t.label.clone(), + meta: format!( + "{} panes", + s.panes.iter().filter(|p| p.tab_id == t.tab_id).count() + ), + status: t.agent_status, + current: s.focused_tab_id.as_deref() == Some(&t.tab_id), + target: ClientNavigatorTarget::Tab(t.tab_id.clone()), + }); + children.extend(panes) + } + } + let wm = + filter(w.agent_status) && (text(&w.label) || w.branch.as_deref().is_some_and(&text)); + if !filtering || wm || !children.is_empty() { + out.push(ClientNavigatorRow { + depth: 0, + label: w.label.clone(), + meta: w.branch.clone().unwrap_or_default(), + status: w.agent_status, + current: s.focused_workspace_id.as_deref() == Some(&w.workspace_id), + target: ClientNavigatorTarget::Workspace(w.workspace_id.clone()), + }); + if n.expanded_workspaces.contains(&w.workspace_id) || filtering { + out.extend(children) + } + } + } + out +} + +fn render_navigator_overlay( + b: &mut Buffer, + n: &ClientNavigatorOverlay, + s: &ClientShellSnapshot, + p: &Palette, +) -> Option<OverlayRender> { + let a = b.area; + let mx = (a.width / 16).max(2); + let my = (a.height / 10).max(1); + let q = Rect::new( + a.x + mx, + a.y + my, + a.width.saturating_sub(mx * 2).max(4), + a.height.saturating_sub(my * 2).max(4), + ); + let i = panel(b, q, p.accent, p.panel_bg)?; + let rows = client_navigator_rows(s, n); + let search = if n.search_focused { + format!(" / {}", n.query) + } else if let Some(f) = n.filter { + format!( + " / {}", + match f { + ClientNavigatorFilter::Blocked => "blocked", + ClientNavigatorFilter::Working => "working", + ClientNavigatorFilter::Idle => "idle", + ClientNavigatorFilter::Done => "done", + } + ) + } else if n.query.is_empty() { + " / search panes".to_owned() + } else { + format!(" / {}", n.query) + }; + put_text( + b, + i.x, + i.y, + i.width, + &search, + Style::default() + .fg(if n.search_focused { p.text } else { p.overlay0 }) + .bg(p.panel_bg), + ); + put_right_text( + b, + i, + i.y, + &format!("{} panes", s.panes.len()), + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + put_text( + b, + i.x, + i.y + 1, + i.width, + &"─".repeat(i.width as usize), + Style::default().fg(p.surface1).bg(p.panel_bg), + ); + let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(4)); + let max = rows.len().saturating_sub(body.height as usize); + let scroll = n + .scroll + .max( + n.selected + .saturating_sub(body.height.saturating_sub(1) as usize), + ) + .min(n.selected) + .min(max); + let mut row_hits = Vec::new(); + for (vis, (ix, r)) in rows + .iter() + .enumerate() + .skip(scroll) + .take(body.height as usize) + .enumerate() + { + let rect = Rect::new(body.x, body.y + vis as u16, body.width, 1); + row_hits.push((rect, ix)); + let st = if ix == n.selected { + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(if r.current { p.text } else { p.subtext0 }) + .bg(p.panel_bg) + }; + b.set_style(rect, st); + let tree = match r.depth { + 0 => match &r.target { + ClientNavigatorTarget::Workspace(id) if n.expanded_workspaces.contains(id) => "▾", + _ => "▸", + }, + 1 => "└──", + _ => " └──", + }; + let label = format!( + " {} {} {} {}", + if r.current { "◆" } else { " " }, + tree, + status_dot(r.status), + r.label + ); + put_text(b, rect.x, rect.y, rect.width, &label, st); + if !r.meta.is_empty() { + let label_width = display_width(&label).min(rect.width); + let meta = Rect::new( + rect.x.saturating_add(label_width).saturating_add(1), + rect.y, + rect.width.saturating_sub(label_width.saturating_add(1)), + 1, + ); + put_right_text(b, meta, rect.y, &r.meta, st) + } + } + let dy = i.bottom() - 2; + if let Some(r) = rows.get(n.selected) { + put_text( + b, + i.x, + dy, + i.width, + &format!(" {} · {}", r.label, r.meta), + Style::default().fg(p.overlay0).bg(p.panel_bg), + ) + } + put_text( + b, + i.x, + i.bottom() - 1, + i.width, + if n.search_focused { + " search type · move ↑↓/ctrl+n/p · open enter · back esc" + } else { + " move j/k · expand space · filter a/b/w/i/d · search / · open enter · close esc" + }, + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + Some(OverlayRender { + primary: Rect::default(), + clear: Rect::default(), + cancel: Rect::default(), + navigator_popup: q, + navigator_search: Rect::new(i.x, i.y, i.width, 1), + navigator_rows: row_hits, + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: n.search_focused.then(|| crate::protocol::CursorState { + x: i.x + 3 + display_width(&n.query), + y: i.y, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} + +fn help_lines( + keybinds: &LiveKeybindConfig, + query: &str, + palette: &Palette, +) -> Vec<(usize, ratatui::text::Line<'static>)> { + use ratatui::text::{Line, Span}; + + let groups = crate::input::filter_keybind_help_groups( + crate::input::keybind_help_groups(&keybinds.keybinds, keybinds.prefix), + query, + ); + let key_width = groups + .iter() + .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count())) + .max() + .unwrap_or(8); + if groups.is_empty() { + let message = " no matching keybinds"; + return vec![( + message.chars().count(), + Line::from(Span::styled( + message, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + )), + )]; + } + + let mut lines = Vec::new(); + for (group, entries) in groups { + lines.push(( + group.len() + 1, + Line::from(Span::styled( + format!(" {group}"), + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + )), + )); + for (key, label) in entries { + let padded_key = format!(" {key:<key_width$} "); + let width = padded_key.chars().count() + label.chars().count(); + lines.push(( + width, + Line::from(vec![ + Span::styled( + padded_key, + Style::default() + .fg(palette.mauve) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + label.into_owned(), + Style::default().fg(palette.text).bg(palette.panel_bg), + ), + ]), + )); + } + lines.push((0, Line::raw(""))); + } + lines +} + +fn render_help_overlay( + b: &mut Buffer, + h: &ClientHelpOverlay, + k: &LiveKeybindConfig, + p: &Palette, +) -> Option<OverlayRender> { + use ratatui::widgets::{Paragraph, Widget, Wrap}; + + let q = popup(b.area, 76, 22)?; + let i = panel(b, q, p.accent, p.panel_bg)?; + if i.width < 20 || i.height < 6 { + return None; + } + put_text( + b, + i.x, + i.y, + i.width, + "keybinds", + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + let close = Rect::new(i.right() - 13, i.y, 13, 1); + button( + b, + close, + if h.search_focused { + " esc back " + } else { + " esc close " + }, + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + let sy = i.y + 1; + put_text( + b, + i.x, + sy, + i.width, + &if h.search_focused { + format!(" / {}", h.query) + } else { + " / press / to filter by command or shortcut".to_owned() + }, + Style::default() + .fg(if h.search_focused { p.text } else { p.overlay0 }) + .bg(p.panel_bg), + ); + + let body = Rect::new(i.x, i.y + 3, i.width, i.height.saturating_sub(5)); + let lines = help_lines(k, &h.query, p); + let viewport_rows = usize::from(body.height.max(1)); + let wrapped_rows = |width: u16| { + let width = usize::from(width.max(1)); + lines + .iter() + .map(|(line_width, _)| line_width.max(&1).div_ceil(width)) + .sum::<usize>() + }; + let needs_scrollbar = wrapped_rows(body.width) > viewport_rows; + let text_area = if needs_scrollbar { + Rect::new(body.x, body.y, body.width.saturating_sub(1), body.height) + } else { + body + }; + let total_rows = wrapped_rows(text_area.width); + let max_scroll = total_rows.saturating_sub(viewport_rows); + let scroll = h.scroll.min(max_scroll); + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: max_scroll.saturating_sub(scroll), + max_offset_from_bottom: max_scroll, + viewport_rows, + }; + let scrollbar = needs_scrollbar.then_some(Rect::new( + body.right().saturating_sub(1), + body.y, + 1, + body.height, + )); + Widget::render( + Paragraph::new(lines.into_iter().map(|(_, line)| line).collect::<Vec<_>>()) + .wrap(Wrap { trim: false }) + .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)), + text_area, + b, + ); + if let Some(track) = scrollbar { + if let Some(thumb) = crate::ui::scrollbar_thumb(metrics, track) { + for y in track.y..track.bottom() { + b[(track.x, y)] + .set_symbol("▐") + .set_style(Style::default().fg(p.overlay0).bg(p.panel_bg)); + } + for y in thumb.top..thumb.top.saturating_add(thumb.len) { + b[(track.x, y)] + .set_symbol("▐") + .set_style(Style::default().fg(p.overlay1).bg(p.panel_bg)); + } + } + } + + put_text( + b, + i.x, + i.bottom() - 1, + i.width, + if h.search_focused { + " filter type/backspace · clear ctrl+u · scroll ↑↓/pgup/pgdn · back esc" + } else { + " search / · scroll j/k/↑↓/pgup/pgdn · close esc/enter" + }, + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + Some(OverlayRender { + cancel: close, + help_popup: q, + help_scrollbar: scrollbar.unwrap_or_default(), + help_scroll_metrics: Some(metrics), + help_max_scroll: max_scroll, + cursor: h.search_focused.then(|| crate::protocol::CursorState { + x: (i.x + 3 + display_width(&h.query)).min(i.right() - 1), + y: sy, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} +fn render_confirm_close_overlay( + b: &mut Buffer, + c: &ClientConfirmCloseOverlay, + p: &Palette, +) -> Option<OverlayRender> { + let q = popup(b.area, 64, 6)?; + let i = panel(b, q, p.red, p.panel_bg)?; + put_text( + b, + i.x, + i.y, + i.width, + &format!(" {}", c.title), + Style::default() + .fg(p.red) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + b, + i.x, + i.y + 1, + i.width, + &format!(" {}", c.detail), + Style::default().fg(p.text).bg(p.panel_bg), + ); + let rs = row(i, &[13, 12], 2, 3); + let [ok, cancel] = rs.as_slice() else { + return None; + }; + button( + b, + *ok, + " ↵ confirm ", + Style::default() + .fg(contrast(p)) + .bg(p.red) + .add_modifier(Modifier::BOLD), + ); + button( + b, + *cancel, + " esc cancel ", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ); + Some(OverlayRender { + primary: *ok, + clear: Rect::default(), + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: None, + ..OverlayRender::default() + }) +} diff --git a/src/client/shell/preferences.rs b/src/client/shell/preferences.rs new file mode 100644 index 00000000..dc6cc1ac --- /dev/null +++ b/src/client/shell/preferences.rs @@ -0,0 +1,132 @@ +use std::{ + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(1); + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ClientChromePreferences { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) sidebar_width: Option<u16>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) sidebar_section_split: Option<f32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) sidebar_collapsed: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) agent_panel_sort: Option<crate::config::AgentPanelSortConfig>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(super) collapsed_groups: Vec<String>, +} + +pub(super) fn path_for_local_endpoint(socket_path: &Path) -> PathBuf { + let mut hash = 0xcbf29ce484222325u64; + for byte in socket_path.to_string_lossy().as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + crate::config::state_dir() + .join("client-shell") + .join(format!("local-{hash:016x}.json")) +} + +pub(super) fn load(path: &Path) -> Option<ClientChromePreferences> { + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +pub(super) fn store(path: &Path, preferences: ClientChromePreferences) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("invalid client shell state path: {}", path.display()))?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create client shell state directory: {error}"))?; + let content = serde_json::to_vec_pretty(&preferences) + .map_err(|error| format!("failed to encode client shell state: {error}"))?; + let sequence = NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed); + let mut temp_name = path + .file_name() + .ok_or_else(|| format!("invalid client shell state path: {}", path.display()))? + .to_os_string(); + temp_name.push(format!(".tmp-{}-{sequence}", std::process::id())); + let temp_path = parent.join(temp_name); + std::fs::write(&temp_path, content) + .map_err(|error| format!("failed to write client shell state: {error}"))?; + crate::platform::replace_file(&temp_path, path).map_err(|error| { + let _ = std::fs::remove_file(&temp_path); + format!("failed to replace client shell state: {error}") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_paths_are_stable_and_distinct() { + let first = path_for_local_endpoint(Path::new("/run/herdr/one.sock")); + let again = path_for_local_endpoint(Path::new("/run/herdr/one.sock")); + let second = path_for_local_endpoint(Path::new("/run/herdr/two.sock")); + assert_eq!(first, again); + assert_ne!(first, second); + } + + #[test] + fn concurrent_stores_leave_complete_preferences() { + let path = std::env::temp_dir().join(format!( + "herdr-shell-concurrent-preferences-{}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let writers = (20..28) + .map(|width| { + let path = path.clone(); + std::thread::spawn(move || { + store( + &path, + ClientChromePreferences { + sidebar_width: Some(width), + ..ClientChromePreferences::default() + }, + ) + }) + }) + .collect::<Vec<_>>(); + for writer in writers { + writer.join().expect("preference writer").expect("store"); + } + assert!(load(&path) + .and_then(|saved| saved.sidebar_width) + .is_some_and(|width| (20..28).contains(&width))); + std::fs::remove_file(path).expect("remove preferences"); + } + + #[test] + fn repeated_store_replaces_existing_preferences() { + let path = std::env::temp_dir().join(format!( + "herdr-shell-preferences-{}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + store( + &path, + ClientChromePreferences { + sidebar_width: Some(24), + ..ClientChromePreferences::default() + }, + ) + .expect("first preference store"); + store( + &path, + ClientChromePreferences { + sidebar_width: Some(32), + ..ClientChromePreferences::default() + }, + ) + .expect("replacement preference store"); + assert_eq!(load(&path).and_then(|saved| saved.sidebar_width), Some(32)); + std::fs::remove_file(path).expect("remove preferences"); + } +} diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs new file mode 100644 index 00000000..0e7f3908 --- /dev/null +++ b/src/client/shell/render.rs @@ -0,0 +1,313 @@ +use super::*; + +#[path = "../shell/overlays.rs"] +mod overlays; +#[path = "../shell/sidebar.rs"] +mod sidebar; +#[path = "../shell/tabs.rs"] +mod tabs; + +pub(super) use super::agent_sidebar::{ordered_agent_pane_ids, render_agent_panel}; +pub(super) use overlays::{ + client_navigator_rows, render_client_overlay, render_context_menu, render_global_menu, +}; +pub(super) use sidebar::{render_collapsed_sidebar, render_sidebar, workspace_entries}; +pub(super) use tabs::{render_tab_bar, tab_bar_status_width}; +pub(super) fn render_mode_bar( + buffer: &mut Buffer, + pane_area: Rect, + mode: ClientShellMode, + copy_mode: Option<&ClientCopyModeState>, + endpoint_error: Option<&str>, + update_available: bool, + keybinds: &LiveKeybindConfig, + palette: &Palette, +) -> Option<Rect> { + if (mode == ClientShellMode::Terminal && endpoint_error.is_none()) || pane_area.is_empty() { + return None; + } + + let bar = Rect::new( + pane_area.x, + pane_area.y + pane_area.height.saturating_sub(1), + pane_area.width, + 1, + ); + let base = Style::default().fg(palette.overlay0).bg(palette.panel_bg); + for x in bar.x..bar.x + bar.width { + buffer[(x, bar.y)].set_symbol(" ").set_style(base); + } + + let key = Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD); + let mode_style = Style::default() + .fg(match palette.panel_bg { + ratatui::style::Color::Reset => palette.surface_dim, + color => color, + }) + .bg(if mode == ClientShellMode::Resize { + palette.mauve + } else { + palette.accent + }) + .add_modifier(Modifier::BOLD); + let prefix = crate::config::format_key_combo(keybinds.prefix); + let prefix_rhs = |bindings: &crate::config::ActionKeybinds| { + bindings + .prefix_rhs_label() + .unwrap_or_else(|| "unset".to_owned()) + }; + + let mut segments = Vec::<(String, Style)>::new(); + if let Some(error) = endpoint_error { + segments.extend([ + (" ERROR ".to_owned(), mode_style), + (format!(" {error}"), base), + ]); + } else { + match mode { + ClientShellMode::Prefix => { + segments.extend([ + (" PREFIX ".to_owned(), mode_style), + (" ".to_owned(), base), + ("esc".to_owned(), key), + (" cancel ".to_owned(), base), + (prefix, key), + (" send prefix ".to_owned(), base), + (prefix_rhs(&keybinds.keybinds.workspace_picker), key), + (" workspace nav ".to_owned(), base), + (prefix_rhs(&keybinds.keybinds.help), key), + (" keybinds".to_owned(), base), + ]); + } + ClientShellMode::Navigate => { + segments.extend([ + (" NAVIGATE ".to_owned(), mode_style), + (" esc back ".to_owned(), base), + ("↑/↓".to_owned(), key), + (" workspace ".to_owned(), base), + ("tab".to_owned(), key), + (" pane ".to_owned(), base), + (prefix_rhs(&keybinds.keybinds.help), key), + (" keybinds".to_owned(), base), + ]); + } + ClientShellMode::Resize => { + segments.extend([ + (" RESIZE ".to_owned(), mode_style), + (" ".to_owned(), base), + ("h/l".to_owned(), key), + (" width ".to_owned(), base), + ("j/k".to_owned(), key), + (" height ".to_owned(), base), + ("esc".to_owned(), key), + (" done".to_owned(), base), + ]); + } + ClientShellMode::Copy => { + let copy_mode = copy_mode?; + if let Some(prompt) = copy_mode.search_prompt.as_ref() { + let marker = match prompt.direction { + crate::api::schema::PaneCopySearchDirection::Forward => "/", + crate::api::schema::PaneCopySearchDirection::Backward => "?", + }; + segments.extend([ + (" COPY ".to_owned(), mode_style), + (" ".to_owned(), base), + (marker.to_owned(), key), + ( + prompt.query.clone(), + Style::default().fg(palette.text).bg(palette.panel_bg), + ), + ("█".to_owned(), key), + (" enter search esc cancel".to_owned(), base), + ]); + } else { + let select = if copy_mode.selection.is_some() { + "selecting" + } else { + "select" + }; + let match_status = copy_mode + .search_current_global + .map(|current| format!(" {}/{}", current + 1, copy_mode.search_total)) + .or_else(|| (!copy_mode.search_query.is_empty()).then(|| " 0/0".to_owned())) + .unwrap_or_default(); + let (exit_keys, exit_label) = + if copy_mode.search_query.is_empty() && copy_mode.selection.is_none() { + ("q/esc", " exit") + } else { + ("esc", " clear q exit") + }; + segments.extend([ + (" COPY ".to_owned(), mode_style), + (" ".to_owned(), base), + ("h/j/k/l w/b/e { }".to_owned(), key), + (" move ".to_owned(), base), + ("/ ?".to_owned(), key), + (" search ".to_owned(), base), + ("n/N".to_owned(), key), + (format!(" repeat{match_status} "), base), + ("v/space".to_owned(), key), + (format!(" {select} "), base), + ("y/enter".to_owned(), key), + (" copy ".to_owned(), base), + (exit_keys.to_owned(), key), + (exit_label.to_owned(), base), + ]); + } + } + ClientShellMode::Terminal => unreachable!(), + } + } + + let mut x = bar.x; + let end = bar.x + bar.width; + for (text, style) in segments { + if x >= end { + break; + } + let remaining = end - x; + buffer.set_stringn(x, bar.y, &text, usize::from(remaining), style); + x = x.saturating_add( + u16::try_from(UnicodeWidthStr::width(text.as_str())) + .unwrap_or(u16::MAX) + .min(remaining), + ); + } + if update_available && mode == ClientShellMode::Navigate { + let width = 13.min(bar.width); + let area = Rect::new(bar.right().saturating_sub(width), bar.y, width, 1); + buffer.set_style(area, Style::default().bg(palette.panel_bg)); + put_right_text( + buffer, + area, + area.y, + " update ready", + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + } + Some(bar) +} + +pub(super) struct ShellRenderState<'a> { + pub(super) collapsed_groups: &'a HashSet<String>, + pub(super) workspace_scroll: &'a mut usize, + pub(super) agent_scroll: &'a mut usize, + pub(super) tab_scroll: &'a mut usize, + pub(super) reveal_focused_tab: &'a mut bool, + pub(super) sidebar_collapsed: bool, + pub(super) sidebar_section_split: f32, + pub(super) tab_drag_insert_index: Option<usize>, + pub(super) selected_workspace_id: Option<&'a str>, + pub(super) dragged_workspace_id: Option<&'a str>, + pub(super) workspace_drop_indicator_row: Option<u16>, +} + +pub(super) fn render_shell( + buffer: &mut Buffer, + layout: ClientShellLayout, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + mut state: ShellRenderState<'_>, +) -> ShellHitMap { + let mut hits = ShellHitMap::default(); + if layout.mobile_header.height > 0 { + super::mobile::render_mobile_header( + buffer, + layout.mobile_header, + snapshot, + config, + &mut hits, + ); + } + if layout.sidebar.width > 0 { + if state.sidebar_collapsed { + render_collapsed_sidebar( + buffer, + layout.sidebar, + snapshot, + config, + state.selected_workspace_id, + &mut hits, + ); + } else { + render_sidebar( + buffer, + layout.sidebar, + snapshot, + config, + &mut state, + &mut hits, + ); + } + } + if layout.tab_bar.height > 0 { + render_tab_bar( + buffer, + layout.tab_bar, + snapshot, + config, + state.tab_scroll, + state.reveal_focused_tab, + state.tab_drag_insert_index, + &mut hits, + ); + } + if !config.mouse_capture { + hits.sidebar_divider = Rect::default(); + hits.sidebar_section_divider = Rect::default(); + hits.workspace_scrollbar = Rect::default(); + hits.agent_scrollbar = Rect::default(); + hits.agent_sort_toggle = Rect::default(); + hits.new_workspace = Rect::default(); + hits.workspaces.clear(); + hits.agents.clear(); + hits.tab_scroll_left = Rect::default(); + hits.tab_scroll_right = Rect::default(); + hits.new_tab = Rect::default(); + hits.pane_splits.clear(); + } + hits +} + +fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) { + let width = display_width(text).min(area.width); + put_text( + buffer, + area.right().saturating_sub(width), + y, + width, + text, + style, + ); +} + +pub(super) fn put_segment( + buffer: &mut Buffer, + x: u16, + y: u16, + right: u16, + text: &str, + style: Style, +) -> u16 { + let width = display_width(text).min(right.saturating_sub(x)); + put_text(buffer, x, y, width, text, style); + x.saturating_add(width) +} + +pub(super) fn put_text(buffer: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) { + if width == 0 || y >= buffer.area.bottom() || x >= buffer.area.right() { + return; + } + buffer.set_stringn(x, y, text, width as usize, style); +} + +pub(super) fn display_width(text: &str) -> u16 { + UnicodeWidthStr::width(text).min(u16::MAX as usize) as u16 +} diff --git a/src/client/shell/scroll.rs b/src/client/shell/scroll.rs new file mode 100644 index 00000000..a0496cb8 --- /dev/null +++ b/src/client/shell/scroll.rs @@ -0,0 +1,98 @@ +use ratatui::{buffer::Buffer, layout::Rect, style::Style}; + +use super::Palette; + +pub(super) fn list_scroll_metrics( + row_heights: &[u16], + gaps_after: &[u16], + body_height: u16, + requested_start: usize, +) -> crate::pane::ScrollMetrics { + if row_heights.is_empty() || body_height == 0 { + return crate::pane::ScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 0, + }; + } + + let mut used = 0u16; + let mut max_start = row_heights.len(); + for index in (0..row_heights.len()).rev() { + let height = row_heights[index].max(1).min(body_height); + let gap = gaps_after.get(index).copied().unwrap_or(0); + if used.saturating_add(height).saturating_add(gap) > body_height { + break; + } + used = used.saturating_add(height).saturating_add(gap); + max_start = index; + } + max_start = max_start.min(row_heights.len().saturating_sub(1)); + let start = requested_start.min(max_start); + + let mut viewport_rows = 0usize; + let mut used = 0u16; + for (index, row_height) in row_heights.iter().enumerate().skip(start) { + let height = (*row_height).max(1).min(body_height); + if used.saturating_add(height) > body_height { + break; + } + used = used.saturating_add(height); + viewport_rows += 1; + let gap = gaps_after.get(index).copied().unwrap_or(0); + if used.saturating_add(gap) > body_height { + break; + } + used = used.saturating_add(gap); + } + + crate::pane::ScrollMetrics { + offset_from_bottom: max_start.saturating_sub(start), + max_offset_from_bottom: max_start, + viewport_rows, + } +} + +pub(super) fn render_list_scrollbar( + buffer: &mut Buffer, + track: Rect, + metrics: crate::pane::ScrollMetrics, + palette: &Palette, +) { + let Some(thumb) = crate::ui::scrollbar_thumb(metrics, track) else { + return; + }; + for row in track.y..track.bottom() { + if let Some(cell) = buffer.cell_mut((track.x, row)) { + cell.set_symbol("▕") + .set_style(Style::default().fg(palette.surface_dim)); + } + } + for row in thumb.top..thumb.top.saturating_add(thumb.len) { + if let Some(cell) = buffer.cell_mut((track.x, row)) { + cell.set_symbol("▕") + .set_style(Style::default().fg(palette.overlay0)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_metrics_preserve_variable_rows_and_caller_owned_gap_policy() { + let top = list_scroll_metrics(&[1, 3, 2], &[1, 1, 0], 5, 0); + assert_eq!(top.max_offset_from_bottom, 2); + assert_eq!(top.offset_from_bottom, 2); + assert_eq!(top.viewport_rows, 2); + + let bottom = list_scroll_metrics(&[1, 3, 2], &[1, 1, 0], 5, usize::MAX); + assert_eq!(bottom.offset_from_bottom, 0); + assert_eq!(bottom.viewport_rows, 1); + + let parent_child = list_scroll_metrics(&[2, 2, 2], &[0, 1, 0], 5, 0); + assert_eq!(parent_child.max_offset_from_bottom, 1); + assert_eq!(parent_child.viewport_rows, 2); + } +} diff --git a/src/client/shell/settings.rs b/src/client/shell/settings.rs new file mode 100644 index 00000000..95a437fe --- /dev/null +++ b/src/client/shell/settings.rs @@ -0,0 +1,385 @@ +use super::*; +use crossterm::event::{KeyCode, KeyModifiers}; + +pub(super) fn normalized_theme_name(name: &str) -> String { + name.to_lowercase().replace([' ', '_'], "-") +} + +fn theme_index(name: &str) -> usize { + let normalized = normalized_theme_name(name); + crate::config::THEME_NAMES + .iter() + .position(|candidate| normalized_theme_name(candidate) == normalized) + .unwrap_or(0) +} + +fn indicator_index(style: crate::config::StatusIndicatorStyle) -> usize { + usize::from(style == crate::config::StatusIndicatorStyle::Symbols) +} + +fn toast_index(delivery: crate::config::ToastDelivery) -> usize { + match delivery { + crate::config::ToastDelivery::Off => 0, + crate::config::ToastDelivery::Herdr => 1, + crate::config::ToastDelivery::Terminal => 2, + crate::config::ToastDelivery::System => 3, + } +} + +pub(super) fn integration_needs_install(info: &crate::api::schema::IntegrationInfo) -> bool { + info.state == crate::api::schema::IntegrationState::Outdated + || info.available && info.state == crate::api::schema::IntegrationState::NotInstalled +} + +impl ClientShellState { + pub(super) fn open_settings_overlay(&mut self) { + self.overlay = Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Theme, + selected: theme_index(&self.config.theme_name), + original_theme_name: self.config.theme_name.clone(), + original_palette: self.config.palette.clone(), + integrations: Vec::new(), + integration_messages: Vec::new(), + loading_integrations: false, + installing_integrations: false, + })); + } + + fn selected_index_for_settings_section(&self, section: ClientSettingsSection) -> usize { + match section { + ClientSettingsSection::Theme => theme_index(&self.config.theme_name), + ClientSettingsSection::Indicators => indicator_index(self.config.status_indicators), + ClientSettingsSection::Sound => usize::from(!self.config.sound_enabled), + ClientSettingsSection::Toast => toast_index(self.config.toast_delivery), + ClientSettingsSection::Integrations => 0, + } + } + + pub(super) fn select_settings_section( + &mut self, + section: ClientSettingsSection, + outcome: &mut ClientShellInput, + ) { + let selected = self.selected_index_for_settings_section(section); + let request_integrations = matches!(section, ClientSettingsSection::Integrations) + && matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + loading_integrations: false, + installing_integrations: false, + .. + })) + ); + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + settings.section = section; + settings.selected = selected; + } + if request_integrations { + self.queue_integration_list(outcome, true); + } + outcome.repaint = true; + } + + fn move_settings_section(&mut self, delta: isize, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_ref() else { + return; + }; + let current = ClientSettingsSection::ALL + .iter() + .position(|section| *section == settings.section) + .unwrap_or(0); + let next = (current as isize + delta).rem_euclid(ClientSettingsSection::ALL.len() as isize) + as usize; + self.select_settings_section(ClientSettingsSection::ALL[next], outcome); + } + + fn settings_choice_count(&self) -> usize { + match self.overlay.as_ref() { + Some(ClientShellOverlay::Settings(settings)) => match settings.section { + ClientSettingsSection::Theme => crate::config::THEME_NAMES.len(), + ClientSettingsSection::Indicators | ClientSettingsSection::Sound => 2, + ClientSettingsSection::Toast => 4, + ClientSettingsSection::Integrations => settings.integrations.len(), + }, + _ => 0, + } + } + + pub(super) fn move_settings_selection(&mut self, delta: isize) { + let count = self.settings_choice_count(); + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() else { + return; + }; + if count == 0 { + settings.selected = 0; + return; + } + settings.selected = (settings.selected as isize + delta) + .clamp(0, count.saturating_sub(1) as isize) as usize; + if settings.section == ClientSettingsSection::Theme { + self.preview_selected_theme(); + } + } + + pub(super) fn select_settings_choice(&mut self, index: usize) { + let count = self.settings_choice_count(); + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + if count > 0 { + settings.selected = index.min(count - 1); + } + } + if matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Theme, + .. + })) + ) { + self.preview_selected_theme(); + } + } + + fn preview_selected_theme(&mut self) { + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_ref() else { + return; + }; + let Some(name) = crate::config::THEME_NAMES.get(settings.selected) else { + return; + }; + self.config.theme_name = (*name).to_owned(); + self.config.palette = + crate::app::client_palette_for_theme(&self.config.theme_runtime, name); + } + + pub(super) fn cancel_settings_overlay(&mut self) { + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.take() else { + return; + }; + self.config.theme_name = settings.original_theme_name; + self.config.palette = settings.original_palette; + } + + fn save_settings_edit( + &mut self, + edit: crate::config::ConfigEdit<'_>, + outcome: &mut ClientShellInput, + ) -> bool { + if let Err(error) = crate::config::write_edit(edit) { + self.endpoint_error = Some(error); + outcome.repaint = true; + return false; + } + self.reload_client_config(); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ServerReloadConfig( + crate::api::schema::EmptyParams::default(), + ), + PendingEndpointKind::ReloadConfig, + outcome, + ); + outcome.repaint = true; + true + } + + pub(super) fn apply_settings_choice(&mut self, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_ref() else { + return; + }; + let section = settings.section; + let selected = settings.selected; + match section { + ClientSettingsSection::Theme => { + let Some(name) = crate::config::THEME_NAMES.get(selected).copied() else { + return; + }; + if self.save_settings_edit(crate::config::ConfigEdit::Theme(name), outcome) { + self.overlay = None; + } + } + ClientSettingsSection::Indicators => { + let style = if selected == 0 { + crate::config::StatusIndicatorStyle::Dots + } else { + crate::config::StatusIndicatorStyle::Symbols + }; + self.save_settings_edit( + crate::config::ConfigEdit::StatusIndicators(style), + outcome, + ); + } + ClientSettingsSection::Sound => { + self.save_settings_edit(crate::config::ConfigEdit::Sound(selected == 0), outcome); + } + ClientSettingsSection::Toast => { + let delivery = match selected { + 0 => crate::config::ToastDelivery::Off, + 1 => crate::config::ToastDelivery::Herdr, + 2 => crate::config::ToastDelivery::Terminal, + _ => crate::config::ToastDelivery::System, + }; + self.save_settings_edit( + crate::config::ConfigEdit::ToastDelivery(delivery), + outcome, + ); + } + ClientSettingsSection::Integrations => self.install_recommended_integrations(outcome), + } + } + + fn queue_integration_list(&mut self, outcome: &mut ClientShellInput, clear_messages: bool) { + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + settings.loading_integrations = true; + if clear_messages { + settings.integration_messages.clear(); + } + } + self.push_endpoint_method_with_kind( + crate::api::schema::Method::IntegrationList(crate::api::schema::EmptyParams::default()), + PendingEndpointKind::IntegrationList, + outcome, + ); + } + + fn install_recommended_integrations(&mut self, outcome: &mut ClientShellInput) { + if self.pending_integration_installs > 0 { + return; + } + let targets = match self.overlay.as_ref() { + Some(ClientShellOverlay::Settings(settings)) => settings + .integrations + .iter() + .filter(|integration| integration_needs_install(integration)) + .map(|integration| integration.target) + .collect::<Vec<_>>(), + _ => return, + }; + if targets.is_empty() { + return; + } + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + settings.installing_integrations = true; + settings.integration_messages.clear(); + } + self.pending_integration_installs = targets.len(); + for target in targets { + self.push_endpoint_method_with_kind( + crate::api::schema::Method::IntegrationInstall( + crate::api::schema::IntegrationInstallParams { target }, + ), + PendingEndpointKind::IntegrationInstall, + outcome, + ); + } + outcome.repaint = true; + } + + pub(super) fn handle_settings_endpoint_result( + &mut self, + kind: PendingEndpointKind, + result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>, + ) -> (bool, Vec<ClientShellAction>) { + match kind { + PendingEndpointKind::IntegrationList => { + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + settings.loading_integrations = false; + match result { + Ok(crate::api::schema::ResponseResult::IntegrationList { + integrations, + }) => { + settings.integrations = integrations; + settings.selected = settings + .selected + .min(settings.integrations.len().saturating_sub(1)); + } + Ok(_) => { + self.endpoint_error = Some( + "endpoint returned an unexpected integration list result".into(), + ); + } + Err(error) => self.endpoint_error = Some(error.message), + } + } + (true, Vec::new()) + } + PendingEndpointKind::IntegrationInstall => { + self.pending_integration_installs = + self.pending_integration_installs.saturating_sub(1); + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + match result { + Ok(crate::api::schema::ResponseResult::IntegrationInstall { + details, + .. + }) => settings.integration_messages.extend(details.messages), + Ok(_) => settings + .integration_messages + .push("endpoint returned an unexpected integration result".into()), + Err(error) => settings.integration_messages.push(error.message), + } + settings.installing_integrations = self.pending_integration_installs > 0; + } + let actions = if self.pending_integration_installs == 0 + && matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) + { + let mut deferred = ClientShellInput::default(); + self.queue_integration_list(&mut deferred, false); + deferred.actions + } else { + Vec::new() + }; + (true, actions) + } + _ => (false, Vec::new()), + } + } + + pub(super) fn route_settings_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) -> bool { + if !matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) { + return false; + } + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + if code == KeyCode::Esc { + if !matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + installing_integrations: true, + .. + })) + ) { + self.cancel_settings_overlay(); + outcome.repaint = true; + } + return true; + } + if matches!(code, KeyCode::Tab | KeyCode::Right | KeyCode::Char('l')) + && modifiers.is_empty() + { + self.move_settings_section(1, outcome); + return true; + } + if matches!(code, KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h')) + && modifiers.difference(KeyModifiers::SHIFT).is_empty() + { + self.move_settings_section(-1, outcome); + return true; + } + if matches!(code, KeyCode::Up | KeyCode::Char('k')) && modifiers.is_empty() { + self.move_settings_selection(-1); + outcome.repaint = true; + return true; + } + if matches!(code, KeyCode::Down | KeyCode::Char('j')) && modifiers.is_empty() { + self.move_settings_selection(1); + outcome.repaint = true; + return true; + } + if matches!(code, KeyCode::Enter | KeyCode::Char(' ')) && modifiers.is_empty() { + self.apply_settings_choice(outcome); + return true; + } + true + } +} diff --git a/src/client/shell/settings_overlay.rs b/src/client/shell/settings_overlay.rs new file mode 100644 index 00000000..ea59ad02 --- /dev/null +++ b/src/client/shell/settings_overlay.rs @@ -0,0 +1,414 @@ +use super::*; + +fn choice_style(selected: bool, palette: &Palette) -> Style { + if selected { + Style::default() + .fg(contrast(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text).bg(palette.panel_bg) + } +} + +fn draw_choice( + buffer: &mut Buffer, + rect: Rect, + label: &str, + selected: bool, + current: bool, + palette: &Palette, +) { + let style = choice_style(selected, palette); + buffer.set_style(rect, style); + let marker = if selected { "▸" } else { " " }; + let current = if current { " ✓" } else { "" }; + put_text( + buffer, + rect.x, + rect.y, + rect.width, + &format!(" {marker} {label}{current}"), + style, + ); +} + +pub(super) fn render_settings_overlay( + buffer: &mut Buffer, + settings: &ClientSettingsOverlay, + integration_updates_available: bool, + palette: &Palette, +) -> Option<OverlayRender> { + let integration_height = 14u16 + .saturating_add(settings.integrations.len().max(1) as u16) + .saturating_add(settings.integration_messages.len().min(6) as u16); + let height = if settings.section == ClientSettingsSection::Integrations { + integration_height.max(22) + } else { + 22 + }; + let popup = popup(buffer.area, 76, height)?; + let inner = panel(buffer, popup, palette.accent, palette.panel_bg)?; + if inner.width < 20 || inner.height < 8 { + return None; + } + + put_text( + buffer, + inner.x, + inner.y, + inner.width, + " settings", + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + + let integration_badge = integration_updates_available + || settings + .integrations + .iter() + .any(|integration| integration.state == crate::api::schema::IntegrationState::Outdated); + let mut tab_x = inner.x; + let mut tab_hits = Vec::new(); + for section in ClientSettingsSection::ALL { + let badge = *section == ClientSettingsSection::Integrations && integration_badge; + let label = if badge { + format!(" ● {} ", section.label()) + } else { + format!(" {} ", section.label()) + }; + let width = display_width(&label).min(inner.right().saturating_sub(tab_x)); + let rect = Rect::new(tab_x, inner.y + 1, width, 1); + let active = *section == settings.section; + let style = if active { + Style::default() + .fg(contrast(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.overlay1).bg(palette.panel_bg) + }; + buffer.set_style(rect, style); + put_text(buffer, rect.x, rect.y, rect.width, &label, style); + if badge && !active { + put_text( + buffer, + rect.x.saturating_add(1), + rect.y, + rect.width.saturating_sub(1).min(2), + "● ", + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + } + tab_hits.push((rect, *section)); + tab_x = tab_x.saturating_add(width.saturating_add(1)); + if tab_x >= inner.right() { + break; + } + } + put_text( + buffer, + inner.x, + inner.y + 2, + inner.width, + &"─".repeat(inner.width as usize), + Style::default().fg(palette.surface0).bg(palette.panel_bg), + ); + + let content = Rect::new( + inner.x, + inner.y + 4, + inner.width, + inner.height.saturating_sub(7), + ); + let mut choice_hits = Vec::new(); + match settings.section { + ClientSettingsSection::Theme => { + let visible = usize::from(content.height); + let scroll = settings.selected.saturating_sub(visible.saturating_sub(1)); + for (visible_index, (index, name)) in crate::config::THEME_NAMES + .iter() + .enumerate() + .skip(scroll) + .take(visible) + .enumerate() + { + let rect = Rect::new( + content.x, + content.y + visible_index as u16, + content.width, + 1, + ); + draw_choice( + buffer, + rect, + name, + index == settings.selected, + super::super::settings::normalized_theme_name(name) + == super::super::settings::normalized_theme_name( + &settings.original_theme_name, + ), + palette, + ); + choice_hits.push((rect, index)); + } + } + ClientSettingsSection::Indicators => { + render_choice_section( + buffer, + content, + "agent status indicators", + "choose color dots or distinct symbols for each state", + &["color dots ● ● ● ○ ·", "distinct symbols × ◐ ✓ ○ ·"], + settings.selected, + palette, + &mut choice_hits, + ); + } + ClientSettingsSection::Sound => { + render_choice_section( + buffer, + content, + "sound alerts", + "play sounds when agents change state in background", + &["on", "off"], + settings.selected, + palette, + &mut choice_hits, + ); + } + ClientSettingsSection::Toast => { + render_choice_section( + buffer, + content, + "notification popups", + "choose where background popup notifications should appear", + &["off", "inside herdr", "via terminal", "via system"], + settings.selected, + palette, + &mut choice_hits, + ); + } + ClientSettingsSection::Integrations => { + render_integrations(buffer, content, settings, palette); + } + } + + let installable = settings + .integrations + .iter() + .any(super::super::settings::integration_needs_install); + let show_primary = settings.section != ClientSettingsSection::Integrations || installable; + let labels = if show_primary { vec![10, 12] } else { vec![12] }; + let buttons = row(inner, &labels, 2, inner.height.saturating_sub(1)); + let (primary, close) = if show_primary { + let primary = buttons[0]; + button( + buffer, + primary, + if settings.section == ClientSettingsSection::Integrations { + " ↵ install " + } else { + " ↵ apply " + }, + Style::default() + .fg(contrast(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD), + ); + (primary, buttons[1]) + } else { + (Rect::default(), buttons[0]) + }; + button( + buffer, + close, + " esc close ", + Style::default() + .fg(palette.text) + .bg(palette.surface0) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + inner.x, + inner.bottom().saturating_sub(2), + inner.width, + " ↑↓ select tab section", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + + Some(OverlayRender { + primary, + cancel: close, + settings_popup: popup, + settings_tabs: tab_hits, + settings_choices: choice_hits, + ..OverlayRender::default() + }) +} + +fn render_choice_section( + buffer: &mut Buffer, + area: Rect, + title: &str, + description: &str, + choices: &[&str], + selected: usize, + palette: &Palette, + hits: &mut Vec<(Rect, usize)>, +) { + put_text( + buffer, + area.x, + area.y, + area.width, + title, + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + area.x, + area.y + 1, + area.width, + description, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + let row_gap = u16::from(choices.len() > 2); + for (index, choice) in choices.iter().enumerate() { + let y = area.y + 3 + index as u16 * (1 + row_gap); + if y >= area.bottom() { + break; + } + let rect = Rect::new(area.x, y, area.width, 1); + draw_choice(buffer, rect, choice, index == selected, false, palette); + hits.push((rect, index)); + } +} + +fn render_integrations( + buffer: &mut Buffer, + area: Rect, + settings: &ClientSettingsOverlay, + palette: &Palette, +) { + put_text( + buffer, + area.x, + area.y, + area.width, + "agent integrations", + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + area.x, + area.y + 1, + area.width, + "let agents report state directly instead of relying only on process detection", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + if settings.loading_integrations { + put_text( + buffer, + area.x, + area.y + 3, + area.width, + " loading integrations…", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + return; + } + if settings.integrations.is_empty() { + put_text( + buffer, + area.x, + area.y + 3, + area.width, + " no integration targets available", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + return; + } + for (index, integration) in settings.integrations.iter().enumerate() { + let y = area.y + 3 + index as u16; + if y >= area.bottom() { + break; + } + let (marker, color, status) = match integration.state { + crate::api::schema::IntegrationState::Current => ("✓", palette.green, "installed"), + crate::api::schema::IntegrationState::Outdated => { + ("↻", palette.yellow, "update available") + } + crate::api::schema::IntegrationState::NotInstalled if integration.available => { + ("+", palette.accent, "available") + } + crate::api::schema::IntegrationState::NotInstalled => { + ("–", palette.overlay0, "not found") + } + }; + put_text( + buffer, + area.x, + y, + 3, + &format!(" {marker}"), + Style::default().fg(color).bg(palette.panel_bg), + ); + put_text( + buffer, + area.x + 3, + y, + 11.min(area.width.saturating_sub(3)), + &format!("{:<9}", integration.label), + Style::default().fg(palette.subtext0).bg(palette.panel_bg), + ); + put_text( + buffer, + area.x + 14, + y, + area.width.saturating_sub(14), + status, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + } + let message_y = area + .y + .saturating_add(4) + .saturating_add(settings.integrations.len() as u16); + for (offset, message) in settings.integration_messages.iter().take(6).enumerate() { + let y = message_y.saturating_add(offset as u16); + if y >= area.bottom() { + break; + } + put_text( + buffer, + area.x, + y, + area.width, + &format!(" {message}"), + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + } + if settings.installing_integrations && message_y < area.bottom() { + put_text( + buffer, + area.x, + message_y, + area.width, + " installing…", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + } +} diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs new file mode 100644 index 00000000..f8a87043 --- /dev/null +++ b/src/client/shell/sidebar.rs @@ -0,0 +1,698 @@ +use super::*; +use ratatui::{ + text::Line, + widgets::{Paragraph, Widget}, +}; + +fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect) { + let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); + if content.is_empty() { + return (Rect::default(), None, Rect::default()); + } + if content.height < 7 { + return (content, None, Rect::default()); + } + let workspace_height = content.height.div_ceil(2); + let divider_y = content.y + workspace_height; + let detail_height = content.height.saturating_sub(workspace_height + 1); + ( + Rect::new(content.x, content.y, content.width, workspace_height), + Some(divider_y), + Rect::new(content.x, divider_y + 1, content.width, detail_height), + ) +} + +pub(crate) fn render_collapsed_sidebar( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + selected_workspace_id: Option<&str>, + hits: &mut ShellHitMap, +) { + let palette = &config.palette; + render_sidebar_background(buffer, area, palette); + let (workspace_area, divider_y, detail_area) = collapsed_sidebar_sections(area); + for (index, workspace) in snapshot + .workspaces + .iter() + .take(workspace_area.height as usize) + .enumerate() + { + let rect = Rect::new( + workspace_area.x, + workspace_area.y + index as u16, + workspace_area.width, + 1, + ); + let selected = selected_workspace_id == Some(workspace.workspace_id.as_str()); + let selection_background = + if workspace.focused && palette.selection_bg == ratatui::style::Color::Reset { + palette.active_row_bg + } else { + palette.selection_bg + }; + if selected { + buffer.set_style(rect, Style::default().bg(selection_background)); + } else if workspace.focused { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + let number_style = if selected { + Style::default() + .fg(palette.overlay1) + .bg(selection_background) + } else if workspace.focused { + Style::default().fg(palette.text).bg(palette.active_row_bg) + } else { + Style::default().fg(palette.overlay0) + }; + put_text( + buffer, + rect.x, + rect.y, + rect.width.min(2), + &format!("{:<2}", index + 1), + number_style, + ); + let status = workspace.agent_status; + put_text( + buffer, + rect.x.saturating_add(2), + rect.y, + rect.width.saturating_sub(2), + status_icon(status, config.status_indicators), + Style::default().fg(status_color(status, palette)), + ); + hits.workspaces.push(WorkspaceHit { + rect, + workspace_id: workspace.workspace_id.clone(), + indented: false, + group_toggle: None, + }); + } + + if let Some(divider_y) = divider_y { + put_text( + buffer, + workspace_area.x, + divider_y, + workspace_area.width, + &"─".repeat(workspace_area.width as usize), + Style::default().fg(palette.surface_dim), + ); + } + + let detail_content = Rect::new( + detail_area.x, + detail_area.y, + detail_area.width, + detail_area.height.saturating_sub(1), + ); + for (index, pane_id) in super::ordered_agent_pane_ids(snapshot, config.agent_panel_sort) + .into_iter() + .take(detail_content.height as usize) + .enumerate() + { + let Some(agent) = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id) + else { + continue; + }; + let rect = Rect::new( + detail_content.x, + detail_content.y + index as u16, + detail_content.width, + 1, + ); + if agent.focused { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + put_text( + buffer, + rect.x, + rect.y, + rect.width.min(2), + &format!("{:<2}", index + 1), + Style::default().fg(if agent.focused { + palette.text + } else { + palette.overlay0 + }), + ); + put_text( + buffer, + rect.x.saturating_add(2), + rect.y, + rect.width.saturating_sub(2), + status_icon(agent.agent_status, config.status_indicators), + Style::default().fg(status_color(agent.agent_status, palette)), + ); + hits.agents.push((rect, pane_id)); + } + hits.sidebar_toggle = if area.is_empty() || workspace_area.width == 0 { + Rect::default() + } else { + Rect::new( + workspace_area.x + workspace_area.width / 2, + area.bottom().saturating_sub(1), + 1, + 1, + ) + }; + put_text( + buffer, + hits.sidebar_toggle.x, + hits.sidebar_toggle.y, + hits.sidebar_toggle.width, + "»", + if super::super::global_menu::global_menu_attention(snapshot) { + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.overlay0) + }, + ); +} + +pub(crate) fn render_sidebar( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + state: &mut ShellRenderState<'_>, + hits: &mut ShellHitMap, +) { + let palette = &config.palette; + render_sidebar_background(buffer, area, palette); + hits.sidebar_divider = if area.is_empty() { + Rect::default() + } else { + Rect::new(area.right().saturating_sub(1), area.y, 1, area.height) + }; + let (workspace_area, detail_area) = + crate::ui::expanded_sidebar_sections(area, state.sidebar_section_split); + hits.sidebar_section_divider = + crate::ui::sidebar_section_divider_rect(area, state.sidebar_section_split); + put_text( + buffer, + workspace_area.x, + workspace_area.y, + workspace_area.width, + " spaces", + Style::default() + .fg(palette.overlay0) + .add_modifier(Modifier::BOLD), + ); + + let entries = workspace_entries(snapshot, state.collapsed_groups); + let body = Rect::new( + workspace_area.x, + workspace_area.y.saturating_add(WORKSPACE_HEADER_ROWS), + workspace_area.width, + workspace_area + .height + .saturating_sub(WORKSPACE_HEADER_ROWS + 1), + ); + hits.workspace_body = body; + let row_heights = entries + .iter() + .map(|entry| { + snapshot + .workspaces + .get(entry.index) + .map(|workspace| { + workspace_rows( + workspace, + displayed_workspace_status(snapshot, workspace, state.collapsed_groups), + entry.indented, + &config.spaces, + ) + .len() + .max(1) + .min(u16::MAX as usize) as u16 + }) + .unwrap_or(1) + }) + .collect::<Vec<_>>(); + let gaps = entries + .iter() + .enumerate() + .map(|(index, _)| { + entries + .get(index + 1) + .map_or(0, |next| u16::from(!next.indented) * config.spaces.row_gap) + }) + .collect::<Vec<_>>(); + let metrics = super::scroll::list_scroll_metrics( + &row_heights, + &gaps, + body.height, + *state.workspace_scroll, + ); + hits.workspace_max_scroll = metrics.max_offset_from_bottom; + hits.workspace_scroll_metrics = Some(metrics); + *state.workspace_scroll = metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom); + let show_scrollbar = metrics.max_offset_from_bottom > 0 && body.width > 1; + let content_width = body.width.saturating_sub(u16::from(show_scrollbar)); + let mut y = body.y; + for (entry_position, entry) in entries.iter().enumerate().skip(*state.workspace_scroll) { + let Some(workspace) = snapshot.workspaces.get(entry.index) else { + continue; + }; + let status = displayed_workspace_status(snapshot, workspace, state.collapsed_groups); + let rows = workspace_rows(workspace, status, entry.indented, &config.spaces); + let row_height = (rows.len().max(1).min(u16::MAX as usize) as u16).min(body.height); + if y.saturating_add(row_height) > body.bottom() { + break; + } + let rect = Rect::new(body.x, y, content_width, row_height); + let selected = state.selected_workspace_id == Some(workspace.workspace_id.as_str()); + let dragged = state.dragged_workspace_id == Some(workspace.workspace_id.as_str()); + if selected { + buffer.set_style(rect, Style::default().bg(palette.selection_bg)); + } else if dragged { + buffer.set_style(rect, Style::default().bg(palette.surface1)); + } else if workspace.focused { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + render_workspace_rows( + buffer, + rect, + workspace, + status, + config.status_indicators, + entry, + rows, + selected, + dragged, + palette, + ); + let group_toggle = parent_group_key(snapshot, entry.index).map(|key| { + let rect = Rect::new(rect.right().saturating_sub(1), rect.y, 1, 1); + put_text( + buffer, + rect.x, + rect.y, + rect.width, + if state.collapsed_groups.contains(&key) { + "▸" + } else { + "▾" + }, + Style::default().fg(palette.accent), + ); + (rect, key) + }); + hits.workspaces.push(WorkspaceHit { + rect, + workspace_id: workspace.workspace_id.clone(), + indented: entry.indented, + group_toggle, + }); + let gap = entries + .get(entry_position + 1) + .map_or(0, |next| u16::from(!next.indented) * config.spaces.row_gap); + y = y.saturating_add(row_height + gap); + } + + if show_scrollbar { + let track = Rect::new(body.right().saturating_sub(1), body.y, 1, body.height); + hits.workspace_scrollbar = track; + super::scroll::render_list_scrollbar(buffer, track, metrics, palette); + } + + if let Some(row) = state.workspace_drop_indicator_row.filter(|row| { + *row >= workspace_area.y.saturating_add(1) + && *row < workspace_area.bottom().saturating_sub(1) + }) { + put_text( + buffer, + body.x, + row, + body.width, + &"─".repeat(body.width as usize), + Style::default().fg(palette.accent), + ); + } + + let footer_y = workspace_area.bottom().saturating_sub(1); + if config.mouse_capture { + hits.new_workspace = Rect::new( + workspace_area.x, + footer_y, + 5.min(workspace_area.width), + u16::from(workspace_area.height > 0), + ); + put_text( + buffer, + workspace_area.x, + footer_y, + workspace_area.width, + " new", + Style::default().fg(palette.overlay0), + ); + let attention = super::super::global_menu::global_menu_attention(snapshot); + let launcher_width = if attention { 8 } else { 6 }.min(workspace_area.width); + hits.global_launcher = Rect::new( + workspace_area.right().saturating_sub(launcher_width), + footer_y, + launcher_width, + 1, + ); + if attention { + let start_x = workspace_area.right().saturating_sub(6); + put_text( + buffer, + start_x, + footer_y, + 2, + "● ", + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + start_x.saturating_add(2), + footer_y, + 4, + "menu", + Style::default().fg(palette.overlay0), + ); + } else { + put_right_text( + buffer, + workspace_area, + footer_y, + "menu", + Style::default().fg(palette.overlay0), + ); + } + } + + super::render_agent_panel( + buffer, + detail_area, + snapshot, + config, + state.agent_scroll, + hits, + ); + + hits.sidebar_toggle = Rect::new( + area.right().saturating_sub(2), + area.bottom().saturating_sub(1), + u16::from(area.width > 1), + u16::from(area.height > 0), + ); + put_text( + buffer, + hits.sidebar_toggle.x, + hits.sidebar_toggle.y, + hits.sidebar_toggle.width, + "«", + Style::default().fg(palette.overlay0), + ); +} + +pub(crate) fn render_sidebar_background(buffer: &mut Buffer, area: Rect, palette: &Palette) { + buffer.set_style(area, Style::default().bg(palette.sidebar_bg)); + let separator_x = area.right().saturating_sub(1); + for y in area.y..area.bottom() { + if let Some(cell) = buffer.cell_mut((separator_x, y)) { + cell.set_symbol("│"); + cell.set_style(Style::default().fg(palette.surface_dim)); + } + } +} + +pub(crate) fn workspace_entries( + snapshot: &ClientShellSnapshot, + collapsed_groups: &HashSet<String>, +) -> Vec<WorkspaceEntry> { + let mut members = HashMap::<&str, Vec<usize>>::new(); + for (index, workspace) in snapshot.workspaces.iter().enumerate() { + if let Some(worktree) = &workspace.worktree { + members.entry(&worktree.key).or_default().push(index); + } + } + let grouped = members + .iter() + .filter(|(_, indices)| { + indices.len() >= 2 + && indices.iter().any(|index| { + snapshot.workspaces[*index] + .worktree + .as_ref() + .is_some_and(|worktree| !worktree.is_linked_worktree) + }) + }) + .map(|(key, _)| *key) + .collect::<HashSet<_>>(); + let mut emitted = HashSet::<&str>::new(); + let mut entries = Vec::new(); + for (index, workspace) in snapshot.workspaces.iter().enumerate() { + let Some(worktree) = workspace + .worktree + .as_ref() + .filter(|worktree| grouped.contains(worktree.key.as_str())) + else { + entries.push(WorkspaceEntry { + index, + indented: false, + last_child: false, + }); + continue; + }; + if !emitted.insert(&worktree.key) { + continue; + } + let Some(group_members) = members.get(worktree.key.as_str()) else { + continue; + }; + let parent = group_members + .iter() + .copied() + .find(|member| { + snapshot.workspaces[*member] + .worktree + .as_ref() + .is_some_and(|worktree| !worktree.is_linked_worktree) + }) + .unwrap_or(index); + entries.push(WorkspaceEntry { + index: parent, + indented: false, + last_child: false, + }); + if collapsed_groups.contains(&worktree.key) { + if let Some(active) = group_members + .iter() + .copied() + .find(|member| *member != parent && snapshot.workspaces[*member].focused) + { + entries.push(WorkspaceEntry { + index: active, + indented: true, + last_child: true, + }); + } + continue; + } + let children = group_members + .iter() + .copied() + .filter(|member| *member != parent) + .collect::<Vec<_>>(); + for (child_index, child) in children.iter().enumerate() { + entries.push(WorkspaceEntry { + index: *child, + indented: true, + last_child: child_index + 1 == children.len(), + }); + } + } + entries +} + +fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<String> { + let workspace = snapshot.workspaces.get(index)?; + let worktree = workspace.worktree.as_ref()?; + if worktree.is_linked_worktree { + return None; + } + (snapshot + .workspaces + .iter() + .filter(|candidate| { + candidate + .worktree + .as_ref() + .is_some_and(|candidate| candidate.key == worktree.key) + }) + .count() + >= 2) + .then(|| worktree.key.clone()) +} + +fn displayed_workspace_status( + snapshot: &ClientShellSnapshot, + workspace: &ClientShellWorkspace, + collapsed_groups: &HashSet<String>, +) -> crate::api::schema::AgentStatus { + let Some(worktree) = workspace + .worktree + .as_ref() + .filter(|worktree| !worktree.is_linked_worktree) + else { + return workspace.agent_status; + }; + if !collapsed_groups.contains(&worktree.key) { + return workspace.agent_status; + } + snapshot + .workspaces + .iter() + .filter(|candidate| { + candidate + .worktree + .as_ref() + .is_some_and(|candidate| candidate.key == worktree.key) + }) + .map(|candidate| candidate.agent_status) + .max_by_key(|status| status_priority(*status)) + .unwrap_or(workspace.agent_status) +} + +fn workspace_rows( + workspace: &ClientShellWorkspace, + status: crate::api::schema::AgentStatus, + indented: bool, + config: &SpacesSidebarConfig, +) -> Vec<Vec<crate::ui::ResolvedToken>> { + let label = if indented && !workspace.custom_label { + workspace + .branch + .as_deref() + .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch))) + .unwrap_or(&workspace.label) + } else { + &workspace.label + }; + let token_values = workspace.tokens.iter().cloned().collect::<HashMap<_, _>>(); + crate::ui::sidebar_space_rows( + config, + crate::ui::SpaceTokenContext { + workspace: label, + branch: workspace.branch.as_deref(), + state_text: status_text(status), + ahead_behind: workspace.git_ahead_behind, + tokens: &token_values, + suppress_git_details: indented, + }, + ) +} + +fn render_workspace_rows( + buffer: &mut Buffer, + area: Rect, + workspace: &ClientShellWorkspace, + status: crate::api::schema::AgentStatus, + indicators: crate::config::StatusIndicatorStyle, + entry: &WorkspaceEntry, + rows: Vec<Vec<crate::ui::ResolvedToken>>, + selected: bool, + dragged: bool, + palette: &Palette, +) { + for (row_index, row) in rows.iter().enumerate() { + let y = area.y + row_index as u16; + if y >= area.bottom() { + break; + } + let mut x = area.x; + if entry.indented { + let prefix = if row_index == 0 { + if entry.last_child { + " └─ " + } else { + " ├─ " + } + } else if entry.last_child { + " " + } else { + " │ " + }; + x = put_segment( + buffer, + x, + y, + area.right(), + prefix, + Style::default().fg(palette.overlay0), + ); + } else if row_index == 0 { + x = x.saturating_add(1); + } else { + x = x.saturating_add(3); + } + let highlighted = workspace.focused || dragged; + let workspace_style = Style::default() + .fg(if highlighted { + palette.text + } else { + palette.subtext0 + }) + .add_modifier(if highlighted { + Modifier::BOLD + } else { + Modifier::empty() + }); + let secondary_style = Style::default().fg(if workspace.focused { + palette.mauve + } else { + palette.overlay0 + }); + let spans = crate::ui::resolved_token_spans( + row, + ( + status_icon(status, indicators), + Style::default().fg(status_color(status, palette)), + ), + Style::default() + .fg(status_color(status, palette)) + .add_modifier(Modifier::DIM), + workspace_style, + secondary_style, + Style::default().fg(palette.overlay1), + palette, + area.right().saturating_sub(2).saturating_sub(x) as usize, + ); + Paragraph::new(Line::from(spans)).render( + Rect::new(x, y, area.right().saturating_sub(2).saturating_sub(x), 1), + buffer, + ); + } + + let background = if selected { + Some(palette.selection_bg) + } else if dragged { + Some(palette.surface1) + } else if workspace.focused { + Some(palette.active_row_bg) + } else { + None + }; + if let Some(background) = background { + for y in area.y..area.bottom() { + for x in area.x..area.right() { + buffer[(x, y)].set_bg(background); + } + } + } +} diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs new file mode 100644 index 00000000..81173c18 --- /dev/null +++ b/src/client/shell/state.rs @@ -0,0 +1,1613 @@ +use super::*; + +pub(super) const MIN_TAB_WIDTH: u16 = 8; +pub(super) const NEW_TAB_WIDTH: u16 = 3; +pub(super) const WORKSPACE_HEADER_ROWS: u16 = 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ClientShellKeybindingSource { + Local, + RemoteLocal, + Endpoint, +} + +pub(crate) struct ClientShellConfig { + pub(super) sidebar_width: u16, + pub(super) sidebar_min_width: u16, + pub(super) sidebar_max_width: u16, + pub(super) sidebar_start_collapsed: bool, + pub(super) sidebar_collapsed_mode: SidebarCollapsedModeConfig, + pub(super) mobile_width_threshold: u16, + pub(super) tab_bar_position: TabBarPositionConfig, + pub(super) hide_tab_bar_when_single_tab: bool, + pub(super) spaces: SpacesSidebarConfig, + pub(super) agents: crate::config::AgentsSidebarConfig, + pub(super) agent_panel_sort: crate::config::AgentPanelSortConfig, + pub(super) status_indicators: crate::config::StatusIndicatorStyle, + pub(super) sound_enabled: bool, + pub(super) toast_delivery: crate::config::ToastDelivery, + pub(super) toast_delay_seconds: u64, + pub(super) toast_position: crate::config::ToastHerdrPosition, + pub(super) copy_on_select: bool, + pub(super) clipboard_toast_enabled: bool, + pub(super) clipboard_toast_position: crate::config::ToastClipboardPosition, + pub(super) theme_name: String, + pub(super) theme_runtime: crate::app::state::ThemeRuntimeConfig, + pub(super) palette: Palette, + pub(super) keybinds: LiveKeybindConfig, + pub(super) local_keys: crate::config::KeysConfig, + pub(super) keybinding_source: ClientShellKeybindingSource, + pub(super) prompt_new_tab_name: bool, + pub(super) prompt_new_workspace_name: bool, + pub(super) confirm_close: bool, + pub(super) mouse_capture: bool, + pub(super) mouse_scroll_lines: usize, + pub(super) right_click_passthrough_modifiers: Option<crossterm::event::KeyModifiers>, + pub(super) redraw_on_focus_gained: bool, + pub(super) switch_ascii_input_source_in_prefix: bool, + pub(super) local_config_path: std::path::PathBuf, + pub(super) preferences_path: Option<std::path::PathBuf>, + pub(super) preferences: preferences::ClientChromePreferences, + pub(super) startup_config_diagnostic: Option<String>, + pub(super) startup_onboarding: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ClientShellLayout { + pub sidebar: Rect, + pub tab_bar: Rect, + pub mobile_header: Rect, + pub pane_surface: Rect, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum ClientMobileTarget { + NewWorkspace, + Workspace(String), + NewTab, + Tab(String), + Agent(String), + Menu(usize), +} + +#[derive(Default)] +pub(super) struct ShellHitMap { + pub(super) workspaces: Vec<WorkspaceHit>, + pub(super) workspace_body: Rect, + pub(super) workspace_scrollbar: Rect, + pub(super) workspace_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(super) workspace_max_scroll: usize, + pub(super) tabs: Vec<(Rect, String)>, + pub(super) panes: Vec<PaneHit>, + pub(super) popup: Option<PaneHit>, + pub(super) pane_splits: Vec<PaneSplitHit>, + pub(super) agents: Vec<(Rect, String)>, + pub(super) agent_body: Rect, + pub(super) agent_scrollbar: Rect, + pub(super) agent_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(super) agent_max_scroll: usize, + pub(super) agent_sort_toggle: Rect, + pub(super) sidebar_divider: Rect, + pub(super) sidebar_section_divider: Rect, + pub(super) sidebar_toggle: Rect, + pub(super) new_workspace: Rect, + pub(super) new_tab: Rect, + pub(super) tab_scroll_left: Rect, + pub(super) tab_scroll_right: Rect, + pub(super) mobile_switch: Rect, + pub(super) mobile_close: Rect, + pub(super) mobile_targets: Vec<(Rect, ClientMobileTarget)>, + pub(super) mobile_max_scroll: usize, + pub(super) global_launcher: Rect, + pub(super) notification_toast: Rect, + pub(super) global_menu_rows: Vec<(Rect, usize)>, + pub(super) context_menu_rows: Vec<(Rect, usize)>, + pub(super) overlay_primary: Rect, + pub(super) overlay_clear: Rect, + pub(super) overlay_cancel: Rect, + pub(super) navigator_popup: Rect, + pub(super) navigator_search: Rect, + pub(super) navigator_rows: Vec<(Rect, usize)>, + pub(super) worktree_search: Rect, + pub(super) worktree_rows: Vec<(Rect, usize)>, + pub(super) help_popup: Rect, + pub(super) help_scrollbar: Rect, + pub(super) help_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(super) help_max_scroll: usize, + pub(super) settings_popup: Rect, + pub(super) settings_tabs: Vec<(Rect, ClientSettingsSection)>, + pub(super) settings_choices: Vec<(Rect, usize)>, + pub(super) product_announcement_scrollbar: Rect, + pub(super) product_announcement_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(super) product_announcement_max_scroll: usize, + pub(super) release_notes_scrollbar: Rect, + pub(super) release_notes_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(super) release_notes_max_scroll: usize, +} + +#[derive(Clone)] +pub(super) struct PaneHit { + pub(super) rect: Rect, + pub(super) inner_rect: Rect, + pub(super) scrollbar_rect: Option<Rect>, + pub(super) scroll: Option<crate::pane::ScrollMetrics>, + pub(super) pane_id: String, + pub(super) popup: bool, + pub(super) mouse_reporting: bool, + pub(super) sgr_pixel_mouse: bool, + pub(super) pixel_width: u32, + pub(super) pixel_height: u32, +} + +#[derive(Clone)] +pub(super) struct PaneSplitHit { + pub(super) direction: crate::protocol::PaneSurfaceSplitDirection, + pub(super) pos: u16, + pub(super) area: Rect, + pub(super) hit_rect: Rect, + pub(super) path: Vec<bool>, + pub(super) topology_signature: u64, +} + +pub(super) struct ClientPaneMouseGesture { + pub(super) hit: PaneHit, + pub(super) button: crossterm::event::MouseButton, + pub(super) stripped_modifiers: crossterm::event::KeyModifiers, + pub(super) last_event: crossterm::event::MouseEvent, + pub(super) last_position: crate::protocol::ClientMousePosition, +} + +pub(super) struct ClientWorkspacePress { + pub(super) workspace_id: String, + pub(super) start_column: u16, + pub(super) start_row: u16, +} + +pub(super) struct ClientTabPress { + pub(super) tab_id: String, + pub(super) workspace_id: String, + pub(super) start_column: u16, + pub(super) start_row: u16, +} + +pub(super) enum ClientChromeDrag { + SidebarWidth, + SidebarSection, + WorkspaceScrollbar { + grab_row_offset: u16, + }, + AgentScrollbar { + grab_row_offset: u16, + }, + HelpScrollbar { + grab_row_offset: u16, + }, + ProductAnnouncementScrollbar { + grab_row_offset: u16, + }, + ReleaseNotesScrollbar { + grab_row_offset: u16, + }, + Tab { + tab_id: String, + workspace_id: String, + insert_index: Option<usize>, + }, + Workspace { + source_workspace_id: String, + target: Option<(Option<String>, u16)>, + }, + PaneSplit { + hit: PaneSplitHit, + tab_id: String, + grab_offset: i32, + last_sent_ratio: Option<f32>, + last_sent_at: Option<std::time::Instant>, + }, + PaneScrollbar { + hit: PaneHit, + grab_row_offset: u16, + last_sent_offset: Option<usize>, + last_sent_at: Option<std::time::Instant>, + }, +} + +pub(super) struct WorkspaceHit { + pub(super) rect: Rect, + pub(super) workspace_id: String, + pub(super) indented: bool, + pub(super) group_toggle: Option<(Rect, String)>, +} + +#[derive(Debug)] +pub(crate) enum ClientShellAction { + Endpoint { + boot_id: String, + request: Box<crate::api::schema::Request>, + }, + ClipboardWrite(Vec<u8>), + Request(ClientMessage), + OpenSafeWebUrl(String), + ReplayMouse(Vec<crossterm::event::MouseEvent>), + Keybind(crate::input::KeybindAction), +} + +#[derive(Default)] +pub(crate) struct ClientShellInput { + pub detach: bool, + pub repaint: bool, + pub resize: bool, + pub query_host_appearance: bool, + pub query_host_theme: bool, + pub requests: Vec<ClientMessage>, + pub actions: Vec<ClientShellAction>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientShellMode { + Terminal, + Prefix, + Navigate, + Resize, + Copy, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientShellOverlayKind { + Onboarding, + ProductAnnouncement, + ReleaseNotes, + Rename, + ConfirmClose, + Help, + Navigator, + WorktreeCreate, + WorktreeOpen, + WorktreeRemove, + ContextMenu, + GlobalMenu, + Settings, +} + +#[derive(Debug)] +pub(super) enum ClientRenameTarget { + NewWorkspace { + source_workspace_id: Option<String>, + cwd: Option<String>, + suggested_name: String, + }, + Workspace { + workspace_id: String, + }, + NewTab { + workspace_id: String, + default_name: String, + }, + Tab { + tab_id: String, + auto_name: bool, + original_name: String, + }, + Pane { + pane_id: String, + }, +} + +#[derive(Debug)] +pub(super) struct ClientRenameOverlay { + pub(super) title: &'static str, + pub(super) input: String, + pub(super) replace_on_type: bool, + pub(super) target: ClientRenameTarget, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ClientNavigatorFilter { + Blocked, + Working, + Idle, + Done, +} + +#[derive(Clone, Debug)] +pub(super) enum ClientNavigatorTarget { + Workspace(String), + Tab(String), + Pane(String), +} + +#[derive(Clone, Debug)] +pub(super) struct ClientNavigatorRow { + pub(super) depth: u8, + pub(super) label: String, + pub(super) meta: String, + pub(super) status: crate::api::schema::AgentStatus, + pub(super) current: bool, + pub(super) target: ClientNavigatorTarget, +} + +#[derive(Debug)] +pub(super) struct ClientNavigatorOverlay { + pub(super) query: String, + pub(super) search_focused: bool, + pub(super) selected: usize, + pub(super) scroll: usize, + pub(super) filter: Option<ClientNavigatorFilter>, + pub(super) expanded_workspaces: HashSet<String>, +} + +#[derive(Debug)] +pub(super) struct ClientHelpOverlay { + pub(super) query: String, + pub(super) search_focused: bool, + pub(super) scroll: usize, +} + +#[derive(Debug)] +pub(super) struct ClientGlobalMenuOverlay { + pub(super) highlighted: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientSettingsSection { + Theme, + Indicators, + Sound, + Toast, + Integrations, +} + +impl ClientSettingsSection { + pub(super) const ALL: &[Self] = &[ + Self::Theme, + Self::Indicators, + Self::Sound, + Self::Toast, + Self::Integrations, + ]; + + pub(super) fn label(self) -> &'static str { + match self { + Self::Theme => "theme", + Self::Indicators => "indicators", + Self::Sound => "sound", + Self::Toast => "toasts", + Self::Integrations => "integrations", + } + } +} + +#[derive(Debug)] +pub(super) struct ClientSettingsOverlay { + pub(super) section: ClientSettingsSection, + pub(super) selected: usize, + pub(super) original_theme_name: String, + pub(super) original_palette: Palette, + pub(super) integrations: Vec<crate::api::schema::IntegrationInfo>, + pub(super) integration_messages: Vec<String>, + pub(super) loading_integrations: bool, + pub(super) installing_integrations: bool, +} + +#[derive(Debug)] +pub(super) struct ClientWorktreeCreateOverlay { + pub(super) source_workspace_id: String, + pub(super) repo_name: String, + pub(super) branch: String, + pub(super) checkout_path: String, + pub(super) replace_on_type: bool, + pub(super) error: Option<String>, + pub(super) creating: bool, +} + +#[derive(Debug, Clone)] +pub(super) struct ClientWorktreeOpenEntry { + pub(super) path: String, + pub(super) branch: Option<String>, + pub(super) is_linked_worktree: bool, + pub(super) is_detached: bool, + pub(super) open_workspace_id: Option<String>, + pub(super) label: String, +} + +impl ClientWorktreeOpenEntry { + pub(super) fn status_label(&self) -> &'static str { + if self.open_workspace_id.is_some() { + "open" + } else if self.branch.is_some() { + "" + } else if self.is_detached && self.is_linked_worktree { + "detached" + } else { + "root" + } + } + + pub(super) fn matches_query(&self, query: &str) -> bool { + let query = query.trim().to_lowercase(); + query.is_empty() + || format!( + "{} {} {} {}", + self.label, + self.branch.as_deref().unwrap_or_default(), + self.path, + self.status_label() + ) + .to_lowercase() + .contains(&query) + } +} + +#[derive(Debug)] +pub(super) struct ClientWorktreeOpenOverlay { + pub(super) source_workspace_id: String, + pub(super) entries: Vec<ClientWorktreeOpenEntry>, + pub(super) selected: usize, + pub(super) query: String, + pub(super) search_focused: bool, + pub(super) error: Option<String>, + pub(super) opening: bool, +} + +impl ClientWorktreeOpenOverlay { + pub(super) fn filtered_indices(&self) -> Vec<usize> { + self.entries + .iter() + .enumerate() + .filter_map(|(index, entry)| entry.matches_query(&self.query).then_some(index)) + .collect() + } + + pub(super) fn selected_entry_index(&self) -> Option<usize> { + let filtered = self.filtered_indices(); + filtered + .contains(&self.selected) + .then_some(self.selected) + .or_else(|| filtered.first().copied()) + } +} + +#[derive(Debug)] +pub(super) struct ClientWorktreeRemoveOverlay { + pub(super) workspace_id: String, + pub(super) path: String, + pub(super) error: Option<String>, + pub(super) removing: bool, + pub(super) force_confirmation: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientContextMenuAction { + Rename, + Close, + NewWorktree, + OpenWorktree, + RemoveWorktree, + ToggleGroup, + NewTab, + RenamePane, + ClearPaneName, + SwapWithFocusedPane, + SplitRight, + SplitDown, + Zoom, + ToggleRightClickPassthrough, + ClosePane, +} + +#[derive(Debug)] +pub(super) enum ClientContextMenuTarget { + Workspace { + workspace_id: String, + is_git: bool, + is_linked_worktree: bool, + has_worktree_children: bool, + collapsed: bool, + }, + Tab { + tab_id: String, + workspace_id: String, + }, + Pane { + pane_id: String, + workspace_id: String, + source_pane_id: Option<String>, + has_manual_label: bool, + right_click_passthrough: bool, + }, +} + +#[derive(Debug)] +pub(super) struct ClientContextMenuOverlay { + pub(super) target: ClientContextMenuTarget, + pub(super) x: u16, + pub(super) y: u16, + pub(super) highlighted: usize, +} + +pub(super) struct ClientContextMenuItem { + pub(super) label: &'static str, + pub(super) action: ClientContextMenuAction, +} + +#[derive(Debug)] +pub(super) struct ClientConfirmCloseOverlay { + pub(super) workspace_id: String, + pub(super) title: String, + pub(super) detail: String, +} + +#[derive(Debug)] +pub(super) enum ClientShellOverlay { + Onboarding, + ProductAnnouncement(crate::app::state::ProductAnnouncementState), + ReleaseNotes(crate::app::state::ReleaseNotesState), + Rename(ClientRenameOverlay), + ConfirmClose(ClientConfirmCloseOverlay), + Help(ClientHelpOverlay), + Navigator(ClientNavigatorOverlay), + WorktreeCreate(ClientWorktreeCreateOverlay), + WorktreeOpen(ClientWorktreeOpenOverlay), + WorktreeRemove(ClientWorktreeRemoveOverlay), + ContextMenu(ClientContextMenuOverlay), + GlobalMenu(ClientGlobalMenuOverlay), + Settings(ClientSettingsOverlay), +} + +impl ClientShellOverlay { + pub(super) fn kind(&self) -> ClientShellOverlayKind { + match self { + Self::Onboarding => ClientShellOverlayKind::Onboarding, + Self::ProductAnnouncement(_) => ClientShellOverlayKind::ProductAnnouncement, + Self::ReleaseNotes(_) => ClientShellOverlayKind::ReleaseNotes, + Self::Rename(_) => ClientShellOverlayKind::Rename, + Self::ConfirmClose(_) => ClientShellOverlayKind::ConfirmClose, + Self::Help(_) => ClientShellOverlayKind::Help, + Self::Navigator(_) => ClientShellOverlayKind::Navigator, + Self::WorktreeCreate(_) => ClientShellOverlayKind::WorktreeCreate, + Self::WorktreeOpen(_) => ClientShellOverlayKind::WorktreeOpen, + Self::WorktreeRemove(_) => ClientShellOverlayKind::WorktreeRemove, + Self::ContextMenu(_) => ClientShellOverlayKind::ContextMenu, + Self::GlobalMenu(_) => ClientShellOverlayKind::GlobalMenu, + Self::Settings(_) => ClientShellOverlayKind::Settings, + } + } +} + +#[derive(Debug)] +pub(super) enum PendingEndpointKind { + Generic, + ProductAnnouncementDismiss { + version: String, + id: String, + }, + ReleaseNotesDismiss, + PopupCommand, + ReloadConfig, + IntegrationList, + IntegrationInstall, + PrepareWorktreeCreate { + workspace_id: String, + }, + PrepareWorktreeOpen { + workspace_id: String, + }, + PrepareWorktreeRemove { + workspace_id: String, + }, + WorktreeCreate, + WorktreeOpen, + WorktreeRemove { + forced: bool, + }, + SelectionCopy { + fallback: Option<ClientMessage>, + }, + PaneScroll { + pane_id: String, + serial: u64, + }, + WordSelection { + pane_id: String, + absolute_row: u32, + col: u16, + generation: u64, + }, + PaneLinkActivate { + pane_id: String, + inner_rect: Rect, + fallback_events: Vec<crossterm::event::MouseEvent>, + }, + CopyMotion { + pane_id: String, + origin: crate::api::schema::PaneTextPoint, + session_generation: u64, + }, + CopySearch { + pane_id: String, + origin: crate::api::schema::PaneTextPoint, + query: String, + direction: crate::api::schema::PaneCopySearchDirection, + repeat: bool, + generation: u64, + session_generation: u64, + }, +} + +pub(super) struct PendingEndpointRequest { + pub(super) boot_id: String, + pub(super) confirmation_workspace_id: Option<String>, + pub(super) kind: PendingEndpointKind, +} + +pub(crate) struct ClientShellEndpointError { + pub code: Option<String>, + pub message: String, +} + +pub(crate) enum ClientShellNotificationEffect { + Sound { + sound: crate::sound::Sound, + agent: Option<String>, + }, + Terminal { + title: String, + body: Option<String>, + }, + System { + title: String, + body: Option<String>, + }, +} + +pub(super) struct ClientPendingNotification { + pub(super) event: SemanticNotification, + pub(super) deadline: std::time::Instant, + pub(super) validate_state: bool, +} + +pub(super) struct ClientVisibleNotification { + pub(super) event: SemanticNotification, + pub(super) deadline: std::time::Instant, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum ClientInputTarget { + Pane(String), + Popup(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ClientInputContext { + pub(super) mode: ClientShellMode, + pub(super) overlay: Option<ClientShellOverlayKind>, + pub(super) popup_terminal_id: Option<String>, + pub(super) popup_pending: bool, + pub(super) retained_selection: bool, +} + +type ClientInputLeases = crate::input::InputLeaseTable<u8, ClientInputContext, ClientInputTarget>; + +#[derive(Clone, Debug)] +pub(super) struct ClientPaneClick { + pub(super) pane_id: String, + pub(super) viewport_row: u16, + pub(super) col: u16, + pub(super) at: std::time::Instant, +} + +impl ClientPaneClick { + pub(super) fn is_double_click_for(&self, next: &Self) -> bool { + self.pane_id == next.pane_id + && next.at.duration_since(self.at) <= std::time::Duration::from_millis(350) + && self.viewport_row.abs_diff(next.viewport_row) <= 1 + && self.col.abs_diff(next.col) <= 1 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientSelectionAutoscrollDirection { + Up, + Down, +} + +#[derive(Clone, Debug)] +pub(super) struct ClientSelectionAutoscroll { + pub(super) pane_id: String, + pub(super) direction: ClientSelectionAutoscrollDirection, + pub(super) last_mouse_column: u16, + pub(super) last_mouse_row: u16, + pub(super) inner_rect: Rect, + pub(super) offset_from_bottom: usize, + pub(super) max_offset_from_bottom: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientCopySelection { + Character { + anchor: crate::api::schema::PaneTextPoint, + }, + Linewise { + anchor_row: u32, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ClientCopySearchPrompt { + pub(super) direction: crate::api::schema::PaneCopySearchDirection, + pub(super) query: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum ClientCopyOperation { + Motion(crate::api::schema::PaneCopyMotion), + Search { + query: String, + direction: crate::api::schema::PaneCopySearchDirection, + repeat: bool, + }, +} + +pub(super) struct ClientCopySearchResult { + pub(super) content_revision: u64, + pub(super) matches: Vec<crate::api::schema::PaneTextRange>, + pub(super) total: u64, + pub(super) current: Option<usize>, + pub(super) current_global: Option<u64>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ClientCopyModeState { + pub(super) pane_id: String, + pub(super) content_revision: u64, + pub(super) geometry: (u16, u16), + pub(super) cursor: crate::api::schema::PaneTextPoint, + pub(super) offset_from_bottom: usize, + pub(super) max_offset_from_bottom: usize, + pub(super) entry_offset_from_bottom: usize, + pub(super) selection: Option<ClientCopySelection>, + pub(super) search_prompt: Option<ClientCopySearchPrompt>, + pub(super) search_query: String, + pub(super) search_direction: Option<crate::api::schema::PaneCopySearchDirection>, + pub(super) search_matches: Vec<crate::api::schema::PaneTextRange>, + pub(super) search_total: u64, + pub(super) search_current: Option<usize>, + pub(super) search_current_global: Option<u64>, + pub(super) search_generation: u64, + pub(super) copy_after_search: bool, +} + +pub(crate) struct ClientShellState { + pub(super) config: ClientShellConfig, + pub(super) snapshot: Option<Box<ClientShellSnapshot>>, + pub(super) pane_surface: Option<PaneSurfaceFrame>, + pub(super) graphics: crate::kitty_graphics::surface::ClientState, + pub(super) graphics_cell_size: crate::kitty_graphics::HostCellSize, + pub(super) popup_terminal_id: Option<String>, + pub(super) sidebar_collapsed: bool, + pub(super) sidebar_collapsed_manual: bool, + pub(super) sidebar_width: u16, + pub(super) sidebar_width_manual: bool, + pub(super) sidebar_section_split: f32, + pub(super) sidebar_section_split_manual: bool, + pub(super) agent_panel_sort_manual: bool, + pub(super) last_sidebar_divider_click: Option<std::time::Instant>, + pub(super) chrome_drag: Option<ClientChromeDrag>, + pub(super) workspace_press: Option<ClientWorkspacePress>, + pub(super) tab_press: Option<ClientTabPress>, + pub(super) collapsed_groups: HashSet<String>, + pub(super) workspace_scroll: usize, + pub(super) agent_scroll: usize, + pub(super) tab_scroll: usize, + pub(super) mobile_switcher_scroll: usize, + pub(super) reveal_mobile_workspace: bool, + pub(super) mobile_switcher_suspended: bool, + pub(super) reveal_focused_tab: bool, + pub(super) last_tab_bar_width: Option<u16>, + pub(super) last_composed_size: Option<(u16, u16)>, + pub(super) hits: ShellHitMap, + pub(super) mode: ClientShellMode, + pub(super) navigate_workspace_id: Option<String>, + pub(super) overlay: Option<ClientShellOverlay>, + pub(super) previous_pane_id: Option<String>, + pub(super) pane_mouse_gesture: Option<ClientPaneMouseGesture>, + pub(super) url_click_consumes_until_up: bool, + pub(super) replaying_url_click: bool, + pub(super) selection: Option<crate::selection::Selection<String>>, + pub(super) last_pane_click: Option<ClientPaneClick>, + pub(super) selection_autoscroll: Option<ClientSelectionAutoscroll>, + pub(super) selection_autoscroll_deadline: Option<std::time::Instant>, + pub(super) selection_highlight_clear_deadline: Option<std::time::Instant>, + pub(super) pending_word_selection: Option<u64>, + pub(super) word_selection_generation: u64, + pub(super) copy_mode: Option<ClientCopyModeState>, + pub(super) copy_session_generation: u64, + pub(super) copy_operation_in_flight: bool, + pub(super) copy_operation_queue: VecDeque<ClientCopyOperation>, + pub(super) copy_input_queue: VecDeque<crate::input::TerminalKey>, + pub(super) next_scroll_serial: u64, + pub(super) pane_scroll_in_flight: HashMap<String, u64>, + pub(super) pane_scroll_queued: HashMap<String, usize>, + pub(super) pane_scroll_targets: HashMap<String, usize>, + pub(super) copy_feedback: Option<crate::app::state::CopyFeedback>, + pub(super) copy_feedback_deadline: Option<std::time::Instant>, + pub(super) host_mouse_pixels: Option<crate::input::mouse::HostPixels>, + pub(super) input_leases: ClientInputLeases, + pub(super) popup_pending: bool, + pub(super) popup_pending_deadline: Option<std::time::Instant>, + pub(super) next_request_id: u64, + pub(super) pending_requests: HashMap<String, PendingEndpointRequest>, + pub(super) pending_integration_installs: usize, + pub(super) pending_notifications: Vec<ClientPendingNotification>, + pub(super) visible_notification: Option<ClientVisibleNotification>, + pub(super) outer_focused: Option<bool>, + pub(super) ascii_input_source_active: bool, + pub(super) pending_input_source_changes: Vec<bool>, + pub(super) host_appearance: Option<crate::terminal_theme::HostAppearance>, + pub(super) host_appearance_explicit: bool, + pub(super) local_config_diagnostic: Option<String>, + pub(super) config_diagnostic: Option<String>, + pub(super) endpoint_error: Option<String>, + pub(super) dismissed_product_announcement: Option<(String, String)>, +} + +pub(super) fn product_announcement_state( + announcement: &crate::protocol::ClientShellProductAnnouncement, +) -> crate::app::state::ProductAnnouncementState { + crate::app::state::ProductAnnouncementState { + version: announcement.version.clone(), + id: announcement.id.clone(), + title: announcement.title.clone(), + body: announcement.body.clone(), + scroll: 0, + preview: announcement.preview, + } +} + +pub(super) fn release_notes_state( + notes: &crate::protocol::ClientShellReleaseNotes, +) -> crate::app::state::ReleaseNotesState { + crate::app::state::ReleaseNotesState { + version: notes.version.clone(), + body: notes.body.clone(), + scroll: 0, + preview: notes.preview, + } +} + +#[derive(Clone, Copy)] +pub(super) struct WorkspaceEntry { + pub(super) index: usize, + pub(super) indented: bool, + pub(super) last_child: bool, +} + +impl ClientShellState { + pub(crate) fn new(mut config: ClientShellConfig) -> Self { + let preferences = config.preferences.clone(); + let local_config_diagnostic = config.startup_config_diagnostic.take(); + let overlay = config + .startup_onboarding + .then_some(ClientShellOverlay::Onboarding); + let sidebar_collapsed = preferences + .sidebar_collapsed + .unwrap_or(config.sidebar_start_collapsed); + let (min_width, max_width) = crate::config::validated_sidebar_bounds( + config.sidebar_min_width, + config.sidebar_max_width, + ) + .unwrap_or((18, 36)); + let sidebar_width = preferences + .sidebar_width + .unwrap_or(config.sidebar_width) + .clamp(min_width, max_width); + let sidebar_section_split = preferences + .sidebar_section_split + .filter(|split| split.is_finite()) + .map(|split| split.clamp(0.1, 0.9)) + .unwrap_or(0.5); + if let Some(sort) = preferences.agent_panel_sort { + config.agent_panel_sort = sort; + } + Self { + config, + snapshot: None, + pane_surface: None, + graphics: crate::kitty_graphics::surface::ClientState::default(), + graphics_cell_size: crate::kitty_graphics::HostCellSize { + width_px: 1, + height_px: 1, + }, + popup_terminal_id: None, + sidebar_collapsed, + sidebar_collapsed_manual: preferences.sidebar_collapsed.is_some(), + sidebar_width, + sidebar_width_manual: preferences.sidebar_width.is_some(), + sidebar_section_split, + sidebar_section_split_manual: preferences.sidebar_section_split.is_some(), + agent_panel_sort_manual: preferences.agent_panel_sort.is_some(), + last_sidebar_divider_click: None, + chrome_drag: None, + workspace_press: None, + tab_press: None, + collapsed_groups: preferences.collapsed_groups.into_iter().collect(), + workspace_scroll: 0, + agent_scroll: 0, + tab_scroll: 0, + mobile_switcher_scroll: 0, + reveal_mobile_workspace: false, + mobile_switcher_suspended: false, + reveal_focused_tab: true, + last_tab_bar_width: None, + last_composed_size: None, + hits: ShellHitMap::default(), + mode: ClientShellMode::Terminal, + navigate_workspace_id: None, + overlay, + previous_pane_id: None, + pane_mouse_gesture: None, + url_click_consumes_until_up: false, + replaying_url_click: false, + selection: None, + last_pane_click: None, + selection_autoscroll: None, + selection_autoscroll_deadline: None, + selection_highlight_clear_deadline: None, + pending_word_selection: None, + word_selection_generation: 0, + copy_mode: None, + copy_session_generation: 0, + copy_operation_in_flight: false, + copy_operation_queue: VecDeque::new(), + copy_input_queue: VecDeque::new(), + next_scroll_serial: 0, + pane_scroll_in_flight: HashMap::new(), + pane_scroll_queued: HashMap::new(), + pane_scroll_targets: HashMap::new(), + copy_feedback: None, + copy_feedback_deadline: None, + host_mouse_pixels: None, + input_leases: ClientInputLeases::default(), + popup_pending: false, + popup_pending_deadline: None, + next_request_id: 1, + pending_requests: HashMap::new(), + pending_integration_installs: 0, + pending_notifications: Vec::new(), + visible_notification: None, + outer_focused: None, + ascii_input_source_active: false, + pending_input_source_changes: Vec::new(), + host_appearance: None, + host_appearance_explicit: false, + config_diagnostic: local_config_diagnostic.clone(), + local_config_diagnostic, + endpoint_error: None, + dismissed_product_announcement: None, + } + } + + fn focused_tab_count(&self) -> usize { + let Some(snapshot) = self.snapshot.as_deref() else { + return 0; + }; + snapshot + .tabs + .iter() + .filter(|tab| { + Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref() + }) + .count() + } + + pub(super) fn resume_mobile_switcher_if_ready(&mut self) -> bool { + if !self.mobile_switcher_suspended || self.overlay.is_some() { + return false; + } + self.mobile_switcher_suspended = false; + if self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.as_ref()) + .is_some() + { + self.mode = self.copy_or_terminal_mode(); + self.navigate_workspace_id = None; + } else { + self.mode = ClientShellMode::Navigate; + } + true + } + + pub(super) fn mobile_layout_active(&self) -> bool { + self.last_composed_size + .is_some_and(|(cols, rows)| !self.layout(cols, rows).mobile_header.is_empty()) + } + + pub(super) fn navigation_workspace_entries( + &self, + snapshot: &ClientShellSnapshot, + ) -> Vec<WorkspaceEntry> { + if self.mobile_layout_active() { + render::workspace_entries(snapshot, &HashSet::new()) + } else { + render::workspace_entries(snapshot, &self.collapsed_groups) + } + } + + pub(super) fn reveal_workspace(&mut self, workspace_id: &str) { + if self + .hits + .workspaces + .iter() + .any(|hit| hit.workspace_id == workspace_id) + { + return; + } + let target = self.snapshot.as_deref().and_then(|snapshot| { + self.navigation_workspace_entries(snapshot) + .iter() + .position(|entry| snapshot.workspaces[entry.index].workspace_id == workspace_id) + }); + if let Some(target) = target { + self.workspace_scroll = target.min(self.hits.workspace_max_scroll); + } + } + + pub(super) fn layout(&self, cols: u16, rows: u16) -> ClientShellLayout { + self.config.layout( + cols, + rows, + self.sidebar_collapsed, + self.focused_tab_count(), + self.sidebar_width, + ) + } + + pub(crate) fn surface_size(&self, cols: u16, rows: u16) -> ClientSurfaceSize { + let surface = self.layout(cols, rows).pane_surface; + ClientSurfaceSize { + cols: surface.width.max(1), + rows: surface.height.max(1), + } + } + + pub(crate) fn set_snapshot(&mut self, snapshot: Box<ClientShellSnapshot>) { + if self.snapshot.as_ref().is_some_and(|current| { + current.boot_id == snapshot.boot_id && snapshot.revision < current.revision + }) { + return; + } + self.graphics.set_scope(&snapshot.boot_id); + let command_bindings_changed = self.snapshot.as_ref().is_none_or(|current| { + current.commands.len() != snapshot.commands.len() + || current + .commands + .iter() + .zip(&snapshot.commands) + .any(|(left, right)| { + left.binding_labels != right.binding_labels || left.action != right.action + }) + }); + let endpoint_profile_changed = self.snapshot.as_ref().is_none_or(|current| { + current.server_keybindings_toml != snapshot.server_keybindings_toml + }); + let snapshot_keybindings_changed = match self.config.keybinding_source { + ClientShellKeybindingSource::Local => self + .snapshot + .as_ref() + .is_none_or(|current| current.commands != snapshot.commands), + ClientShellKeybindingSource::Endpoint => { + endpoint_profile_changed + || self + .snapshot + .as_ref() + .is_none_or(|current| current.commands != snapshot.commands) + } + ClientShellKeybindingSource::RemoteLocal => false, + }; + let active_keymap_changed = match self.config.keybinding_source { + ClientShellKeybindingSource::Local => command_bindings_changed, + ClientShellKeybindingSource::Endpoint => { + endpoint_profile_changed || command_bindings_changed + } + ClientShellKeybindingSource::RemoteLocal => false, + }; + self.config_diagnostic = super::config::merged_config_diagnostic( + self.local_config_diagnostic.as_deref(), + snapshot.config_diagnostic.as_deref(), + ); + let boot_changed = self + .snapshot + .as_ref() + .is_some_and(|current| current.boot_id != snapshot.boot_id); + if boot_changed + || self + .pane_surface + .as_ref() + .is_none_or(|surface| surface.projection_revision != snapshot.revision) + { + self.hits = ShellHitMap::default(); + } + if boot_changed { + self.pane_surface = None; + self.input_leases = ClientInputLeases::default(); + self.popup_terminal_id = None; + self.chrome_drag = None; + self.workspace_press = None; + self.tab_press = None; + self.workspace_scroll = 0; + self.agent_scroll = 0; + self.tab_scroll = 0; + self.mobile_switcher_scroll = 0; + self.reveal_mobile_workspace = false; + self.mobile_switcher_suspended = false; + self.reveal_focused_tab = true; + self.last_tab_bar_width = None; + self.last_composed_size = None; + self.pending_requests.clear(); + self.pane_scroll_in_flight.clear(); + self.pane_scroll_queued.clear(); + self.pane_scroll_targets.clear(); + self.popup_pending = false; + self.popup_pending_deadline = None; + self.pending_integration_installs = 0; + self.pending_notifications.clear(); + self.visible_notification = None; + self.endpoint_error = None; + self.navigate_workspace_id = None; + self.overlay = self + .config + .startup_onboarding + .then_some(ClientShellOverlay::Onboarding); + self.previous_pane_id = None; + self.pane_mouse_gesture = None; + self.url_click_consumes_until_up = false; + self.replaying_url_click = false; + self.selection = None; + self.last_pane_click = None; + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + self.selection_highlight_clear_deadline = None; + self.pending_word_selection = None; + self.copy_mode = None; + self.reset_copy_pipeline(); + self.copy_feedback = None; + self.copy_feedback_deadline = None; + self.host_mouse_pixels = None; + self.dismissed_product_announcement = None; + } else if let Some(previous) = self + .snapshot + .as_deref() + .and_then(|current| current.focused_pane_id.as_ref()) + .filter(|previous| Some(previous.as_str()) != snapshot.focused_pane_id.as_deref()) + { + self.previous_pane_id = Some(previous.clone()); + } + if snapshot_keybindings_changed { + if let Err(err) = self.config.apply_snapshot_keybindings( + snapshot.server_keybindings_toml.as_deref(), + &snapshot.commands, + ) { + self.endpoint_error = Some(err); + } else if active_keymap_changed + && matches!( + self.mode, + ClientShellMode::Prefix | ClientShellMode::Navigate | ClientShellMode::Resize + ) + { + self.mode = ClientShellMode::Terminal; + } + } + let tab_layout_changed = self.snapshot.as_deref().is_none_or(|current| { + current.tabs.len() != snapshot.tabs.len() + || current + .tabs + .iter() + .zip(&snapshot.tabs) + .any(|(left, right)| { + left.tab_id != right.tab_id + || left.workspace_id != right.workspace_id + || left.label != right.label + || left.zoomed != right.zoomed + }) + || render::tab_bar_status_width(current) != render::tab_bar_status_width(&snapshot) + }); + if tab_layout_changed + || self + .snapshot + .as_deref() + .and_then(|current| current.focused_tab_id.as_deref()) + != snapshot.focused_tab_id.as_deref() + { + self.reveal_focused_tab = true; + } + if self.selection.as_ref().is_some_and(|selection| { + snapshot.focused_pane_id.as_deref() != Some(selection.pane_id.as_str()) + || !snapshot + .panes + .iter() + .any(|pane| pane.pane_id == selection.pane_id) + }) { + self.selection = None; + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + self.selection_highlight_clear_deadline = None; + self.pending_word_selection = None; + self.last_pane_click = None; + } + if let Some(copy_pane_id) = self + .copy_mode + .as_ref() + .map(|copy_mode| copy_mode.pane_id.clone()) + { + let pane_exists = snapshot + .panes + .iter() + .any(|pane| pane.pane_id == copy_pane_id); + let pane_focused = snapshot.focused_pane_id.as_deref() == Some(copy_pane_id.as_str()); + if !pane_exists { + self.copy_mode = None; + self.reset_copy_pipeline(); + if self + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == copy_pane_id) + { + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + } + if self.mode == ClientShellMode::Copy { + self.mode = ClientShellMode::Terminal; + } + } else if pane_focused { + if self.mode == ClientShellMode::Terminal { + self.mode = ClientShellMode::Copy; + } + if self.selection.is_none() { + self.sync_copy_selection(); + } + } else { + if self + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == copy_pane_id) + { + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + } + if self.mode == ClientShellMode::Copy { + self.mode = ClientShellMode::Terminal; + } + } + } + if self.mode == ClientShellMode::Navigate + && self.navigate_workspace_id.as_ref().is_none_or(|selected| { + !snapshot + .workspaces + .iter() + .any(|workspace| &workspace.workspace_id == selected) + }) + { + self.navigate_workspace_id = snapshot.focused_workspace_id.clone(); + self.reveal_mobile_workspace = self.mobile_layout_active(); + } + let pane_exists = + |pane_id: &String| snapshot.panes.iter().any(|pane| &pane.pane_id == pane_id); + self.pane_scroll_in_flight + .retain(|pane_id, _| pane_exists(pane_id)); + self.pane_scroll_queued + .retain(|pane_id, _| pane_exists(pane_id)); + self.pane_scroll_targets + .retain(|pane_id, _| pane_exists(pane_id)); + + if !self.config.startup_onboarding { + match snapshot.product_announcement.as_ref() { + Some(announcement) => { + let key = (announcement.version.clone(), announcement.id.clone()); + let already_open = matches!( + self.overlay.as_ref(), + Some(ClientShellOverlay::ProductAnnouncement(current)) + if current.version == announcement.version && current.id == announcement.id + ); + let may_open = self.overlay.is_none() + || matches!( + self.overlay.as_ref(), + Some(ClientShellOverlay::ProductAnnouncement(_)) + ); + if self.dismissed_product_announcement.as_ref() != Some(&key) + && may_open + && !already_open + { + self.overlay = Some(ClientShellOverlay::ProductAnnouncement( + product_announcement_state(announcement), + )); + } + } + None if matches!( + self.overlay.as_ref(), + Some(ClientShellOverlay::ProductAnnouncement(_)) + ) => + { + self.overlay = None; + self.chrome_drag = None; + self.dismissed_product_announcement = None; + } + None => { + self.dismissed_product_announcement = None; + } + } + } + if let Some(ClientShellOverlay::ReleaseNotes(current)) = self.overlay.as_ref() { + match snapshot.release_notes.as_ref() { + Some(notes) + if current.version != notes.version + || current.body != notes.body + || current.preview != notes.preview => + { + self.overlay = + Some(ClientShellOverlay::ReleaseNotes(release_notes_state(notes))); + self.chrome_drag = None; + } + None => { + self.overlay = None; + self.chrome_drag = None; + } + Some(_) => {} + } + } + self.snapshot = Some(snapshot); + self.resume_mobile_switcher_if_ready(); + self.reconcile_input_source(); + } + + pub(crate) fn set_pane_surface(&mut self, mut surface: PaneSurfaceFrame) { + let Some(snapshot) = self.snapshot.as_ref() else { + return; + }; + if surface.boot_id != snapshot.boot_id + || surface.projection_revision < snapshot.revision + || self.pane_surface.as_ref().is_some_and(|current| { + current.boot_id == surface.boot_id + && (surface.projection_revision < current.projection_revision + || (surface.projection_revision == current.projection_revision + && surface.surface_revision < current.surface_revision)) + }) + { + return; + } + if self + .snapshot + .as_ref() + .is_none_or(|snapshot| snapshot.revision != surface.projection_revision) + { + self.hits = ShellHitMap::default(); + } + let previous_popup = self.popup_terminal_id.clone(); + let next_popup = surface + .popup + .as_deref() + .map(|popup| popup.terminal_id.clone()); + if previous_popup != next_popup { + if next_popup.is_some() && matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) + { + self.cancel_settings_overlay(); + } + if let Some(terminal_id) = previous_popup.as_ref() { + self.input_leases + .remove_target(&ClientInputTarget::Popup(terminal_id.clone())); + } + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + if !matches!( + self.overlay.as_ref(), + Some(ClientShellOverlay::Onboarding | ClientShellOverlay::ProductAnnouncement(_)) + ) { + self.overlay = self + .config + .startup_onboarding + .then_some(ClientShellOverlay::Onboarding); + } + self.selection = None; + self.last_pane_click = None; + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + self.selection_highlight_clear_deadline = None; + self.pending_word_selection = None; + self.copy_mode = None; + self.reset_copy_pipeline(); + self.chrome_drag = None; + self.workspace_press = None; + self.tab_press = None; + if self.pane_mouse_gesture.as_ref().is_some_and(|gesture| { + gesture.hit.popup && previous_popup.as_deref() == Some(gesture.hit.pane_id.as_str()) + }) { + self.pane_mouse_gesture = None; + } + self.hits.popup = None; + self.endpoint_error = None; + } + if next_popup.is_some() { + self.popup_pending = false; + self.popup_pending_deadline = None; + } + let selection_content_changed = self.selection.as_ref().is_some_and(|selection| { + let previous_revision = self.pane_surface.as_ref().and_then(|previous| { + previous + .panes + .iter() + .find(|pane| pane.pane_id == selection.pane_id) + .map(|pane| pane.content_revision) + }); + let next_revision = surface + .panes + .iter() + .find(|pane| pane.pane_id == selection.pane_id) + .map(|pane| pane.content_revision); + previous_revision.is_some() + && next_revision.is_some() + && previous_revision != next_revision + }); + if selection_content_changed { + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + } + for pane in &surface.panes { + let Some(target) = self.pane_scroll_targets.get(&pane.pane_id).copied() else { + continue; + }; + let Some(scroll) = pane.scroll else { + continue; + }; + let target = + target.min(usize::try_from(scroll.max_offset_from_bottom).unwrap_or(usize::MAX)); + if usize::try_from(scroll.offset_from_bottom).unwrap_or(usize::MAX) == target { + self.pane_scroll_targets.remove(&pane.pane_id); + } + } + let mut invalidated_copy_pane = None; + if let Some(copy_mode) = self.copy_mode.as_mut() { + if let Some(pane) = surface + .panes + .iter() + .find(|pane| pane.pane_id == copy_mode.pane_id) + { + let geometry = (pane.inner_rect.width, pane.inner_rect.height); + if copy_mode.content_revision != pane.content_revision + || copy_mode.geometry != geometry + { + copy_mode.content_revision = pane.content_revision; + copy_mode.geometry = geometry; + copy_mode.selection = None; + copy_mode.search_matches.clear(); + copy_mode.search_total = 0; + copy_mode.search_current = None; + copy_mode.search_current_global = None; + copy_mode.search_generation = copy_mode.search_generation.saturating_add(1); + copy_mode.copy_after_search = false; + invalidated_copy_pane = Some(copy_mode.pane_id.clone()); + } + if let Some(scroll) = pane.scroll { + let actual_offset = + usize::try_from(scroll.offset_from_bottom).unwrap_or(usize::MAX); + if !self.pane_scroll_targets.contains_key(&pane.pane_id) { + copy_mode.offset_from_bottom = actual_offset; + } + copy_mode.max_offset_from_bottom = + usize::try_from(scroll.max_offset_from_bottom).unwrap_or(usize::MAX); + } + } + } + if invalidated_copy_pane.as_ref().is_some_and(|pane_id| { + self.selection + .as_ref() + .is_some_and(|selection| &selection.pane_id == pane_id) + }) { + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + } + self.popup_terminal_id = next_popup; + self.graphics + .set_scene(std::mem::take(&mut surface.graphics)); + self.pane_surface = Some(surface); + self.resume_mobile_switcher_if_ready(); + self.reconcile_input_source(); + } + + pub(crate) fn tick_popup_pending(&mut self, now: std::time::Instant) { + if self + .popup_pending_deadline + .is_some_and(|deadline| now >= deadline) + { + self.popup_pending = false; + self.popup_pending_deadline = None; + } + } + + pub(crate) fn show_copy_feedback(&mut self, now: std::time::Instant) -> bool { + if !self.config.clipboard_toast_enabled { + return false; + } + self.copy_feedback = Some(crate::app::state::CopyFeedback { + message: "copied to clipboard".to_owned(), + }); + self.copy_feedback_deadline = Some(now + std::time::Duration::from_secs(2)); + true + } + + pub(crate) fn tick_copy_feedback(&mut self, now: std::time::Instant) -> bool { + let mut repaint = false; + if self + .copy_feedback_deadline + .is_some_and(|deadline| now >= deadline) + { + self.copy_feedback = None; + self.copy_feedback_deadline = None; + repaint = true; + } + if self + .selection_highlight_clear_deadline + .is_some_and(|deadline| now >= deadline) + { + self.selection = None; + self.selection_highlight_clear_deadline = None; + repaint = true; + } + repaint + } + + pub(crate) fn timer_delay(&self, now: std::time::Instant) -> std::time::Duration { + let default = std::time::Duration::from_millis(100); + self.selection_autoscroll_deadline + .map(|deadline| deadline.saturating_duration_since(now).min(default)) + .unwrap_or(default) + } + + pub(crate) fn invalidate_pane_surface(&mut self) { + self.pane_surface = None; + self.hits = ShellHitMap::default(); + self.host_mouse_pixels = None; + } + + fn wants_ascii_input(&self) -> bool { + if let Some(overlay) = self.overlay.as_ref() { + return matches!( + overlay, + ClientShellOverlay::ConfirmClose(_) + | ClientShellOverlay::Help(_) + | ClientShellOverlay::Navigator(_) + | ClientShellOverlay::WorktreeRemove(_) + | ClientShellOverlay::ContextMenu(_) + | ClientShellOverlay::GlobalMenu(_) + ); + } + matches!( + self.mode, + ClientShellMode::Prefix + | ClientShellMode::Navigate + | ClientShellMode::Resize + | ClientShellMode::Copy + ) + } + + pub(crate) fn reconcile_input_source(&mut self) { + // Keep the platform restore token while another window has focus. Restoring + // through a global key injection is only safe after this client regains focus. + if self.outer_focused == Some(false) { + return; + } + let desired = self.config.switch_ascii_input_source_in_prefix && self.wants_ascii_input(); + if desired != self.ascii_input_source_active { + self.ascii_input_source_active = desired; + self.pending_input_source_changes.push(desired); + } + } + + pub(crate) fn take_input_source_changes(&mut self) -> Vec<bool> { + std::mem::take(&mut self.pending_input_source_changes) + } +} diff --git a/src/client/shell/surface_patch.rs b/src/client/shell/surface_patch.rs new file mode 100644 index 00000000..10486f50 --- /dev/null +++ b/src/client/shell/surface_patch.rs @@ -0,0 +1,266 @@ +use super::*; + +pub(crate) struct ClientComposedSurfacePatch { + pub(crate) rows: Vec<crate::protocol::PaneSurfacePatchRow>, + pub(crate) cursor: Option<crate::protocol::CursorState>, +} + +pub(crate) enum ClientPaneSurfacePatchOutcome { + Rejected, + Applied(Option<ClientComposedSurfacePatch>), +} + +fn row_fits_frame(row: &crate::protocol::PaneSurfacePatchRow, frame: &FrameData) -> bool { + row.x + .saturating_add(row.cells.len().min(u16::MAX as usize) as u16) + <= frame.width + && row.y < frame.height +} + +fn apply_row(row: &crate::protocol::PaneSurfacePatchRow, frame: &mut FrameData) -> bool { + if !row_fits_frame(row, frame) { + return false; + } + let start = usize::from(row.y) * usize::from(frame.width) + usize::from(row.x); + let end = start + row.cells.len(); + if end > frame.cells.len() { + return false; + } + frame.cells[start..end].clone_from_slice(&row.cells); + true +} + +fn apply_patch_to_surface( + surface: &mut crate::protocol::PaneSurfaceFrame, + patch: &crate::protocol::PaneSurfacePatch, +) -> bool { + for row in &patch.rows { + if !apply_row(row, &mut surface.frame) { + return false; + } + } + for updated in &patch.panes { + let Some(existing) = surface + .panes + .iter_mut() + .find(|pane| pane.pane_id == updated.pane_id) + else { + return false; + }; + *existing = updated.clone(); + } + surface.frame.cursor = patch.cursor.clone(); + surface.surface_revision = patch.surface_revision; + true +} + +fn fast_path_blocker( + state: &ClientShellState, + patch: &crate::protocol::PaneSurfacePatch, +) -> Option<&'static str> { + if state.mode != ClientShellMode::Terminal { + Some("client_surface_patch.fallback.mode") + } else if state.overlay.is_some() { + Some("client_surface_patch.fallback.overlay") + } else if state.endpoint_error.is_some() { + Some("client_surface_patch.fallback.endpoint_error") + } else if state.config_diagnostic.is_some() { + Some("client_surface_patch.fallback.config_diagnostic") + } else if state.visible_notification.is_some() { + Some("client_surface_patch.fallback.notification") + } else if state.copy_feedback.is_some() { + Some("client_surface_patch.fallback.copy_feedback") + } else if state.selection.is_some() { + Some("client_surface_patch.fallback.selection") + } else if state.copy_mode.is_some() { + Some("client_surface_patch.fallback.copy_mode") + } else if state.selection_highlight_clear_deadline.is_some() { + Some("client_surface_patch.fallback.selection_deadline") + } else if patch.panes.iter().any(|pane| { + !state + .hits + .panes + .iter() + .any(|hit| hit.pane_id == pane.pane_id) + }) { + Some("client_surface_patch.fallback.pane_hits") + } else { + None + } +} + +fn pane_geometry_matches( + left: &crate::protocol::PaneSurfacePane, + right: &crate::protocol::PaneSurfacePane, +) -> bool { + left.pane_id == right.pane_id + && left.rect == right.rect + && left.inner_rect == right.inner_rect + && left.focused == right.focused + && left.pixel_width == right.pixel_width + && left.pixel_height == right.pixel_height +} + +impl ClientShellState { + pub(crate) fn apply_pane_surface_patch( + &mut self, + patch: crate::protocol::PaneSurfacePatch, + ) -> ClientPaneSurfacePatchOutcome { + let Some(current) = self.pane_surface.as_ref() else { + return ClientPaneSurfacePatchOutcome::Rejected; + }; + if patch.boot_id != current.boot_id + || patch.projection_revision != current.projection_revision + || patch.base_surface_revision != current.surface_revision + || patch.surface_revision != current.surface_revision.saturating_add(1) + || current.popup.is_some() + || !current.graphics.placements.is_empty() + || !current.graphics.retained_assets.is_empty() + { + return ClientPaneSurfacePatchOutcome::Rejected; + } + + for updated in &patch.panes { + let Some(existing) = current + .panes + .iter() + .find(|pane| pane.pane_id == updated.pane_id) + else { + return ClientPaneSurfacePatchOutcome::Rejected; + }; + if !pane_geometry_matches(existing, updated) { + return ClientPaneSurfacePatchOutcome::Rejected; + } + } + for row in &patch.rows { + if !row_fits_frame(row, ¤t.frame) + || row.cells.is_empty() + || !patch.panes.iter().any(|pane| { + let terminal_row = row.x >= pane.inner_rect.x + && row.y >= pane.inner_rect.y + && row.y < pane.inner_rect.y.saturating_add(pane.inner_rect.height) + && row + .x + .saturating_add(row.cells.len().min(u16::MAX as usize) as u16) + <= pane.inner_rect.x.saturating_add(pane.inner_rect.width); + let scrollbar_rect = pane.scrollbar_rect.or_else(|| { + current + .panes + .iter() + .find(|existing| existing.pane_id == pane.pane_id) + .and_then(|existing| existing.scrollbar_rect) + }); + let scrollbar_row = scrollbar_rect.is_some_and(|rect| { + row.x == rect.x + && row.y >= rect.y + && row.y < rect.y.saturating_add(rect.height) + && row.cells.len() == usize::from(rect.width) + }); + terminal_row || scrollbar_row + }) + { + return ClientPaneSurfacePatchOutcome::Rejected; + } + } + + let fast_path_blocker = fast_path_blocker(self, &patch); + if let Some(reason) = fast_path_blocker { + crate::render_prof::event(reason); + } + let fast_path_area = fast_path_blocker.is_none().then(|| { + let (cols, rows) = self.last_composed_size.unwrap_or_default(); + self.layout(cols, rows).pane_surface + }); + let composed_patch = fast_path_area.map(|area| ClientComposedSurfacePatch { + rows: patch + .rows + .iter() + .map(|row| crate::protocol::PaneSurfacePatchRow { + x: area.x.saturating_add(row.x), + y: area.y.saturating_add(row.y), + cells: row.cells.clone(), + }) + .collect(), + cursor: patch + .cursor + .clone() + .map(|cursor| crate::protocol::CursorState { + x: area.x.saturating_add(cursor.x), + y: area.y.saturating_add(cursor.y), + visible: cursor.visible, + shape: cursor.shape, + }), + }); + if let Some(area) = fast_path_area { + let applied = self + .pane_surface + .as_mut() + .is_some_and(|surface| apply_patch_to_surface(surface, &patch)); + if !applied { + return ClientPaneSurfacePatchOutcome::Rejected; + } + for updated in &patch.panes { + let Some(hit) = self + .hits + .panes + .iter_mut() + .find(|hit| hit.pane_id == updated.pane_id) + else { + continue; + }; + hit.scrollbar_rect = updated.scrollbar_rect.map(|rect| { + Rect::new( + area.x.saturating_add(rect.x), + area.y.saturating_add(rect.y), + rect.width, + rect.height, + ) + }); + hit.scroll = updated.scroll.map(|metrics| crate::pane::ScrollMetrics { + offset_from_bottom: usize::try_from(metrics.offset_from_bottom) + .unwrap_or(usize::MAX), + max_offset_from_bottom: usize::try_from(metrics.max_offset_from_bottom) + .unwrap_or(usize::MAX), + viewport_rows: usize::try_from(metrics.viewport_rows).unwrap_or(usize::MAX), + }); + hit.mouse_reporting = updated.mouse_reporting; + hit.sgr_pixel_mouse = updated.sgr_pixel_mouse; + hit.pixel_width = updated.pixel_width; + hit.pixel_height = updated.pixel_height; + if let (Some(target), Some(scroll)) = ( + self.pane_scroll_targets.get(&updated.pane_id).copied(), + updated.scroll, + ) { + let target = target + .min(usize::try_from(scroll.max_offset_from_bottom).unwrap_or(usize::MAX)); + if usize::try_from(scroll.offset_from_bottom).unwrap_or(usize::MAX) == target { + self.pane_scroll_targets.remove(&updated.pane_id); + } + } + } + self.reconcile_input_source(); + } else { + let mut next = current.clone(); + if !apply_patch_to_surface(&mut next, &patch) { + return ClientPaneSurfacePatchOutcome::Rejected; + } + self.set_pane_surface(next); + } + ClientPaneSurfacePatchOutcome::Applied(composed_patch) + } +} + +#[cfg(test)] +pub(crate) fn apply_composed_surface_patch( + frame: &FrameData, + patch: ClientComposedSurfacePatch, +) -> Option<FrameData> { + let mut next = frame.clone(); + for row in &patch.rows { + if !apply_row(row, &mut next) { + return None; + } + } + next.cursor = patch.cursor; + Some(next) +} diff --git a/src/client/shell/tabs.rs b/src/client/shell/tabs.rs new file mode 100644 index 00000000..cf179d80 --- /dev/null +++ b/src/client/shell/tabs.rs @@ -0,0 +1,386 @@ +use super::*; + +const TAB_SCROLL_BUTTON_WIDTH: u16 = 3; +const MIN_TAB_STRIP_WIDTH: u16 = + MIN_TAB_WIDTH + NEW_TAB_WIDTH + TAB_SCROLL_BUTTON_WIDTH.saturating_mul(2); + +pub(crate) fn render_tab_bar( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + tab_scroll: &mut usize, + reveal_focused_tab: &mut bool, + tab_drag_insert_index: Option<usize>, + hits: &mut ShellHitMap, +) { + let palette = &config.palette; + buffer.set_style(area, Style::default().bg(palette.panel_bg)); + let tabs = snapshot + .tabs + .iter() + .filter(|tab| Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref()) + .collect::<Vec<_>>(); + let desired_widths = tabs + .iter() + .map(|tab| { + let label = tab_label(tab); + display_width(&label).saturating_add(4).max(MIN_TAB_WIDTH) + }) + .collect::<Vec<_>>(); + let content = tab_bar_content_area(snapshot, area); + let mouse_chrome = config.mouse_capture; + let new_tab_width = if mouse_chrome { NEW_TAB_WIDTH } else { 0 }; + let desired_total = desired_widths + .iter() + .copied() + .fold(0_u16, u16::saturating_add) + .saturating_add(tabs.len().saturating_sub(1).min(u16::MAX as usize) as u16) + .saturating_add(new_tab_width); + let overflow = + desired_total > content.width && (!mouse_chrome || content.width >= MIN_TAB_STRIP_WIDTH); + let available = if overflow && mouse_chrome { + content + .width + .saturating_sub(NEW_TAB_WIDTH) + .saturating_sub(TAB_SCROLL_BUTTON_WIDTH.saturating_mul(2)) + } else { + content.width.saturating_sub(new_tab_width) + }; + let max_scroll = max_tab_scroll(&desired_widths, available); + if !overflow { + *tab_scroll = 0; + } else if *reveal_focused_tab { + if let Some(focused) = tabs.iter().position(|tab| tab.focused) { + *tab_scroll = centered_tab_scroll(focused, &desired_widths, available).min(max_scroll); + } + } else { + *tab_scroll = (*tab_scroll).min(max_scroll); + } + *reveal_focused_tab = false; + + let mut x = content.x; + let tab_right = if overflow && mouse_chrome { + hits.tab_scroll_left = Rect::new( + content.x, + content.y, + TAB_SCROLL_BUTTON_WIDTH.min(content.width), + 1, + ); + put_text( + buffer, + hits.tab_scroll_left.x, + content.y, + hits.tab_scroll_left.width, + " < ", + Style::default() + .fg(if *tab_scroll > 0 { + palette.overlay1 + } else { + palette.overlay0 + }) + .bg(palette.surface0), + ); + x = hits.tab_scroll_left.right(); + content + .right() + .saturating_sub(NEW_TAB_WIDTH + TAB_SCROLL_BUTTON_WIDTH) + } else { + content.right().saturating_sub(new_tab_width) + }; + + let mut first_visible = None; + let mut last_visible = None; + for (index, tab) in tabs.iter().enumerate().skip(*tab_scroll) { + let name = tab_label(tab); + let desired = desired_widths[index]; + let remaining = tab_right.saturating_sub(x); + let width = desired.min(remaining); + if width == 0 { + break; + } + let rect = Rect::new(x, area.y, width, 1); + let style = if tab.focused { + let base = Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent); + if tab.custom_label { + base.add_modifier(Modifier::BOLD) + } else { + base + } + } else if tab.custom_label { + Style::default().fg(palette.overlay1).bg(palette.surface0) + } else { + Style::default() + .fg(palette.overlay0) + .bg(palette.surface0) + .add_modifier(Modifier::DIM) + }; + let padding = width.saturating_sub(display_width(&name)); + let left = padding / 2; + let text = format!( + "{empty:left$}{name}{empty:right_padding$}", + empty = "", + left = left as usize, + right_padding = padding.saturating_sub(left) as usize, + ); + put_text(buffer, rect.x, rect.y, rect.width, &text, style); + hits.tabs.push((rect, tab.tab_id.clone())); + first_visible.get_or_insert(index); + last_visible = Some(index); + x = x.saturating_add(width + 1); + if width < desired { + break; + } + } + + if overflow && mouse_chrome { + hits.tab_scroll_right = Rect::new(tab_right, area.y, TAB_SCROLL_BUTTON_WIDTH, 1); + let can_scroll_right = last_visible.is_some_and(|index| index + 1 < tabs.len()); + put_text( + buffer, + hits.tab_scroll_right.x, + area.y, + hits.tab_scroll_right.width, + " > ", + Style::default() + .fg(if can_scroll_right { + palette.overlay1 + } else { + palette.overlay0 + }) + .bg(palette.surface0), + ); + hits.new_tab = Rect::new( + hits.tab_scroll_right.right(), + area.y, + content + .right() + .saturating_sub(hits.tab_scroll_right.right()) + .min(NEW_TAB_WIDTH), + 1, + ); + } else if mouse_chrome { + hits.new_tab = Rect::new( + x.min(content.right()), + area.y, + content.right().saturating_sub(x).min(NEW_TAB_WIDTH), + 1, + ); + } + if mouse_chrome { + put_text( + buffer, + hits.new_tab.x, + area.y, + hits.new_tab.width, + " + ", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + } + + if first_visible.is_some_and(|index| index > 0) { + let ellipsis_x = if hits.tab_scroll_left.width > 0 { + hits.tab_scroll_left.right() + } else { + content.x + }; + put_text( + buffer, + ellipsis_x, + area.y, + u16::from(ellipsis_x < content.right()), + "…", + Style::default().fg(palette.overlay0), + ); + } + if last_visible.is_some_and(|index| index + 1 < tabs.len()) { + let ellipsis_x = if hits.tab_scroll_right.width > 0 { + hits.tab_scroll_right.x.saturating_sub(1) + } else { + content.right().saturating_sub(1) + }; + put_text( + buffer, + ellipsis_x, + area.y, + u16::from(ellipsis_x >= content.x && ellipsis_x < content.right()), + "…", + Style::default().fg(palette.overlay0), + ); + } + + if let Some(insert_index) = tab_drag_insert_index { + if let Some(indicator_x) = tab_drop_indicator_x(hits, &tabs, insert_index) { + put_text( + buffer, + indicator_x.min(content.right().saturating_sub(1)), + area.y, + 1, + "│", + Style::default().fg(palette.accent), + ); + } + } + render_tab_bar_status(buffer, area, snapshot, palette); +} + +pub(crate) fn tab_bar_status_width(snapshot: &ClientShellSnapshot) -> u16 { + let content = snapshot.tab_bar_right.iter().fold(0u16, |width, segment| { + width.saturating_add(display_width(&segment.text)) + }); + let separators = snapshot.tab_bar_right.len().saturating_sub(1); + content.saturating_add( + display_width(&snapshot.tab_bar_right_separator) + .saturating_mul(separators.min(u16::MAX as usize) as u16), + ) +} + +fn tab_bar_status_area(snapshot: &ClientShellSnapshot, area: Rect) -> Option<Rect> { + let width = tab_bar_status_width(snapshot); + if width == 0 { + return None; + } + let reserved = width.saturating_add(1); + (area.width.saturating_sub(reserved) >= MIN_TAB_STRIP_WIDTH) + .then(|| Rect::new(area.right().saturating_sub(width), area.y, width, 1)) +} + +fn tab_bar_content_area(snapshot: &ClientShellSnapshot, area: Rect) -> Rect { + let reserved = tab_bar_status_area(snapshot, area) + .map(|status| status.width.saturating_add(1)) + .unwrap_or(0); + Rect { + width: area.width.saturating_sub(reserved), + ..area + } +} + +fn render_tab_bar_status( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + palette: &Palette, +) { + let Some(status) = tab_bar_status_area(snapshot, area) else { + return; + }; + let separator_width = display_width(&snapshot.tab_bar_right_separator); + let mut x = status.x; + for (index, segment) in snapshot.tab_bar_right.iter().enumerate() { + if index > 0 && separator_width > 0 { + put_text( + buffer, + x, + area.y, + separator_width, + &snapshot.tab_bar_right_separator, + Style::default().fg(palette.overlay0).bg(palette.panel_bg), + ); + x = x.saturating_add(separator_width); + } + let width = display_width(&segment.text); + let style = if segment.accent { + Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.overlay1).bg(palette.panel_bg) + }; + put_text(buffer, x, area.y, width, &segment.text, style); + x = x.saturating_add(width); + } +} + +fn tab_drop_indicator_x( + hits: &ShellHitMap, + tabs: &[&ClientShellTab], + insert_index: usize, +) -> Option<u16> { + let visible = hits + .tabs + .iter() + .filter_map(|(rect, tab_id)| { + tabs.iter() + .position(|tab| tab.tab_id == *tab_id) + .map(|index| (index, *rect)) + }) + .collect::<Vec<_>>(); + let (first_index, first_rect) = *visible.first()?; + let (last_index, last_rect) = *visible.last()?; + if insert_index == 0 { + return Some(if first_index == 0 { + first_rect.x + } else { + hits.tab_scroll_left.right() + }); + } + if let Some((_, rect)) = visible.iter().find(|(index, _)| *index == insert_index) { + return Some(rect.x.saturating_sub(1)); + } + if insert_index >= tabs.len() { + return Some(if last_index + 1 >= tabs.len() { + last_rect.right() + } else { + hits.tab_scroll_right.x.saturating_sub(1) + }); + } + None +} + +fn centered_tab_scroll(focused: usize, widths: &[u16], available: u16) -> usize { + let mut best = focused; + let mut best_distance = u16::MAX; + for start in 0..=focused { + let before = widths + .iter() + .copied() + .enumerate() + .skip(start) + .take(focused.saturating_sub(start)) + .fold(0u16, |width, (_, tab)| width.saturating_add(tab + 1)); + if before >= available { + continue; + } + let focused_width = widths[focused].min(available.saturating_sub(before)); + let center = before.saturating_mul(2).saturating_add(focused_width); + let distance = center.abs_diff(available); + if distance <= best_distance { + best_distance = distance; + best = start; + } + } + best +} + +fn max_tab_scroll(widths: &[u16], available: u16) -> usize { + (0..widths.len()) + .find(|start| last_visible_tab(*start, widths, available) == widths.len().checked_sub(1)) + .unwrap_or(0) +} + +fn last_visible_tab(start: usize, widths: &[u16], available: u16) -> Option<usize> { + let mut remaining = available; + let mut last = None; + for (index, width) in widths.iter().copied().enumerate().skip(start) { + if remaining == 0 { + break; + } + last = Some(index); + if width >= remaining { + break; + } + remaining = remaining.saturating_sub(width.saturating_add(1)); + } + last +} + +fn tab_label(tab: &ClientShellTab) -> String { + if tab.zoomed { + format!("{} Z", tab.label) + } else { + tab.label.clone() + } +} diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs new file mode 100644 index 00000000..0ee5aa3f --- /dev/null +++ b/src/client/shell/tests/agents_worktrees_notifications.rs @@ -0,0 +1,1248 @@ +use super::*; + +#[test] +fn mouse_hits_use_stable_workspace_tab_and_pane_ids() { + let config = ClientShellConfig::from_config(&Config::default()); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + + let workspace_down = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 2, + row: 2, + modifiers: KeyModifiers::empty(), + })]); + assert!(workspace_down.actions.is_empty()); + let workspace = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 2, + row: 2, + modifiers: KeyModifiers::empty(), + })]); + assert!(workspace.requests.is_empty()); + let [ClientShellAction::Endpoint { request, .. }] = &workspace.actions[..] else { + panic!("workspace click should use the endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_1" + )); + + let pane = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 27, + row: 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(pane.requests.is_empty()); + let [ClientShellAction::Endpoint { request, .. }] = &pane.actions[..] else { + panic!("pane click should use the endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + )); +} + +#[test] +fn collapsed_workspace_jitter_remains_a_click() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.sidebar_collapsed = true; + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("collapsed sidebar"); + let workspace = state.hits.workspaces[0].rect; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: workspace.x, + row: workspace.y, + modifiers: KeyModifiers::empty(), + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: workspace.x + 1, + row: workspace.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.chrome_drag.is_none()); + assert!(state.workspace_press.is_some()); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: workspace.x + 1, + row: workspace.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &release.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_1" + ) + )); +} + +#[test] +fn grouped_worktrees_render_parent_branch_and_indented_child() { + let config = ClientShellConfig::from_config(&Config::default()); + let mut state = ClientShellState::new(config); + let mut snapshot = snapshot(); + snapshot.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: false, + }); + snapshot.workspaces.push(ClientShellWorkspace { + workspace_id: "ws_2".into(), + active_tab_id: "tab_ws2".into(), + new_workspace_cwd: "/repo/feature".into(), + number: 2, + label: "repo-feature".into(), + custom_label: false, + branch: Some("worktree/feature".into()), + git_ahead_behind: None, + tokens: Vec::new(), + worktree: Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: true, + }), + focused: false, + agent_status: AgentStatus::Idle, + }); + state.set_snapshot(Box::new(snapshot)); + state.set_pane_surface(surface()); + let frame = state.compose(106, 20).expect("composed frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("main")); + assert!(text.contains("└─")); + assert!(text.contains("feature")); + + let mut replacement = (**state.snapshot.as_ref().expect("snapshot")).clone(); + replacement.revision = 2; + replacement.focused_workspace_id = Some("ws_2".into()); + replacement.workspaces[0].focused = false; + replacement.workspaces[1].focused = true; + replacement.workspaces[1].agent_status = AgentStatus::Blocked; + let mut replacement_surface = surface(); + replacement_surface.projection_revision = 2; + state.collapsed_groups.insert("repo".into()); + state.set_snapshot(Box::new(replacement)); + state.set_pane_surface(replacement_surface); + let collapsed = state.compose(106, 20).expect("collapsed worktree group"); + let parent = state.hits.workspaces[0].rect; + let status_cell = usize::from(parent.y) * usize::from(collapsed.width) + + usize::from(parent.x.saturating_add(1)); + assert_eq!( + collapsed.cells[status_cell].fg, + crate::protocol::color_to_u32(state.config.palette.red) + ); + + let mut next = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NextWorkspace), + &mut next, + ); + assert!(matches!( + &next.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_1" + ) + )); +} + +#[test] +fn workspace_click_waits_for_release_and_drag_reorders_by_stable_id() { + let mut projected = snapshot(); + for index in 2..=3 { + let mut workspace = projected.workspaces[0].clone(); + workspace.workspace_id = format!("ws_{index}"); + workspace.number = index; + workspace.label = format!("workspace-{index}"); + workspace.focused = false; + projected.workspaces.push(workspace); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 24).expect("three workspaces"); + let first = state.hits.workspaces[0].rect; + let third = state.hits.workspaces[2].rect; + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: first.x + 2, + row: first.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(down.actions.is_empty()); + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: third.x + 2, + row: third.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(drag.repaint); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::Workspace { + ref source_workspace_id, + target: Some((None, _)), + }) if source_workspace_id == "ws_1" + )); + let frame = state.compose(106, 24).expect("workspace drop indicator"); + assert!(frame + .cells + .chunks(frame.width as usize) + .any(|row| row.iter().take(20).any(|cell| cell.symbol == "─"))); + + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: third.x + 2, + row: third.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &release.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceMove(params) + if params.workspace_id == "ws_1" && params.insert_index == 3 + ) + )); + + state.compose(106, 24).expect("workspaces after drag"); + let second = state.hits.workspaces[1].rect; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: second.x + 2, + row: second.y, + modifiers: KeyModifiers::empty(), + })]); + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: second.x + 2, + row: second.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &click.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_2" + ) + )); +} + +#[test] +fn workspace_drag_moves_parent_worktree_as_one_block_and_rejects_child() { + let mut projected = snapshot(); + projected.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: false, + }); + let mut child = projected.workspaces[0].clone(); + child.workspace_id = "ws_child".into(); + child.number = 2; + child.label = "feature".into(); + child.focused = false; + child.worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: true, + }); + let mut other = projected.workspaces[0].clone(); + other.workspace_id = "ws_other".into(); + other.number = 3; + other.label = "other".into(); + other.focused = false; + other.worktree = None; + projected.workspaces.extend([child, other]); + + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 24).expect("worktree workspaces"); + assert!(state.hits.workspaces[1].indented); + let parent = state.hits.workspaces[0].rect; + let child = state.hits.workspaces[1].rect; + let other = state.hits.workspaces[2].rect; + + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: parent.x + 2, + row: parent.y, + modifiers: KeyModifiers::empty(), + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: other.x + 2, + row: other.bottom(), + modifiers: KeyModifiers::empty(), + })]); + let moved = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: other.x + 2, + row: other.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &moved.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceMoveBlock(params) + if params.workspace_ids == ["ws_1", "ws_child"] + && params.before_workspace_id.is_none() + ) + )); + + state.compose(106, 24).expect("worktree child"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: child.x + 2, + row: child.y, + modifiers: KeyModifiers::empty(), + })]); + let dragging_child = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: other.x + 2, + row: other.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(dragging_child.actions.is_empty()); + assert!(state.chrome_drag.is_none()); +} + +#[test] +fn pane_cycle_last_and_agent_actions_resolve_to_stable_pane_ids() { + let mut initial = snapshot(); + let mut second = initial.panes[0].clone(); + second.pane_id = "pane_2".into(); + second.focused = false; + initial.panes.push(second); + initial.agents = vec![ + ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("first".into()), + display_agent: None, + agent: None, + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }, + ClientShellAgent { + pane_id: "pane_2".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("second".into()), + display_agent: None, + agent: None, + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: 2, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }, + ]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(initial.clone())); + + let mut cycle = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CyclePaneNext), + &mut cycle, + ); + let [ClientShellAction::Endpoint { request, .. }] = &cycle.actions[..] else { + panic!("pane cycle should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + )); + + let mut replacement = initial; + replacement.revision = 2; + replacement.focused_pane_id = Some("pane_2".into()); + replacement.panes[0].focused = false; + replacement.panes[1].focused = true; + state.set_snapshot(Box::new(replacement)); + let mut last = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::LastPane), + &mut last, + ); + let [ClientShellAction::Endpoint { request, .. }] = &last.actions[..] else { + panic!("last pane should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + )); + + let mut agent = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(1)), + &mut agent, + ); + let [ClientShellAction::Endpoint { request, .. }] = &agent.actions[..] else { + panic!("agent focus should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + )); +} + +#[test] +fn agent_sidebar_honors_priority_symbols_tokens_and_stable_hits() { + let mut projected = snapshot(); + let mut second_pane = projected.panes[0].clone(); + second_pane.pane_id = "pane_2".into(); + second_pane.focused = false; + projected.panes.push(second_pane); + projected.agents = vec![ + ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("pi one".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: Some("first title".into()), + terminal_title_stripped: Some("first".into()), + agent_status: AgentStatus::Done, + state_change_seq: 10, + state_labels: Vec::new(), + tokens: vec![("summary".into(), "review complete".into())], + focused: true, + }, + ClientShellAgent { + pane_id: "pane_2".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("pi two".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: Some("second title".into()), + terminal_title_stripped: Some("second".into()), + agent_status: AgentStatus::Blocked, + state_change_seq: 20, + state_labels: vec![("blocked".into(), "needs input".into())], + tokens: vec![("summary".into(), "waiting for Can".into())], + focused: false, + }, + ]; + let mut config = Config::default(); + config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; + config.ui.status_indicators = crate::config::StatusIndicatorStyle::Symbols; + config.ui.sidebar.agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; + config.ui.sidebar.agents.rows_by_agent.insert( + "pi".into(), + vec![ + vec![ + crate::config::AgentSidebarToken::StateIcon, + crate::config::AgentSidebarToken::StateText, + ], + vec![ + crate::config::AgentSidebarToken::Agent, + crate::config::AgentSidebarToken::Custom("summary".into()), + ], + ], + ); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + + let frame = state.compose(106, 30).expect("agent sidebar frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("× needs input"), "frame: {text}"); + assert!(text.contains("pi two"), "frame: {text}"); + assert!(text.contains("waiting for"), "frame: {text}"); + assert_eq!( + state + .hits + .agents + .first() + .map(|(_, pane_id)| pane_id.as_str()), + Some("pane_2") + ); + + let first = state.hits.agents[0].0; + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: first.x, + row: first.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &click.actions[..] else { + panic!("agent row should focus through endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + )); + + state.compose(106, 10).expect("short agent sidebar frame"); + assert_eq!( + state + .hits + .agents + .first() + .map(|(_, pane_id)| pane_id.as_str()), + Some("pane_2") + ); + let body = state.hits.agent_body; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: body.x, + row: body.y, + modifiers: KeyModifiers::empty(), + })]); + state + .compose(106, 10) + .expect("scrolled agent sidebar frame"); + assert_eq!( + state + .hits + .agents + .first() + .map(|(_, pane_id)| pane_id.as_str()), + Some("pane_1") + ); + + state.sidebar_collapsed = true; + let compact = state.compose(106, 30).expect("compact agent sidebar frame"); + let blocked = state + .hits + .agents + .iter() + .find(|(_, pane_id)| pane_id == "pane_2") + .expect("blocked compact agent") + .0; + let row_start = blocked.y as usize * compact.width as usize + blocked.x as usize; + assert_ne!(compact.cells[row_start].fg, compact.cells[row_start + 2].fg); + assert_eq!(compact.cells[row_start].bg, compact.cells[row_start + 2].bg); +} + +#[test] +fn active_agent_view_controls_sidebar_order_and_focus_indices() { + let mut projected = snapshot(); + let mut second_pane = projected.panes[0].clone(); + second_pane.pane_id = "pane_2".into(); + second_pane.focused = false; + projected.panes.push(second_pane.clone()); + let mut third_pane = second_pane; + third_pane.pane_id = "pane_3".into(); + projected.panes.push(third_pane); + projected.agents = vec![ + ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("first".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }, + ClientShellAgent { + pane_id: "pane_2".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("second".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Blocked, + state_change_seq: 2, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }, + ClientShellAgent { + pane_id: "pane_3".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("third".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: 3, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }, + ]; + projected.agent_view_label = Some("review".into()); + projected.agent_order = vec!["pane_2".into(), "pane_3".into()]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("filtered agent sidebar"); + assert_eq!( + state + .hits + .agents + .iter() + .map(|(_, pane_id)| pane_id.as_str()) + .collect::<Vec<_>>(), + vec!["pane_2", "pane_3"] + ); + assert_eq!(state.hits.agent_sort_toggle, Rect::default()); + + let mut focus = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(0)), + &mut focus, + ); + assert!(matches!( + &focus.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + ) + )); + + let mut next = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NextAgent), + &mut next, + ); + assert!(matches!( + &next.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + ) + )); +} + +#[test] +fn agent_sort_toggle_is_client_local_and_persists_per_endpoint() { + let path = std::env::temp_dir().join(format!( + "herdr-shell-agent-sort-{}-{}.json", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + let mut projected = snapshot(); + projected.agents.push(ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("pi".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Working, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }); + let config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("agent sidebar frame"); + let toggle = state.hits.agent_sort_toggle; + + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: toggle.x, + row: toggle.y, + modifiers: KeyModifiers::empty(), + })]); + + assert_eq!( + state.config.agent_panel_sort, + crate::config::AgentPanelSortConfig::Priority + ); + assert!(click.actions.is_empty()); + let reloaded_config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let reloaded = ClientShellState::new(reloaded_config); + assert_eq!( + reloaded.config.agent_panel_sort, + crate::config::AgentPanelSortConfig::Priority + ); + assert!(reloaded.agent_panel_sort_manual); + std::fs::remove_file(path).expect("remove agent sort preferences"); +} + +#[test] +fn workspace_actions_preserve_selected_target_and_client_confirmation() { + let mut snapshot = snapshot(); + let mut second = snapshot.workspaces[0].clone(); + second.workspace_id = "ws_2".into(); + second.number = 2; + second.label = "second".into(); + second.focused = false; + snapshot.workspaces.push(second); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + state.mode = ClientShellMode::Navigate; + state.navigate_workspace_id = Some("ws_2".into()); + + let rename = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('w'), + KeyModifiers::SHIFT, + ))]); + assert!(rename.actions.is_empty()); + assert!(matches!( + state.overlay.as_ref(), + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::Workspace { workspace_id }, + .. + })) if workspace_id == "ws_2" + )); + assert!(state.handle_input_bytes(&[0x15]).actions.is_empty()); + assert!(state.handle_input_bytes(b"renamed").actions.is_empty()); + let save = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &save.actions[..] else { + panic!("workspace rename should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceRename(params) + if params.workspace_id == "ws_2" && params.label == "renamed" + )); + + state.navigate_workspace_id = Some("ws_2".into()); + let mut close = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CloseWorkspace), + &mut close, + ); + assert!(close.actions.is_empty()); + assert!(matches!( + state.overlay.as_ref(), + Some(ClientShellOverlay::ConfirmClose(ClientConfirmCloseOverlay { + workspace_id, + .. + })) if workspace_id == "ws_2" + )); + let confirm = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &confirm.actions[..] else { + panic!("workspace confirmation should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceClose(params) + if params.workspace_id == "ws_2" && params.close_group + )); +} + +#[test] +fn desktop_workspace_navigation_reveals_overflowing_selection() { + let mut projected = snapshot(); + for index in 2..=8 { + let mut workspace = projected.workspaces[0].clone(); + workspace.workspace_id = format!("ws_{index}"); + workspace.number = index; + workspace.label = format!("workspace-{index}"); + workspace.focused = false; + projected.workspaces.push(workspace); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + state.navigate_workspace_id = Some("ws_1".into()); + state.compose(106, 12).expect("overflowing sidebar"); + + for _ in 0..6 { + state.handle_input_bytes(b"\x1b[B"); + state.compose(106, 12).expect("revealed workspace"); + let selected = state.navigate_workspace_id.as_deref().expect("selection"); + assert!( + state + .hits + .workspaces + .iter() + .any(|hit| hit.workspace_id == selected), + "{selected} should remain visible" + ); + } +} + +#[test] +fn named_workspace_overlay_targets_projected_source_workspace() { + let mut config = Config::default(); + config.ui.prompt_new_workspace_name = true; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorkspace), + &mut open, + ); + assert!(matches!( + state.overlay.as_ref(), + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + input: value, + target: ClientRenameTarget::NewWorkspace { + source_workspace_id, + .. + }, + .. + })) if value == "repo" && source_workspace_id.as_deref() == Some("ws_1") + )); + let create = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &create.actions[..] else { + panic!("named workspace should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceCreate(params) + if params.source_workspace_id.as_deref() == Some("ws_1") + && params.cwd.as_deref() == Some("/repo") + && params.label.is_none() + )); +} + +#[test] +fn navigate_mode_selects_workspace_locally_then_focuses_by_stable_id() { + let mut snapshot = snapshot(); + let mut second = snapshot.workspaces[0].clone(); + second.workspace_id = "ws_2".into(); + second.number = 2; + second.label = "second".into(); + second.focused = false; + snapshot.workspaces.push(second); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + state.set_pane_surface(surface()); + + assert!(state.handle_input_bytes(&[0x02]).actions.is_empty()); + let enter_navigate = state.handle_input_bytes(b"w"); + assert!(enter_navigate.repaint); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_1")); + + let invalid = state.handle_input_bytes(b"9"); + assert!(invalid.actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_1")); + + let move_selection = state.handle_input_bytes(b"\x1b[B"); + assert!(move_selection.actions.is_empty()); + assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_2")); + let frame = state.compose(106, 20).expect("navigate frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("second")); + assert!(text.contains("NAVIGATE")); + + let focus = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &focus.actions[..] else { + panic!("selected workspace should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_2" + )); + assert_eq!(state.mode, ClientShellMode::Terminal); +} + +#[test] +fn worktree_create_previews_the_endpoint_owned_checkout_path() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut prepare = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorktree), + &mut prepare, + ); + let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else { + panic!("new worktree should prepare through worktree.list"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeList(params) + if params.workspace_id.as_deref() == Some("ws_1") + )); + let request_id = request.id.clone(); + assert!( + state + .handle_endpoint_result("boot-1", &request_id, Ok(worktree_list_result(None))) + .0 + ); + let frame = state.compose(106, 30).expect("new worktree modal"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("new worktree")); + assert!(text.contains("create and open")); + assert!(frame.cursor.as_ref().is_some_and(|cursor| cursor.visible)); + + assert!(state + .handle_input_bytes(b"feature/client-shell") + .actions + .is_empty()); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::WorktreeCreate(create)) + if create.checkout_path + == "/tmp/herdr-worktrees/repo/feature-client-shell" + )); + let submit = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &submit.actions[..] else { + panic!("worktree create should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeCreate(params) + if params.workspace_id.as_deref() == Some("ws_1") + && params.branch.as_deref() == Some("feature/client-shell") + && params.path.is_none() + && params.focus + )); +} + +#[test] +fn worktree_open_filters_and_clicks_a_stable_public_entry() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut prepare = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::OpenWorktree), + &mut prepare, + ); + let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else { + panic!("open worktree should prepare through worktree.list"); + }; + let request_id = request.id.clone(); + state.handle_endpoint_result("boot-1", &request_id, Ok(worktree_list_result(None))); + let frame = state.compose(106, 30).expect("open worktree modal"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("feature")); + let row = state.hits.worktree_rows[0].0; + let open = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: row.x + 2, + row: row.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &open.actions[..] else { + panic!("worktree row should open through endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeOpen(params) + if params.workspace_id.as_deref() == Some("ws_1") + && params.path.as_deref() == Some("/repo-feature") + && params.focus + )); +} + +#[test] +fn worktree_remove_escalates_dirty_failure_to_force_confirmation() { + let mut snapshot = snapshot(); + snapshot.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo-key".into(), + label: "repo".into(), + is_linked_worktree: true, + }); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + state.set_pane_surface(surface()); + let mut prepare = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::RemoveWorktree), + &mut prepare, + ); + let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else { + panic!("remove worktree should prepare through worktree.list"); + }; + let request_id = request.id.clone(); + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(worktree_list_result(Some("ws_1"))), + ); + let remove = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &remove.actions[..] else { + panic!("worktree remove should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeRemove(params) + if params.workspace_id == "ws_1" && !params.force + )); + let request_id = request.id.clone(); + state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("dirty_worktree_requires_force".into()), + message: "dirty worktree".into(), + }), + ); + let frame = state.compose(106, 30).expect("force remove modal"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("delete anyway")); + assert!(text.contains("permanently deleted")); + let force = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &force.actions[..] else { + panic!("forced worktree remove should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeRemove(params) + if params.workspace_id == "ws_1" && params.force + )); +} + +#[test] +fn semantic_notifications_use_client_policy_and_stable_navigation_targets() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.toast_delivery = crate::config::ToastDelivery::Herdr; + config.toast_delay_seconds = 0; + let mut state = ClientShellState::new(config); + let mut projected = snapshot(); + projected.agents.push(ClientShellAgent { + pane_id: "pane_2".into(), + workspace_id: "ws_2".into(), + tab_id: "tab_2".into(), + name: None, + display_agent: Some("codex".into()), + agent: Some("codex".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Blocked, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + let now = std::time::Instant::now(); + let (effects, repaint) = state.receive_notification( + SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "codex needs attention".into(), + body: Some("other · 2".into()), + sound: Some(SemanticNotificationSound::Request), + agent: Some("codex".into()), + workspace_id: Some("ws_2".into()), + tab_id: Some("tab_2".into()), + pane_id: Some("pane_2".into()), + position: None, + }, + now, + ); + assert!(repaint); + assert!(matches!( + effects.as_slice(), + [ClientShellNotificationEffect::Sound { + sound: crate::sound::Sound::Request, + .. + }] + )); + let frame = state.compose(100, 28).expect("notification frame"); + let rendered = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(rendered.contains("codex needs attention")); + let hit = state.hits.notification_toast; + let click = || { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: hit.x, + row: hit.y, + modifiers: KeyModifiers::empty(), + }) + }; + state.mode = ClientShellMode::Navigate; + let ignored = state.handle_raw_events(vec![click()]); + assert!(ignored.actions.is_empty()); + assert!(state.visible_notification.is_some()); + + state.mode = ClientShellMode::Terminal; + let outcome = state.handle_raw_events(vec![click()]); + assert!(outcome.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(params) + if params.pane_id == "pane_2" + ) + ))); + assert!(state.visible_notification.is_none()); + + state.receive_notification( + SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "codex needs attention".into(), + body: None, + sound: None, + agent: Some("codex".into()), + workspace_id: Some("ws_2".into()), + tab_id: Some("tab_2".into()), + pane_id: Some("pane_2".into()), + position: None, + }, + now, + ); + let mut keybind = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::OpenNotificationTarget), + &mut keybind, + ); + assert!(keybind.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(params) + if params.pane_id == "pane_2" + ) + ))); + assert!(state.visible_notification.is_none()); + + state.receive_notification( + SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "first".into(), + body: None, + sound: None, + agent: Some("codex".into()), + workspace_id: Some("ws_2".into()), + tab_id: Some("tab_2".into()), + pane_id: Some("pane_2".into()), + position: None, + }, + now, + ); + assert!(state.visible_notification.is_some()); + state.config.toast_delay_seconds = 1; + let (_, repaint) = state.receive_notification( + SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "replacement".into(), + body: None, + sound: None, + agent: Some("codex".into()), + workspace_id: Some("ws_2".into()), + tab_id: Some("tab_2".into()), + pane_id: Some("pane_2".into()), + position: None, + }, + now, + ); + assert!(repaint); + assert!(state.visible_notification.is_none()); + assert_eq!(state.pending_notifications.len(), 1); +} diff --git a/src/client/shell/tests/chrome_context.rs b/src/client/shell/tests/chrome_context.rs new file mode 100644 index 00000000..eeb272c2 --- /dev/null +++ b/src/client/shell/tests/chrome_context.rs @@ -0,0 +1,339 @@ +use super::*; + +#[test] +fn tab_overflow_controls_scroll_the_client_owned_tab_bar() { + let mut snapshot = snapshot(); + snapshot.tabs.extend((2..=8).map(|number| ClientShellTab { + tab_id: format!("tab_{number}"), + workspace_id: "ws_1".into(), + number, + label: number.to_string(), + custom_label: false, + zoomed: false, + focused: false, + agent_status: AgentStatus::Idle, + })); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + state.set_pane_surface(surface()); + state.compose(80, 20).expect("overflow tab bar"); + + assert!(state.hits.tab_scroll_right.width > 0); + let scroll_right = state.hits.tab_scroll_right; + let outcome = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: scroll_right.x + 1, + row: scroll_right.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(outcome.repaint); + assert_eq!(state.tab_scroll, 1); + + let mut update = state.snapshot.as_deref().expect("snapshot").clone(); + update.focused_tab_id = Some("tab_8".into()); + for tab in &mut update.tabs { + tab.focused = tab.tab_id == "tab_8"; + } + state.set_snapshot(Box::new(update)); + state.compose(80, 20).expect("focused overflow tab"); + assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_8")); + + state.compose(300, 20).expect("tabs without overflow"); + assert_eq!(state.tab_scroll, 0); + assert_eq!(state.hits.tabs.len(), 8); + state.compose(80, 20).expect("focused tab after narrowing"); + assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_8")); +} + +#[test] +fn client_owned_sidebar_dividers_resize_live() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("expanded sidebar"); + let workspace_body = state.hits.workspace_body; + let needless_scroll = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: workspace_body.x, + row: workspace_body.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.hits.workspace_max_scroll, 0); + assert_eq!(state.workspace_scroll, 0); + assert!(!needless_scroll.repaint); + let width_divider = state.hits.sidebar_divider; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: width_divider.x, + row: width_divider.y + 2, + modifiers: KeyModifiers::empty(), + })]); + let resize = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 31, + row: width_divider.y + 2, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.sidebar_width, 32); + assert!(state.sidebar_width_manual); + assert!(resize.repaint); + assert!(resize.resize); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 31, + row: width_divider.y + 2, + modifiers: KeyModifiers::empty(), + })]); + + state.set_pane_surface(surface()); + state.compose(106, 30).expect("resized sidebar"); + let section_divider = state.hits.sidebar_section_divider; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: section_divider.x + 2, + row: section_divider.y, + modifiers: KeyModifiers::empty(), + })]); + let split = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: section_divider.x + 2, + row: 20, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.sidebar_section_split > 0.6); + assert!(split.repaint); + assert!(!split.resize); +} + +#[test] +fn context_menus_capture_stable_targets_and_route_actions() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + + let workspace = state.hits.workspaces[0].rect; + let open_workspace_menu = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: workspace.x + 2, + row: workspace.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(open_workspace_menu.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + target: ClientContextMenuTarget::Workspace { ref workspace_id, .. }, + .. + })) if workspace_id == "ws_1" + )); + let workspace_items = match state.overlay.as_ref() { + Some(ClientShellOverlay::ContextMenu(menu)) => menu.items(), + _ => panic!("workspace context menu"), + }; + assert!(workspace_items + .iter() + .any(|item| item.action == ClientContextMenuAction::NewWorktree)); + state.compose(106, 20).expect("workspace context menu"); + let rename = state.hits.context_menu_rows[0].0; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rename.x + 1, + row: rename.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::Workspace { ref workspace_id }, + .. + })) if workspace_id == "ws_1" + )); + + state.overlay = None; + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].rect; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: pane.x + 1, + row: pane.y, + modifiers: KeyModifiers::empty(), + })]); + state.compose(106, 20).expect("pane context menu"); + let split_index = match state.overlay.as_ref() { + Some(ClientShellOverlay::ContextMenu(menu)) => menu + .items() + .iter() + .position(|item| item.action == ClientContextMenuAction::SplitRight) + .expect("split right item"), + _ => panic!("pane context menu"), + }; + let split = state.hits.context_menu_rows[split_index].0; + let outcome = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: split.x + 1, + row: split.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("pane split context action should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneSplit(params) + if params.target_pane_id.as_deref() == Some("pane_1") + && params.direction == crate::api::schema::SplitDirection::Right + )); +} + +#[test] +fn global_menu_opens_from_sidebar_and_routes_client_actions() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("shell frame"); + let launcher = state.hits.global_launcher; + assert_ne!(launcher, Rect::default()); + + let open = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: launcher.x, + row: launcher.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(open.repaint); + let menu = state.compose(106, 30).expect("global menu"); + let text = menu + .cells + .chunks(menu.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("settings")); + assert!(text.contains("keybinds")); + assert!(text.contains("reload config")); + assert!(text.contains("detach")); + + let keybinds = state.hits.global_menu_rows[1].0; + let help = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: keybinds.x, + row: keybinds.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(help.actions.is_empty()); + assert!(matches!(state.overlay, Some(ClientShellOverlay::Help(_)))); + + state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 3, + })); + let detach = state.handle_input_bytes(b"\r"); + assert!(detach.detach); + assert!(state.overlay.is_none()); +} + +#[test] +fn new_tab_overlay_owns_text_cursor_and_submits_public_api_request() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewTab), + &mut open, + ); + assert!(open.actions.is_empty()); + let frame = state.compose(106, 20).expect("new tab overlay"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("new tab")); + assert!(text.contains("save")); + let restored = frame.to_ratatui_buffer().expect("overlay frame"); + assert!(!restored + .cell((26, 7)) + .expect("overlay title cell") + .modifier + .contains(Modifier::DIM)); + assert!(frame.cursor.as_ref().is_some_and(|cursor| cursor.visible)); + + assert!(state.handle_input_bytes(b"logs").actions.is_empty()); + let create = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &create.actions[..] else { + panic!("new tab save should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::TabCreate(params) + if params.workspace_id.as_deref() == Some("ws_1") + && params.label.as_deref() == Some("logs") + )); + assert!(state.overlay.is_none()); +} + +#[test] +fn close_confirmation_error_becomes_client_owned_overlay_and_stable_group_close() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut close = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::ClosePane), + &mut close, + ); + let [ClientShellAction::Endpoint { request, .. }] = &close.actions[..] else { + panic!("pane close should use endpoint API"); + }; + let request_id = request.id.clone(); + assert!( + state + .handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("confirmation_required".into()), + message: "confirmation required".into(), + }), + ) + .0 + ); + let frame = state.compose(106, 20).expect("confirmation overlay"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("Close workspace?")); + assert!(text.contains("1 pane")); + + let confirm = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &confirm.actions[..] else { + panic!("confirmation should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceClose(params) + if params.workspace_id == "ws_1" && params.close_group + )); +} diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs new file mode 100644 index 00000000..64cc78e6 --- /dev/null +++ b/src/client/shell/tests/copy.rs @@ -0,0 +1,1190 @@ +use super::*; + +#[test] +fn pasted_help_and_copy_queries_strip_control_characters() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay { + query: String::new(), + search_focused: true, + scroll: 0, + })); + + assert!(state.insert_overlay_text("work\nspace")); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { ref query, .. })) + if query == "workspace" + )); + + state.overlay = None; + state.copy_mode = Some(ClientCopyModeState { + pane_id: "pane_1".into(), + content_revision: 0, + geometry: (80, 24), + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + offset_from_bottom: 0, + max_offset_from_bottom: 0, + entry_offset_from_bottom: 0, + selection: None, + search_prompt: Some(ClientCopySearchPrompt { + direction: crate::api::schema::PaneCopySearchDirection::Forward, + query: String::new(), + }), + search_query: String::new(), + search_direction: None, + search_matches: Vec::new(), + search_total: 0, + search_current: None, + search_current_global: None, + search_generation: 0, + copy_after_search: false, + }); + + assert!(state.insert_copy_search_text("needle\r\n")); + assert_eq!( + state + .copy_mode + .as_ref() + .and_then(|copy_mode| copy_mode.search_prompt.as_ref()) + .map(|prompt| prompt.query.as_str()), + Some("needle") + ); +} + +#[test] +fn client_mouse_selection_highlights_and_copies_through_endpoint_extraction() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &down.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + ) + )); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| !selection.is_visible())); + + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(drag.repaint); + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible)); + let selected = state.compose(106, 20).expect("selected frame"); + let selected_cell = + &selected.cells[usize::from(pane.inner_rect.y) * 106 + usize::from(pane.inner_rect.x)]; + assert_ne!( + selected_cell.bg, + crate::protocol::color_to_u32(ratatui::style::Color::Reset) + ); + + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.selection.is_none()); + let [ClientShellAction::Endpoint { request, .. }] = &release.actions[..] else { + panic!("selection release should request endpoint extraction"); + }; + let request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneSelectionRead(params) + if params.pane_id == "pane_1" + && params.anchor == crate::api::schema::PaneTextPoint { row: 0, col: 0 } + && params.cursor == crate::api::schema::PaneTextPoint { row: 0, col: 2 } + )); + + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: "LIV".into(), + }), + ); + assert!(repaint); + assert!(matches!( + &actions[..], + [ClientShellAction::ClipboardWrite(bytes)] if bytes == b"LIV" + )); + assert_eq!( + state + .copy_feedback + .as_ref() + .map(|feedback| feedback.message.as_str()), + Some("copied to clipboard") + ); +} + +#[test] +fn clipboard_feedback_is_client_local_and_respects_config() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let now = std::time::Instant::now(); + assert!(state.show_copy_feedback(now)); + assert_eq!( + state + .copy_feedback + .as_ref() + .map(|feedback| feedback.message.as_str()), + Some("copied to clipboard") + ); + assert_eq!( + state.copy_feedback_deadline, + Some(now + std::time::Duration::from_secs(2)) + ); + + state.config.clipboard_toast_enabled = false; + state.copy_feedback = None; + state.copy_feedback_deadline = None; + assert!(!state.show_copy_feedback(now)); + assert!(state.copy_feedback.is_none()); + assert!(state.copy_feedback_deadline.is_none()); +} + +#[test] +fn retained_mouse_selection_copies_only_on_exact_copy_shortcut() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.config.copy_on_select = false; + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + for event in [ + crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + }, + crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + }, + crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + }, + ] { + state.handle_raw_events(vec![RawInputEvent::Mouse(event)]); + } + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_finalized)); + + let copy = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + ))]); + assert!(state.selection.is_none()); + assert!(matches!( + ©.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) + )); + assert!(copy.requests.is_empty()); + let request_id = match ©.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + let (_, fallback) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: String::new(), + }), + ); + assert!(matches!( + &fallback[..], + [ClientShellAction::Request(ClientMessage::ClientShellPaneInput { + pane_id, + events, + })] if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('c'), + kind: crate::protocol::ClientKeyKind::Press, + .. + }, ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('c'), + kind: crate::protocol::ClientKeyKind::Release, + .. + }] + ) + )); +} + +#[test] +fn selection_edge_drag_requests_scroll_and_timer_continues_it() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y.saturating_sub(1), + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &drag.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 3 + ) + )); + let drag_request_id = match &drag.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + let now = std::time::Instant::now(); + state.selection_autoscroll_deadline = Some(now); + let tick = state.tick_selection_autoscroll(now); + assert!(tick.actions.is_empty()); + let (_, next_scroll) = + state.handle_endpoint_result("boot-1", &drag_request_id, Ok(pane_scroll_result(3, 20, 3))); + assert!(matches!( + &next_scroll[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 4 + ) + )); +} + +#[test] +fn keyboard_copy_mode_owns_cursor_selection_copy_and_scroll_restore() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + assert_eq!(state.mode, ClientShellMode::Copy); + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.row), + Some(21) + ); + assert!(enter.actions.is_empty()); + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('b'), + KeyModifiers::CONTROL, + ))]); + assert_eq!(state.mode, ClientShellMode::Prefix); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Copy); + + let page = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::PageUp, + KeyModifiers::empty(), + ))]); + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.row), + Some(20) + ); + assert!(matches!( + &page.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 1 + ) + )); + let page_request_id = match &page.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + + let top = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('g'), + KeyModifiers::empty(), + ))]); + assert!(top.actions.is_empty()); + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.row), + Some(0) + ); + let (_, top_actions) = + state.handle_endpoint_result("boot-1", &page_request_id, Ok(pane_scroll_result(1, 20, 2))); + let [ClientShellAction::Endpoint { request, .. }] = &top_actions[..] else { + panic!("latest queued scroll should follow the completed request"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.pane_id == "pane_1" && params.offset_from_bottom == 20 + )); + let top_request_id = request.id.clone(); + state.handle_endpoint_result("boot-1", &top_request_id, Ok(pane_scroll_result(20, 20, 2))); + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('v'), + KeyModifiers::empty(), + ))]); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('l'), + KeyModifiers::empty(), + ))]); + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible)); + + let copy = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('y'), + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.copy_mode.is_none()); + assert!(state.selection.is_none()); + assert_eq!(copy.actions.len(), 2); + assert!(copy.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) + ))); + assert!(copy.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 0 + ) + ))); +} + +#[test] +fn keyboard_copy_mode_content_motion_is_endpoint_backed_and_stale_safe() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let origin = state.copy_mode.as_ref().expect("copy mode").cursor; + + let motion = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('w'), + KeyModifiers::empty(), + ))]); + let [ClientShellAction::Endpoint { request, .. }] = &motion.actions[..] else { + panic!("word motion should use endpoint semantics"); + }; + let request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneCopyMotion(params) + if params.cursor == origin + && params.motion == crate::api::schema::PaneCopyMotion::NextWordStart + )); + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { + pane_id: "pane_1".into(), + cursor: crate::api::schema::PaneTextPoint { + row: origin.row, + col: 3, + }, + content_revision: 0, + }), + ); + assert!(repaint); + assert!(actions.is_empty()); + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.col), + Some(3) + ); +} + +#[test] +fn copy_search_owns_prompt_repeat_highlights_selection_and_restore() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let origin = state.copy_mode.as_ref().expect("copy mode").cursor; + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('?'), + KeyModifiers::SHIFT, + ))]); + assert!(state.copy_mode.as_ref().is_some_and(|mode| { + mode.search_prompt.as_ref().is_some_and(|prompt| { + prompt.direction == crate::api::schema::PaneCopySearchDirection::Backward + }) + })); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert!(state + .copy_mode + .as_ref() + .is_some_and(|mode| mode.search_prompt.is_none())); + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('/'), + KeyModifiers::empty(), + ))]); + state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "junk", + ))]); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('u'), + KeyModifiers::CONTROL, + ))]); + state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "nee", + ))]); + state.handle_raw_events(vec![RawInputEvent::Paste("dleX".into())]); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Backspace, + KeyModifiers::empty(), + ))]); + assert_eq!( + state + .copy_mode + .as_ref() + .and_then(|mode| mode.search_prompt.as_ref()) + .map(|prompt| prompt.query.as_str()), + Some("needle") + ); + + let search = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Enter, + KeyModifiers::empty(), + ))]); + let [ClientShellAction::Endpoint { request, .. }] = &search.actions[..] else { + panic!("search should use endpoint terminal semantics"); + }; + let request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneCopySearch(params) + if params.pane_id == "pane_1" + && params.query == "needle" + && params.direction == crate::api::schema::PaneCopySearchDirection::Forward + && params.cursor == origin + && params.previous.is_none() + )); + let matches = vec![ + crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 5, col: 2 }, + end: crate::api::schema::PaneTextPoint { row: 5, col: 7 }, + }, + crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 15, col: 1 }, + end: crate::api::schema::PaneTextPoint { row: 15, col: 6 }, + }, + ]; + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(copy_search_result(matches.clone(), Some(0))), + ); + assert!(repaint); + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.row), + Some(5) + ); + assert!(actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 15 + ) + ))); + let initial_scroll_id = actions + .iter() + .find_map(|action| match action { + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneScroll(_)) => + { + Some(request.id.clone()) + } + _ => None, + }) + .expect("initial search scroll"); + state.handle_endpoint_result( + "boot-1", + &initial_scroll_id, + Ok(pane_scroll_result(15, 20, 2)), + ); + let mut scrolled_surface = state.pane_surface.clone().expect("pane surface"); + scrolled_surface.panes[0] + .scroll + .as_mut() + .expect("scroll metrics") + .offset_from_bottom = 15; + state.set_pane_surface(scrolled_surface); + let frame = state.compose(106, 20).expect("search frame"); + let hit = state.hits.panes[0].clone(); + let viewport_top = 5u16; + let restored = frame.to_ratatui_buffer().expect("search frame buffer"); + let highlighted = restored + .cell((hit.inner_rect.x + 2, hit.inner_rect.y + (5 - viewport_top))) + .expect("highlighted search cell"); + assert_eq!(highlighted.bg, state.config.palette.accent); + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('v'), + KeyModifiers::empty(), + ))]); + let repeat = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('n'), + KeyModifiers::empty(), + ))]); + let [ClientShellAction::Endpoint { request, .. }] = &repeat.actions[..] else { + panic!("repeat should use endpoint search"); + }; + let repeat_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneCopySearch(params) + if params.direction == crate::api::schema::PaneCopySearchDirection::Forward + && params.previous == Some(matches[0]) + )); + let (_, repeat_actions) = state.handle_endpoint_result( + "boot-1", + &repeat_id, + Ok(copy_search_result(matches.clone(), Some(1))), + ); + if let Some(scroll_id) = repeat_actions.iter().find_map(|action| match action { + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneScroll(_)) => + { + Some(request.id.clone()) + } + _ => None, + }) { + state.handle_endpoint_result("boot-1", &scroll_id, Ok(pane_scroll_result(6, 20, 2))); + } + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.row), + Some(15) + ); + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible)); + + let reverse = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Char('N'), KeyModifiers::SHIFT), + )]); + let [ClientShellAction::Endpoint { request, .. }] = &reverse.actions[..] else { + panic!("reverse search should use endpoint search"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneCopySearch(params) + if params.direction == crate::api::schema::PaneCopySearchDirection::Backward + && params.previous == Some(matches[1]) + )); + let (_, reverse_actions) = state.handle_endpoint_result( + "boot-1", + &request.id, + Ok(copy_search_result(matches.clone(), Some(0))), + ); + if let Some(scroll_id) = reverse_actions.iter().find_map(|action| match action { + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneScroll(_)) => + { + Some(request.id.clone()) + } + _ => None, + }) { + state.handle_endpoint_result("boot-1", &scroll_id, Ok(pane_scroll_result(15, 20, 2))); + } + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state + .copy_mode + .as_ref() + .is_some_and(|mode| mode.search_query.is_empty() && mode.selection.is_none())); + let exit = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(exit.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 0 + ) + ))); +} + +#[test] +fn navigator_owns_search_mouse_selection_and_stable_target_focus() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::OpenNavigator), + &mut open, + ); + let navigator = state.compose(106, 30).expect("navigator overlay"); + let navigator_text = navigator + .cells + .chunks(navigator.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(navigator_text.contains("client-shell")); + assert!(navigator_text.contains("pane 1")); + + let search = state.hits.navigator_search; + let focus_search = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: search.x, + row: search.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(focus_search.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { + search_focused: true, + .. + })) + )); + assert!(state.handle_input_bytes(b"client").actions.is_empty()); + let filtered = state.compose(106, 30).expect("filtered navigator"); + assert!(filtered + .cursor + .as_ref() + .is_some_and(|cursor| cursor.visible)); + + state.handle_input_bytes(b"\x1b"); + state.handle_input_bytes(b"a"); + state.compose(106, 30).expect("navigator rows"); + let pane_index = { + let snapshot = state.snapshot.as_deref().expect("snapshot"); + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + render::client_navigator_rows(snapshot, navigator) + .iter() + .position(|row| matches!(row.target, ClientNavigatorTarget::Pane(_))) + .expect("pane row") + }; + let pane_rect = state + .hits + .navigator_rows + .iter() + .find(|(_, index)| *index == pane_index) + .map(|(rect, _)| *rect) + .expect("visible pane row"); + let select = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Moved, + column: pane_rect.x + 6, + row: pane_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(select.repaint); + let accept = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane_rect.x + 6, + row: pane_rect.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &accept.actions[..] else { + panic!("navigator pane click should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + )); + assert!(state.overlay.is_none()); +} + +#[test] +fn copy_mode_survives_mouse_motion_and_parks_across_focus_changes() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 10, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Moved, + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state.copy_mode.is_some()); + + state.handle_input_bytes(b"v"); + assert!(state + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.selection.is_some())); + + let mut unfocused = snapshot(); + unfocused.focused_pane_id = Some("pane_2".into()); + unfocused.panes[0].focused = false; + unfocused.panes.push(ClientShellPane { + pane_id: "pane_2".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + label: None, + cwd: Some("/repo".into()), + foreground_cwd: Some("/repo".into()), + focused: true, + right_click_passthrough: false, + }); + state.set_snapshot(Box::new(unfocused.clone())); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.selection.is_some())); + + let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix; + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + prefix_key, + prefix_modifiers, + ))]); + state.set_snapshot(Box::new(unfocused.clone())); + assert_eq!(state.mode, ClientShellMode::Prefix); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Terminal); + + let mut other_selection = + crate::selection::Selection::absolute_range("pane_2".to_owned(), (0, 0), (0, 1)); + assert!(other_selection.finish()); + state.selection = Some(other_selection); + state.set_snapshot(Box::new(unfocused)); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == "pane_2")); + + state.set_snapshot(Box::new(snapshot())); + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state.copy_mode.is_some()); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == "pane_1")); + state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "ignored", + ))]); + state.handle_raw_events(vec![RawInputEvent::Paste("ignored".into())]); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == "pane_1")); + + state.mode = ClientShellMode::Navigate; + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == "pane_1")); + state.mode = ClientShellMode::Resize; + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == "pane_1")); +} + +#[test] +fn retained_selection_copy_suppresses_key_repeats() { + let mut config = Config::default(); + config.ui.copy_on_select = false; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut selection = + crate::selection::Selection::absolute_range("pane_1".to_owned(), (0, 0), (0, 1)); + assert!(selection.finish()); + state.selection = Some(selection); + + let key = crate::input::TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + let press = state.handle_raw_events(vec![RawInputEvent::Key(key.clone())]); + assert!(press.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) + ))); + let repeat = state.handle_raw_events(vec![RawInputEvent::Key( + key.with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(repeat.actions.is_empty()); + assert!(repeat.requests.is_empty()); +} + +#[test] +fn rapid_copy_motions_are_chained_from_the_previous_result() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let origin = state.copy_mode.as_ref().expect("copy mode").cursor; + + let first = state.handle_input_bytes(b"w"); + let second = state.handle_input_bytes(b"w"); + assert_eq!(first.actions.len(), 1); + assert!(second.actions.is_empty()); + let first_id = match &first.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + let intermediate = crate::api::schema::PaneTextPoint { + row: origin.row, + col: 2, + }; + let (_, follow_up) = state.handle_endpoint_result( + "boot-1", + &first_id, + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { + pane_id: "pane_1".into(), + cursor: intermediate, + content_revision: 0, + }), + ); + assert!(matches!( + &follow_up[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneCopyMotion(params) + if params.cursor == intermediate + ) + )); +} + +#[test] +fn queued_copy_keys_preserve_prefix_order() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 10, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let origin = state.copy_mode.as_ref().expect("copy mode").cursor; + let motion = state.handle_input_bytes(b"w"); + state.handle_input_bytes(b"l"); + let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix; + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + prefix_key, + prefix_modifiers, + ))]); + let motion_id = match &motion.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + state.handle_endpoint_result( + "boot-1", + &motion_id, + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { + pane_id: "pane_1".into(), + cursor: origin, + content_revision: 0, + }), + ); + assert_eq!(state.mode, ClientShellMode::Prefix); + assert_eq!( + state + .copy_mode + .as_ref() + .map(|copy_mode| copy_mode.cursor.col), + Some(origin.col.saturating_add(1)) + ); +} + +#[test] +fn reentering_copy_mode_on_the_same_pane_is_a_no_op() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 10, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut first = ClientShellInput::default(); + assert!(state.enter_copy_mode(&mut first)); + state + .copy_mode + .as_mut() + .expect("copy mode") + .offset_from_bottom = 10; + let mut reenter = ClientShellInput::default(); + assert!(state.enter_copy_mode(&mut reenter)); + assert!(reenter.actions.is_empty()); + assert_eq!( + state + .copy_mode + .as_ref() + .map(|copy_mode| copy_mode.entry_offset_from_bottom), + Some(0) + ); +} + +#[test] +fn copy_waits_for_endpoint_motion_before_copying_selection() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + state.handle_input_bytes(b"v"); + let origin = state.copy_mode.as_ref().expect("copy mode").cursor; + let motion = state.handle_input_bytes(b"w"); + let queued_copy = state.handle_input_bytes(b"y"); + assert!(queued_copy.actions.is_empty()); + let motion_id = match &motion.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + let target = crate::api::schema::PaneTextPoint { + row: origin.row, + col: 2, + }; + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &motion_id, + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { + pane_id: "pane_1".into(), + cursor: target, + content_revision: 0, + }), + ); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor == origin && params.cursor == target + ) + ))); +} + +#[test] +fn new_content_revision_invalidates_copy_search_coordinates() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface.clone()); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let copy_mode = state.copy_mode.as_mut().expect("copy mode"); + copy_mode.search_query = "needle".into(); + copy_mode + .search_matches + .push(crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + end: crate::api::schema::PaneTextPoint { row: 0, col: 1 }, + }); + copy_mode.search_total = 1; + copy_mode.search_current = Some(0); + copy_mode.search_current_global = Some(0); + + pane_surface.panes[0].content_revision = 2; + state.set_pane_surface(pane_surface); + let copy_mode = state.copy_mode.as_ref().expect("copy mode retained"); + assert!(copy_mode.search_matches.is_empty()); + assert_eq!(copy_mode.search_total, 0); + assert_eq!(copy_mode.search_current, None); +} + +#[test] +fn word_selection_result_survives_focus_snapshot_lag() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + let hit = state.hits.panes[0].clone(); + let mut request = ClientShellInput::default(); + state.request_word_selection(&hit, 0, 1, &mut request); + let request_id = match &request.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + let mut lagging = snapshot(); + lagging.focused_pane_id = None; + lagging.panes[0].focused = false; + state.set_snapshot(Box::new(lagging)); + let (repaint, _) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: "hello world".into(), + }), + ); + assert!(repaint); + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible)); +} diff --git a/src/client/shell/tests/input.rs b/src/client/shell/tests/input.rs new file mode 100644 index 00000000..bc72b01a --- /dev/null +++ b/src/client/shell/tests/input.rs @@ -0,0 +1,609 @@ +use super::*; + +#[test] +fn host_appearance_prefers_explicit_reports_over_background_inference() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.config.theme_runtime.auto_switch = true; + + let light = crate::app::client_palette_for_appearance( + &state.config.theme_runtime, + crate::terminal_theme::HostAppearance::Light, + ); + let dark = crate::app::client_palette_for_appearance( + &state.config.theme_runtime, + crate::terminal_theme::HostAppearance::Dark, + ); + + let inferred = state.handle_raw_events(vec![RawInputEvent::HostDefaultColor { + kind: crate::terminal_theme::DefaultColorKind::Background, + color: crate::terminal_theme::RgbColor { + r: 255, + g: 255, + b: 255, + }, + }]); + assert!(inferred.repaint); + assert!(matches!( + inferred.requests.as_slice(), + [ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::DefaultColor { + kind: crate::protocol::ClientHostDefaultColorKind::Background, + .. + } + }] + )); + assert_eq!( + state.host_appearance, + Some(crate::terminal_theme::HostAppearance::Light) + ); + assert!(!state.host_appearance_explicit); + assert_eq!(state.config.palette, light); + + let explicit = state.handle_raw_events(vec![RawInputEvent::HostColorSchemeChanged( + crate::terminal_theme::HostAppearance::Dark, + )]); + assert!(explicit.repaint); + assert!(explicit.query_host_theme); + assert!(matches!( + explicit.requests.as_slice(), + [ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::Appearance( + crate::protocol::ClientHostAppearance::Dark + ) + }] + )); + assert_eq!( + state.host_appearance, + Some(crate::terminal_theme::HostAppearance::Dark) + ); + assert!(state.host_appearance_explicit); + assert_eq!(state.config.palette, dark); + + let ignored = state.handle_raw_events(vec![RawInputEvent::HostDefaultColor { + kind: crate::terminal_theme::DefaultColorKind::Background, + color: crate::terminal_theme::RgbColor { + r: 255, + g: 255, + b: 255, + }, + }]); + assert!(!ignored.repaint); + assert_eq!(ignored.requests.len(), 1); + assert_eq!( + state.host_appearance, + Some(crate::terminal_theme::HostAppearance::Dark) + ); + assert_eq!(state.config.palette, dark); +} + +#[test] +fn modal_paste_shortcut_modifiers_are_platform_specific() { + let key = |code, modifiers| crate::input::TerminalKey::new(code, modifiers); + + assert!(input::is_modal_paste_shortcut_for_platform( + &key(KeyCode::Char('v'), KeyModifiers::CONTROL), + false + )); + assert!(input::is_modal_paste_shortcut_for_platform( + &key( + KeyCode::Char('V'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT + ), + false + )); + assert!(!input::is_modal_paste_shortcut_for_platform( + &key(KeyCode::Char('v'), KeyModifiers::SUPER), + false + )); + assert!(input::is_modal_paste_shortcut_for_platform( + &key(KeyCode::Char('v'), KeyModifiers::CONTROL), + true + )); + assert!(input::is_modal_paste_shortcut_for_platform( + &key(KeyCode::Char('v'), KeyModifiers::SUPER), + true + )); + assert!(!input::is_modal_paste_shortcut_for_platform( + &key(KeyCode::Char('v'), KeyModifiers::ALT), + true + )); +} + +#[test] +fn modal_paste_inserts_clipboard_text_through_overlay_text_path() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename pane", + input: "replace me".into(), + replace_on_type: true, + target: ClientRenameTarget::Pane { + pane_id: "pane_1".into(), + }, + })); + let mut outcome = ClientShellInput::default(); + let key = crate::input::TerminalKey::new(KeyCode::Char('v'), KeyModifiers::CONTROL); + + assert!( + state.handle_modal_paste_shortcut_with(&key, &mut outcome, || { + Some("feature/pasted".into()) + }) + ); + assert!(outcome.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { ref input, replace_on_type: false, .. })) + if input == "feature/pasted" + )); +} + +#[test] +fn client_shell_graphics_follow_final_shell_origin_and_local_overlay_visibility() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + let key = crate::protocol::SurfaceGraphicsAssetKey { + source: crate::protocol::SurfaceGraphicsSource::Terminal { + target: crate::protocol::SurfaceGraphicsTarget::Pane { + pane_id: "pane_1".into(), + }, + image_id: 1, + }, + image_width: 1, + image_height: 1, + format: crate::protocol::SurfaceGraphicsFormat::Rgba, + data_len: 4, + data_fingerprint: 17, + }; + pane_surface.graphics = crate::protocol::SurfaceGraphicsScene { + assets: vec![crate::protocol::SurfaceGraphicsAsset { + key: key.clone(), + data: vec![1, 2, 3, 4], + }], + placements: vec![crate::protocol::SurfaceGraphicsPlacement { + asset: key, + logical_placement_id: 1, + x: 0, + y: 0, + cols: 1, + rows: 1, + source_x: 0, + source_y: 0, + source_width: 1, + source_height: 1, + x_offset: 0, + y_offset: 0, + z: 0, + scrollback_offset: 0, + }], + retained_assets: Vec::new(), + }; + state.set_pane_surface(pane_surface); + + let visible = state.compose(106, 20).expect("visible graphics frame"); + let visible = String::from_utf8_lossy(&visible.graphics); + assert!(visible.contains("a=t,t=d")); + assert!(visible.contains("\u{1b}[2;27H")); + + state.overlay = Some(ClientShellOverlay::Onboarding); + let hidden = state.compose(106, 20).expect("overlay frame"); + assert!(String::from_utf8_lossy(&hidden.graphics).contains("a=d,d=i")); + + state.overlay = None; + let restored = state.compose(106, 20).expect("restored graphics frame"); + let restored = String::from_utf8_lossy(&restored.graphics); + assert!(restored.contains("a=p")); + assert!(!restored.contains("a=t,t=d")); +} + +#[test] +fn delayed_link_fallback_does_not_replay_against_changed_geometry() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + let down = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::CONTROL, + }; + let activate = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]); + let request_id = match &activate.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + _ => panic!("expected link activation request"), + }; + state.hits.panes[0].inner_rect.x = state.hits.panes[0].inner_rect.x.saturating_add(1); + + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + url: None, + handled: false, + }), + ); + + assert!(actions.is_empty()); + assert!(state.url_click_consumes_until_up); +} + +#[test] +fn invalid_experimental_reload_keeps_input_source_preference() { + let mut shell = ClientShellConfig::from_config(&Config::default()); + shell.switch_ascii_input_source_in_prefix = true; + let config = Config::default(); + shell.apply_live_config(&config, &[], &["experimental".to_owned()]); + assert!(shell.switch_ascii_input_source_in_prefix); + shell.apply_live_config(&config, &[], &[]); + assert!(!shell.switch_ascii_input_source_in_prefix); +} + +#[test] +fn physical_release_uses_the_leased_press_code_with_current_modifiers() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let press = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x58, + virtual_scan_code: 0x2d, + unicode: 0, + control_key_state: 0, + }); + state.handle_raw_events(vec![RawInputEvent::Key(press)]); + let release = crate::input::TerminalKey::new(KeyCode::Char('z'), KeyModifiers::SHIFT) + .with_kind(crossterm::event::KeyEventKind::Release) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: false, + repeat_count: 1, + virtual_key_code: 0x5a, + virtual_scan_code: 0x2d, + unicode: 0, + control_key_state: 0x0010, + }); + + let outcome = state.handle_raw_events(vec![RawInputEvent::Key(release)]); + + assert!(matches!( + &outcome.requests[..], + [ClientMessage::ClientShellPaneInput { events, .. }] + if matches!( + &events[..], + [ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers, + kind: crate::protocol::ClientKeyKind::Release, + physical_key_id: Some(0x2d), + .. + }] if *modifiers == KeyModifiers::SHIFT.bits() + ) + )); +} + +#[test] +fn highlighted_search_match_copies_after_in_flight_repeat() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let matches = vec![ + crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 5, col: 2 }, + end: crate::api::schema::PaneTextPoint { row: 5, col: 7 }, + }, + crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 15, col: 1 }, + end: crate::api::schema::PaneTextPoint { row: 15, col: 6 }, + }, + ]; + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('/'), + KeyModifiers::empty(), + ))]); + state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "needle", + ))]); + let initial = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Enter, KeyModifiers::empty()), + )]); + let [ClientShellAction::Endpoint { request, .. }] = &initial.actions[..] else { + panic!("initial search request"); + }; + state.handle_endpoint_result( + "boot-1", + &request.id, + Ok(copy_search_result(matches.clone(), Some(0))), + ); + let repeat = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('n'), + KeyModifiers::empty(), + ))]); + let [ClientShellAction::Endpoint { request, .. }] = &repeat.actions[..] else { + panic!("repeat search request"); + }; + let repeat_id = request.id.clone(); + + let early_copy = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty()), + )]); + assert!(early_copy.actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Copy); + + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &repeat_id, + Ok(copy_search_result(matches, Some(1))), + ); + assert_eq!(state.mode, ClientShellMode::Terminal); + let selection_request_id = actions + .iter() + .find_map(|action| match action { + ClientShellAction::Endpoint { request, .. } + if matches!( + request.method, + crate::api::schema::Method::PaneSelectionRead(_) + ) => + { + Some(request.id.clone()) + } + _ => None, + }) + .expect("deferred selection read"); + let (_, clipboard) = state.handle_endpoint_result( + "boot-1", + &selection_request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: "needle".into(), + }), + ); + assert!(matches!( + &clipboard[..], + [ClientShellAction::ClipboardWrite(bytes)] if bytes == b"needle" + )); +} + +#[test] +fn pixel_host_reports_use_cells_without_target_pixel_mode_and_release_outside() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + let geometry = + crate::input::mouse::HostGeometry::new(106, 20, 1060, 400).expect("host geometry"); + let x = u32::from(pane.inner_rect.x) * 10 + 21; + let y = u32::from(pane.inner_rect.y) * 20 + 21; + + let down = state.handle_pixel_mouse(format!("\x1b[<0;{x};{y}M").as_bytes(), geometry); + assert!(matches!( + &down.requests[..], + [ClientMessage::ClientShellPaneInput { events, .. }] + if matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + position: ClientMousePosition::Cell { column: 2, row: 1 }, + .. + }] + ) + )); + + state.hits.panes.clear(); + let release = state.handle_pixel_mouse(b"\x1b[<0;1;1m", geometry); + assert!(matches!( + &release.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + position: ClientMousePosition::Cell { .. }, + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn shell_targets_unconsumed_input_and_keeps_prefix_local() { + let config = ClientShellConfig::from_config(&Config::default()); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + + let text = state.handle_input_bytes(b"hello"); + assert_eq!(text.requests.len(), 1); + let ClientMessage::ClientShellPaneInput { pane_id, events } = &text.requests[0] else { + panic!("expected targeted pane input"); + }; + assert_eq!(pane_id, "pane_1"); + assert_eq!(events.len(), 5); + assert!(matches!( + &events[0], + ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('h'), + generated_text: Some(text), + .. + } if text == "h" + )); + + let interrupt = state.handle_input_bytes(b"\x1b[99;5u"); + assert_eq!(interrupt.requests.len(), 1); + let ClientMessage::ClientShellPaneInput { events, .. } = &interrupt.requests[0] else { + panic!("expected semantic interrupt"); + }; + assert!(matches!( + &events[..], + [ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('c'), + modifiers, + kind: crate::protocol::ClientKeyKind::Press, + .. + }] if *modifiers == KeyModifiers::CONTROL.bits() + )); + + let alt = state.handle_input_bytes(b"\x1b[120;3u"); + let ClientMessage::ClientShellPaneInput { events, .. } = &alt.requests[0] else { + panic!("expected semantic alt key"); + }; + assert!(matches!( + &events[..], + [ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers, + .. + }] if *modifiers == KeyModifiers::ALT.bits() + )); + assert!(!state.handle_input_bytes(&[0x02]).detach); + let detach = state.handle_input_bytes(b"q"); + assert!(detach.detach); + assert!(detach.requests.is_empty()); +} + +#[test] +fn pane_key_release_keeps_the_press_target() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + + let press = state.handle_input_bytes(b"\x1b[99;5u"); + let release = state.handle_input_bytes(b"\x1b[99;5:3u"); + let ClientMessage::ClientShellPaneInput { + pane_id: press_target, + .. + } = &press.requests[0] + else { + panic!("expected targeted press"); + }; + let ClientMessage::ClientShellPaneInput { + pane_id: release_target, + events, + } = &release.requests[0] + else { + panic!("expected targeted release"); + }; + assert_eq!(release_target, press_target); + assert!(matches!( + &events[..], + [ClientPaneInputEvent::Key { + kind: crate::protocol::ClientKeyKind::Release, + .. + }] + )); +} + +#[test] +fn help_overlay_uses_live_keymap_and_owns_filter_state() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::Help), + &mut open, + ); + let initial = state.compose(106, 30).expect("help overlay"); + let text = initial + .cells + .chunks(initial.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("keybinds")); + assert!(text.contains("prefix mode")); + + assert!(state.handle_input_bytes(b"/").actions.is_empty()); + assert!(state.handle_input_bytes(b"workspace").actions.is_empty()); + let filtered = state.compose(106, 30).expect("filtered help"); + let text = filtered + .cells + .chunks(filtered.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("workspace navigation")); + assert!(!text.contains("prefix mode")); + assert!(filtered + .cursor + .as_ref() + .is_some_and(|cursor| cursor.visible)); + + assert!(state.handle_input_bytes(b"\x1b").repaint); + assert!(matches!(state.overlay, Some(ClientShellOverlay::Help(_)))); + assert!(state.handle_input_bytes(b"\x1b").repaint); + assert!(state.overlay.is_none()); +} + +#[test] +fn rename_pane_empty_value_is_preserved_as_a_clear_request() { + let mut snapshot = snapshot(); + snapshot.panes[0].label = Some("build".into()); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::RenamePane), + &mut open, + ); + assert!(state.handle_input_bytes(&[0x15]).actions.is_empty()); + let save = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &save.actions[..] else { + panic!("pane rename should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneRename(params) + if params.pane_id == "pane_1" && params.label.as_deref() == Some("") + )); +} + +#[test] +fn styled_client_composition_preserves_pane_hyperlinks() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + let linked = Buffer::with_lines(["LIVE", "PANE"]); + pane_surface.frame = FrameData::from_ratatui_buffer_with_hyperlinks( + &linked, + None, + &[((0, 0), "L".into(), "https://example.test".into())], + ); + state.set_pane_surface(pane_surface); + let mut selection = + crate::selection::Selection::absolute_range("pane_1".to_owned(), (0, 0), (0, 1)); + assert!(selection.finish()); + state.selection = Some(selection); + let frame = state.compose(106, 20).expect("composed frame"); + let hit = &state.hits.panes[0]; + let index = + usize::from(hit.inner_rect.y) * usize::from(frame.width) + usize::from(hit.inner_rect.x); + let link = frame.cells[index].hyperlink.expect("linked cell") as usize; + assert_eq!(frame.hyperlinks[link], "https://example.test"); +} diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs new file mode 100644 index 00000000..5d2d8243 --- /dev/null +++ b/src/client/shell/tests/keybindings_settings.rs @@ -0,0 +1,633 @@ +use super::*; + +#[test] +fn shell_new_controls_use_the_same_client_action_routes_as_keybinds() { + let mut config = Config::default(); + config.ui.prompt_new_workspace_name = false; + config.ui.prompt_new_tab_name = true; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + + let new_workspace = state.hits.new_workspace; + let create_workspace = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: new_workspace.x + 1, + row: new_workspace.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &create_workspace.actions[..] else { + panic!("new workspace click should use the endpoint API"); + }; + assert!(matches!( + request.method, + crate::api::schema::Method::WorkspaceCreate(_) + )); + + let new_tab = state.hits.new_tab; + let open_new_tab = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: new_tab.x + 1, + row: new_tab.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(open_new_tab.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::NewTab { .. }, + .. + })) + )); +} + +#[test] +fn manual_client_chrome_preferences_round_trip_per_endpoint() { + let path = std::env::temp_dir().join(format!( + "herdr-client-shell-prefs-{}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let mut state = ClientShellState::new(config); + state.sidebar_width = 31; + state.sidebar_width_manual = true; + state.sidebar_section_split = 0.7; + state.sidebar_section_split_manual = true; + state.sidebar_collapsed = true; + state.sidebar_collapsed_manual = true; + state.collapsed_groups.insert("repo-two".into()); + state.collapsed_groups.insert("repo-one".into()); + state.persist_chrome_preferences(&mut ClientShellInput::default()); + + let reloaded_config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let reloaded = ClientShellState::new(reloaded_config); + assert_eq!(reloaded.sidebar_width, 31); + assert!(reloaded.sidebar_width_manual); + assert_eq!(reloaded.sidebar_section_split, 0.7); + assert!(reloaded.sidebar_section_split_manual); + assert!(reloaded.sidebar_collapsed); + assert!(reloaded.sidebar_collapsed_manual); + assert_eq!( + reloaded.collapsed_groups, + HashSet::from(["repo-one".to_string(), "repo-two".to_string()]) + ); + std::fs::remove_file(path).expect("remove client chrome preferences"); +} + +#[test] +fn tab_bar_renders_endpoint_status_ellipses_and_clamps_to_useful_scroll() { + let mut projected = snapshot(); + projected.tab_bar_right = vec![ + crate::protocol::ClientShellTabStatusSegment { + text: "ZOOM".into(), + accent: true, + }, + crate::protocol::ClientShellTabStatusSegment { + text: "host".into(), + accent: false, + }, + ]; + projected.tab_bar_right_separator = " · ".into(); + for number in 2..=8 { + projected.tabs.push(ClientShellTab { + tab_id: format!("tab_{number}"), + workspace_id: "ws_1".into(), + number, + label: number.to_string(), + custom_label: false, + zoomed: false, + focused: false, + agent_status: AgentStatus::Idle, + }); + } + let mut config = ClientShellConfig::from_config(&Config::default()); + config.mobile_width_threshold = 0; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + let frame = state.compose(106, 20).expect("status and overflow tabs"); + let top = frame.cells[..frame.width as usize] + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(top.contains("ZOOM · host")); + assert!(top.contains('…')); + + state.tab_scroll = usize::MAX; + state.reveal_focused_tab = false; + state.compose(106, 20).expect("clamped tab scroll"); + assert!(state.tab_scroll < 7); + let manual_scroll = state.tab_scroll; + let mut replacement = (**state.snapshot.as_ref().expect("snapshot")).clone(); + replacement.revision = 2; + replacement.tab_bar_right[1].text = "tick".into(); + let mut replacement_surface = surface(); + replacement_surface.projection_revision = 2; + state.set_snapshot(Box::new(replacement)); + state.set_pane_surface(replacement_surface); + assert!(!state.reveal_focused_tab); + state.compose(106, 20).expect("same-width status update"); + assert_eq!(state.tab_scroll, manual_scroll); + + state.compose(45, 20).expect("narrow tabs win over status"); + let narrow = state.compose(45, 20).expect("narrow tab frame"); + let top = narrow.cells[..narrow.width as usize] + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(!top.contains("ZOOM · host")); +} + +#[test] +fn configured_prefix_is_client_owned_and_renders_its_bar() { + let config = toml::from_str::<Config>( + r#" +[keys] +prefix = "ctrl+a" +detach = "prefix+x" +"#, + ) + .expect("configured keybinds"); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + let old_default = state.handle_input_bytes(&[0x02]); + assert_eq!( + old_default.requests.len(), + 1, + "ctrl-b should reach the pane" + ); + + let prefix = state.handle_input_bytes(&[0x01]); + assert!(prefix.requests.is_empty()); + assert!(prefix.repaint); + let frame = state.compose(106, 20).expect("prefix frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("PREFIX"), "frame: {text:?}"); + assert!(text.contains("ctrl+a"), "frame: {text:?}"); + + let detach = state.handle_input_bytes(b"x"); + assert!(detach.detach); + assert!(detach.requests.is_empty()); +} + +#[test] +fn prefix_endpoint_action_uses_public_api_with_stable_ids() { + let mut config = Config::default(); + config.ui.prompt_new_tab_name = false; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + + assert!(state.handle_input_bytes(&[0x02]).actions.is_empty()); + let create = state.handle_input_bytes(b"c"); + let [ClientShellAction::Endpoint { boot_id, request }] = &create.actions[..] else { + panic!("expected one endpoint action: {:?}", create.actions); + }; + assert_eq!(boot_id, "boot-1"); + match &request.method { + crate::api::schema::Method::TabCreate(params) => { + assert_eq!(params.workspace_id.as_deref(), Some("ws_1")); + assert!(params.focus); + } + other => panic!("expected tab.create, got {other:?}"), + } + assert!(state.pending_requests.contains_key(&request.id)); +} + +#[test] +fn remote_keybinding_sources_keep_local_commands_off_endpoints_and_apply_server_profiles() { + let local: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+a" +new_tab = "prefix+c" + +[[keys.command]] +key = "prefix+c" +command = "local-only" +"#, + ) + .unwrap(); + let remote_local = ClientShellConfig::from_config(&local) + .with_keybinding_source(ClientShellKeybindingSource::RemoteLocal); + assert_eq!(remote_local.keybinds.prefix.0, KeyCode::Char('a')); + assert!(remote_local.keybinds.keybinds.custom_commands.is_empty()); + assert_eq!( + remote_local.keybinds.keybinds.new_tab.label().as_deref(), + Some("prefix+c") + ); + + let mut local_state = ClientShellState::new( + ClientShellConfig::from_config(&local) + .with_keybinding_source(ClientShellKeybindingSource::Local), + ); + let mut local_projection = snapshot(); + local_projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_loaded_endpoint".into(), + binding_label: "prefix+c / prefix+y".into(), + binding_labels: vec!["prefix+c".into(), "prefix+y".into()], + action: crate::protocol::ClientShellCommandAction::Shell, + description: Some("loaded endpoint command".into()), + }); + local_state.set_snapshot(Box::new(local_projection)); + assert_eq!( + local_state.config.keybinds.keybinds.custom_commands[0].label, + "prefix+y" + ); + assert_eq!( + local_state + .config + .keybinds + .keybinds + .new_tab + .label() + .as_deref(), + Some("prefix+c") + ); + let mut command_outcome = ClientShellInput::default(); + local_state.record_binding( + crate::input::KeybindMatch::Command( + local_state.config.keybinds.keybinds.custom_commands[0].clone(), + ), + &mut command_outcome, + ); + let [ClientShellAction::Endpoint { request, .. }] = &command_outcome.actions[..] else { + panic!("expected surviving endpoint command binding"); + }; + let crate::api::schema::Method::CommandInvoke(params) = &request.method else { + panic!("expected command invocation"); + }; + assert_eq!(params.command_id, "cmd_loaded_endpoint"); + + let mut id_only_projection = snapshot(); + id_only_projection.revision = 2; + id_only_projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_reloaded_endpoint".into(), + binding_label: "prefix+c / prefix+y".into(), + binding_labels: vec!["prefix+c".into(), "prefix+y".into()], + action: crate::protocol::ClientShellCommandAction::Shell, + description: Some("loaded endpoint command".into()), + }); + local_state.mode = ClientShellMode::Prefix; + local_state.set_snapshot(Box::new(id_only_projection)); + assert_eq!(local_state.mode, ClientShellMode::Prefix); + assert_eq!( + local_state.config.keybinds.keybinds.custom_commands[0].command, + "cmd_reloaded_endpoint" + ); + + let endpoint: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+x" +new_tab = "prefix+n" +"#, + ) + .unwrap(); + let mut state = ClientShellState::new( + ClientShellConfig::from_config(&local) + .with_keybinding_source(ClientShellKeybindingSource::Endpoint), + ); + let mut projection = snapshot(); + projection.server_keybindings_toml = endpoint.local_keybindings_profile_toml().ok(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_remote".into(), + binding_label: "prefix+z".into(), + binding_labels: vec!["prefix+z".into()], + action: crate::protocol::ClientShellCommandAction::Shell, + description: Some("remote command".into()), + }); + state.set_snapshot(Box::new(projection)); + + assert_eq!(state.config.keybinds.prefix.0, KeyCode::Char('x')); + assert_eq!( + state.config.keybinds.keybinds.new_tab.label().as_deref(), + Some("prefix+n") + ); + assert_eq!( + state.config.keybinds.keybinds.custom_commands[0].label, + "prefix+z" + ); + assert_eq!( + state.config.keybinds.keybinds.custom_commands[0] + .description + .as_deref(), + Some("remote command") + ); + assert_eq!( + state.config.keybinds.keybinds.custom_commands[0].command, + "cmd_remote" + ); +} + +#[test] +fn custom_binding_invokes_only_the_endpoint_manifest_id() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("z"), + label: "prefix+z".into(), + command: "secret-command --token hidden".into(), + action: crate::config::CustomCommandAction::Shell, + description: None, + width: None, + height: None, + }; + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_0123456789abcdef0123456789abcdef".into(), + binding_label: binding.label.clone(), + binding_labels: binding.bindings.labels(), + action: crate::protocol::ClientShellCommandAction::Shell, + description: None, + }); + state.set_snapshot(Box::new(projection)); + + let mut outcome = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome); + + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("expected endpoint command invocation"); + }; + let crate::api::schema::Method::CommandInvoke(params) = &request.method else { + panic!("expected command.invoke"); + }; + assert_eq!(params.command_id, "cmd_0123456789abcdef0123456789abcdef"); + assert_eq!(params.workspace_id.as_deref(), Some("ws_1")); + assert_eq!(params.tab_id.as_deref(), Some("tab_1")); + assert_eq!(params.pane_id.as_deref(), Some("pane_1")); + assert_eq!(params.selection, None); + assert!(!serde_json::to_string(request) + .unwrap() + .contains("secret-command")); +} + +#[test] +fn plugin_command_carries_client_owned_selection_coordinates() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("p"), + label: "prefix+p".into(), + command: "plugin.action".into(), + action: crate::config::CustomCommandAction::PluginAction, + description: None, + width: None, + height: None, + }; + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_plugin".into(), + binding_label: binding.label.clone(), + binding_labels: binding.bindings.labels(), + action: crate::protocol::ClientShellCommandAction::PluginAction, + description: None, + }); + state.set_snapshot(Box::new(projection)); + let mut pane_surface = surface(); + pane_surface.panes[0].content_revision = 42; + state.set_pane_surface(pane_surface); + let mut selection = + crate::selection::Selection::absolute_range("pane_1".to_owned(), (2, 3), (4, 5)); + assert!(selection.finish()); + state.selection = Some(selection); + + let mut outcome = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome); + + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("expected endpoint command invocation"); + }; + let crate::api::schema::Method::CommandInvoke(params) = &request.method else { + panic!("expected command.invoke"); + }; + assert_eq!( + params.selection, + Some(crate::api::schema::PaneSelectionReadParams { + pane_id: "pane_1".into(), + anchor: crate::api::schema::PaneTextPoint { row: 2, col: 3 }, + cursor: crate::api::schema::PaneTextPoint { row: 4, col: 5 }, + content_revision: Some(42), + }) + ); +} + +#[test] +fn generic_endpoint_failures_and_control_errors_are_visible() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut outcome = ClientShellInput::default(); + state.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: "missing".into(), + }), + &mut outcome, + ); + let request_id = match &outcome.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + other => panic!("expected generic endpoint request, got {other:?}"), + }; + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("not_found".into()), + message: "workspace no longer exists".into(), + }), + ); + assert!(repaint); + assert!(actions.is_empty()); + assert_eq!( + state.endpoint_error.as_deref(), + Some("workspace no longer exists") + ); + + assert!(state.receive_endpoint_error("Paste rejected: too large".into())); + assert_eq!( + state.endpoint_error.as_deref(), + Some("Paste rejected: too large") + ); + assert!(!state.receive_endpoint_error("Paste rejected: too large".into())); +} + +#[test] +fn custom_binding_missing_from_endpoint_manifest_is_not_forwarded() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("z"), + label: "prefix+z".into(), + command: "secret-command".into(), + action: crate::config::CustomCommandAction::Shell, + description: None, + width: None, + height: None, + }; + + let mut outcome = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome); + + assert!(outcome.actions.is_empty()); + assert!(outcome.repaint); + assert!(state + .endpoint_error + .as_deref() + .is_some_and(|error| error.contains("not available"))); +} + +#[test] +fn help_overlay_restores_released_search_scroll_and_custom_binding_behavior() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "plugin-action".into(), + binding_label: "prefix+z".into(), + binding_labels: vec!["prefix+z".into()], + action: crate::protocol::ClientShellCommandAction::PluginAction, + description: Some("run plugin action".into()), + }); + state.set_snapshot(Box::new(projection)); + state.set_pane_surface(surface()); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::Help), + &mut open, + ); + let initial = state.compose(106, 30).expect("help overlay"); + let text = initial + .cells + .chunks(initial.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("global")); + assert!(state.hits.help_max_scroll > 0); + assert_ne!(state.hits.help_scrollbar, Rect::default()); + + state.handle_input_bytes(b"/"); + state.handle_input_bytes(b"plugin"); + let custom = state.compose(106, 30).expect("custom help search"); + let text = custom + .cells + .chunks(custom.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("custom")); + assert!(text.contains("run plugin action")); + state.handle_input_bytes(b"\x1b"); + + state.handle_input_bytes(b"/"); + state.handle_input_bytes(b"does-not-exist"); + let empty = state.compose(106, 30).expect("empty help search"); + let text = empty + .cells + .chunks(empty.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("no matching keybinds")); + + state.handle_input_bytes(b"\x1b"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { + search_focused: false, + ref query, + scroll: 0, + })) if query.is_empty() + )); + state.compose(106, 30).expect("restored help"); + state.handle_input_bytes(b"\x1b[F"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { scroll, .. })) + if scroll == state.hits.help_max_scroll + )); + state.handle_input_bytes(b"\x1b[H"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { + scroll: 0, + .. + })) + )); + state.handle_input_bytes(b"?"); + assert!(state.overlay.is_none()); +} + +#[test] +fn resize_mode_reuses_endpoint_resize_and_stays_active_until_done() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + assert!(state.handle_input_bytes(&[0x02]).actions.is_empty()); + assert!(state.handle_input_bytes(b"r").actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Resize); + + let modified = state.handle_input_bytes(b"\x1b[1;2D"); + assert!(matches!( + &modified.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneResize(params) + if params.direction == crate::api::schema::PaneDirection::Left + ) + )); + assert_eq!(state.mode, ClientShellMode::Resize); + + let resize = state.handle_input_bytes(b"h"); + let [ClientShellAction::Endpoint { request, .. }] = &resize.actions[..] else { + panic!("resize should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneResize(params) + if params.pane_id.as_deref() == Some("pane_1") + && params.direction == crate::api::schema::PaneDirection::Left + )); + assert_eq!(state.mode, ClientShellMode::Resize); + + assert!(state.handle_input_bytes(b"\r").actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Terminal); +} diff --git a/src/client/shell/tests/mobile.rs b/src/client/shell/tests/mobile.rs new file mode 100644 index 00000000..094b141f --- /dev/null +++ b/src/client/shell/tests/mobile.rs @@ -0,0 +1,559 @@ +use super::*; + +#[test] +fn navigate_update_status_uses_released_desktop_and_mobile_placement() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.tab_bar_position = crate::config::TabBarPositionConfig::Bottom; + config.hide_tab_bar_when_single_tab = false; + let mut state = ClientShellState::new(config); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.update_available = Some("0.8.3".into()); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + + let bottom = state.compose(106, 30).expect("bottom-tab update shell"); + let row_text = |frame: &FrameData, row: u16| { + let width = usize::from(frame.width); + let start = usize::from(row) * width; + frame.cells[start..start + width] + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }; + assert!(row_text(&bottom, 29).contains("update ready")); + assert!(!row_text(&bottom, 28).contains("update ready")); + assert!(state.hits.tabs.is_empty()); + assert!(state.hits.new_tab.is_empty()); + assert!(state.hits.tab_scroll_left.is_empty()); + assert!(state.hits.tab_scroll_right.is_empty()); + + state.config.tab_bar_position = crate::config::TabBarPositionConfig::Top; + state.visible_notification = Some(ClientVisibleNotification { + event: SemanticNotification { + kind: SemanticNotificationKind::Custom, + title: "bottom notification".into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: Some(crate::config::ToastHerdrPosition::BottomRight), + }, + deadline: std::time::Instant::now(), + }); + let top = state.compose(106, 30).expect("top-tab update shell"); + assert!(row_text(&top, 29).contains("update ready")); + + let mobile = state.compose(44, 30).expect("mobile update shell"); + let mobile_text = mobile + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(mobile_text.contains("update ready")); +} + +#[test] +fn mobile_layout_reserves_only_client_header() { + let config = ClientShellConfig::from_config(&Config::default()); + let state = ClientShellState::new(config); + let layout = state.layout(44, 20); + assert_eq!(layout.mobile_header, Rect::new(0, 0, 44, 2)); + assert_eq!(layout.pane_surface, Rect::new(0, 2, 44, 18)); + assert_eq!( + state.surface_size(44, 20), + ClientSurfaceSize { cols: 44, rows: 18 } + ); +} + +#[test] +fn mobile_shell_controls_remain_clickable_when_pane_mouse_capture_is_disabled() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.mouse_capture = false; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(44, 20).expect("mobile header"); + assert!(!state.hits.mobile_switch.is_empty()); + let switch = state.hits.mobile_switch; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: switch.x, + row: switch.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.mode, ClientShellMode::Navigate); + state.compose(44, 20).expect("mobile switcher"); + assert!(!state.hits.mobile_close.is_empty()); + assert!(!state.hits.mobile_targets.is_empty()); +} + +#[test] +fn mobile_header_and_switcher_render_released_sections_and_stable_targets() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut projected = snapshot(); + projected.agents.push(ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("pi".into()), + display_agent: Some("pi".into()), + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Blocked, + state_change_seq: 1, + state_labels: vec![("blocked".into(), "waiting".into())], + tokens: Vec::new(), + focused: true, + }); + projected.workspaces[0].agent_status = AgentStatus::Blocked; + state.set_snapshot(Box::new(projected)); + let mut projected_surface = surface(); + for cell in &mut projected_surface.frame.cells { + cell.symbol = "X".to_owned(); + } + state.set_pane_surface(projected_surface); + + let header = state.compose(44, 20).expect("mobile header"); + let header_text = header + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(header_text.contains("client-shell")); + assert!(header_text.contains("tab 1")); + assert!(header_text.contains("blocked")); + assert!(header_text.contains("switch")); + assert_eq!(state.hits.mobile_switch, Rect::new(34, 0, 10, 2)); + + let click = |rect: Rect| { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x, + row: rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + let opened = state.handle_raw_events(vec![click(state.hits.mobile_switch)]); + assert!(opened.repaint); + assert_eq!(state.mode, ClientShellMode::Navigate); + let switcher = state.compose(44, 20).expect("mobile switcher"); + let switcher_text = switcher + .cells + .chunks(switcher.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!( + !switcher_text.contains('X'), + "switcher must clear the pane surface" + ); + for expected in [ + "switch", + "close", + "agents", + "spaces", + "+ new workspace", + "tabs", + "+ new tab", + "menu", + "settings", + "detach", + ] { + assert!(switcher_text.contains(expected), "missing {expected}"); + } + let workspace_hit = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_1").then_some(*rect) + }) + .expect("workspace hit"); + let focused = state.handle_raw_events(vec![click(workspace_hit)]); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.navigate_workspace_id.is_none()); + assert!(focused.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(params) + if params.workspace_id == "ws_1" + ) + ))); + + state.compose(44, 20).expect("restored mobile header"); + state.handle_raw_events(vec![click(state.hits.mobile_switch)]); + state.compose(44, 20).expect("agent switcher"); + let agent_hit = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Agent(id) if id == "pane_1").then_some(*rect) + }) + .expect("agent hit"); + let focused = state.handle_raw_events(vec![click(agent_hit)]); + assert!(focused.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(params) + if params.pane_id == "pane_1" + ) + ))); + + state.compose(44, 20).expect("restored mobile header"); + state.handle_raw_events(vec![click(state.hits.mobile_switch)]); + state.compose(44, 20).expect("tab switcher"); + let tab_hit = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Tab(id) if id == "tab_1").then_some(*rect) + }) + .expect("tab hit"); + let focused = state.handle_raw_events(vec![click(tab_hit)]); + assert!(focused.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::TabFocus(params) + if params.tab_id == "tab_1" + ) + ))); +} + +#[test] +fn mobile_background_workspace_uses_its_own_active_tab_status() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut projected = snapshot(); + projected.tabs.push(ClientShellTab { + tab_id: "tab_7".into(), + workspace_id: "ws_1".into(), + number: 7, + label: "logs".into(), + custom_label: true, + zoomed: false, + focused: false, + agent_status: AgentStatus::Idle, + }); + projected.workspaces.push(ClientShellWorkspace { + workspace_id: "ws_2".into(), + active_tab_id: "tab_3".into(), + new_workspace_cwd: "/feature".into(), + number: 2, + label: "background".into(), + custom_label: true, + branch: Some("feature".into()), + git_ahead_behind: None, + tokens: Vec::new(), + worktree: None, + focused: false, + agent_status: AgentStatus::Idle, + }); + for (number, tab_id, label) in [(1, "tab_2", "one"), (7, "tab_3", "two")] { + projected.tabs.push(ClientShellTab { + tab_id: tab_id.into(), + workspace_id: "ws_2".into(), + number, + label: label.into(), + custom_label: true, + zoomed: false, + focused: false, + agent_status: AgentStatus::Idle, + }); + } + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + state.navigate_workspace_id = Some("ws_2".into()); + let frame = state.compose(44, 20).expect("mobile switcher"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("feature · tab two · 2/2"), "{text}"); + assert!(text.contains("2 · logs"), "{text}"); + assert!(!text.contains("7 · logs"), "{text}"); +} + +#[test] +fn mobile_switcher_create_and_menu_rows_reuse_client_actions() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.prompt_new_workspace_name = true; + config.prompt_new_tab_name = true; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let click = |rect: Rect| { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x, + row: rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("mobile create switcher"); + let new_tab = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| matches!(target, ClientMobileTarget::NewTab).then_some(*rect)) + .expect("new tab hit"); + state.handle_raw_events(vec![click(new_tab)]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::NewTab { .. }, + .. + })) + )); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("mobile workspace switcher"); + let new_workspace = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::NewWorkspace).then_some(*rect) + }) + .expect("new workspace hit"); + state.handle_raw_events(vec![click(new_workspace)]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::NewWorkspace { .. }, + .. + })) + )); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("mobile menu switcher"); + let settings = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| matches!(target, ClientMobileTarget::Menu(0)).then_some(*rect)) + .expect("settings hit"); + state.handle_raw_events(vec![click(settings)]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(_)) + )); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); +} + +#[test] +fn mobile_menu_keeps_inert_notes_open_and_cancel_without_workspace_in_navigate() { + let mut source_config = Config::default(); + source_config.ui.prompt_new_workspace_name = true; + let config = ClientShellConfig::from_config(&source_config); + let mut projected = snapshot(); + projected.latest_release_notes_available = true; + projected.release_notes = None; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("mobile switcher"); + let inert_notes = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| matches!(target, ClientMobileTarget::Menu(3)).then_some(*rect)) + .expect("what's new row"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: inert_notes.x, + row: inert_notes.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert!(state.overlay.is_none()); + assert!(!state.mobile_switcher_suspended); + + let mut empty = snapshot(); + empty.focused_workspace_id = None; + empty.focused_tab_id = None; + empty.focused_pane_id = None; + empty.workspaces.clear(); + empty.tabs.clear(); + empty.panes.clear(); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&source_config)); + state.set_snapshot(Box::new(empty)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("empty mobile switcher"); + let new_workspace = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::NewWorkspace).then_some(*rect) + }) + .expect("new workspace row"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: new_workspace.x, + row: new_workspace.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!(state.overlay, Some(ClientShellOverlay::Rename(_)))); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Navigate); +} + +#[test] +fn mobile_previous_workspace_action_wraps_across_expanded_entries() { + let mut projected = snapshot(); + for index in 2..=3 { + projected.workspaces.push(ClientShellWorkspace { + workspace_id: format!("ws_{index}"), + active_tab_id: format!("tab_{index}"), + new_workspace_cwd: "/tmp".into(), + number: index, + label: format!("workspace-{index}"), + custom_label: true, + branch: None, + git_ahead_behind: None, + tokens: Vec::new(), + worktree: None, + focused: false, + agent_status: AgentStatus::Idle, + }); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(44, 20).expect("mobile layout"); + let mut outcome = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::PreviousWorkspace), + &mut outcome, + ); + assert!(outcome.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_3" + ) + ))); +} + +#[test] +fn mobile_switcher_scroll_close_and_width_transition_clear_mobile_hits() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut projected = snapshot(); + for index in 2..=8 { + projected.workspaces.push(ClientShellWorkspace { + workspace_id: format!("ws_{index}"), + active_tab_id: format!("tab_{index}"), + new_workspace_cwd: "/tmp".into(), + number: index, + label: format!("workspace-{index}"), + custom_label: true, + branch: None, + git_ahead_behind: None, + tokens: Vec::new(), + worktree: None, + focused: false, + agent_status: AgentStatus::Idle, + }); + } + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(44, 10).expect("mobile header"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: state.hits.mobile_switch.x, + row: state.hits.mobile_switch.y, + modifiers: KeyModifiers::empty(), + })]); + state.compose(44, 10).expect("mobile switcher"); + let wheel = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 20, + row: 8, + modifiers: KeyModifiers::empty(), + })]); + assert!(wheel.repaint); + assert_eq!(state.mobile_switcher_scroll, 2); + state.compose(44, 10).expect("wheel position stays stable"); + assert_eq!(state.mobile_switcher_scroll, 2); + for _ in 0..7 { + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Down, + KeyModifiers::empty(), + ))]); + } + state.compose(44, 10).expect("revealed mobile selection"); + assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_8")); + assert!(state.mobile_switcher_scroll > 2); + assert!(state.hits.mobile_targets.iter().any(|(_, target)| { + matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_8") + })); + let close = state.hits.mobile_close; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: close.x, + row: close.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.navigate_workspace_id.is_none()); + + state.compose(80, 20).expect("desktop transition"); + assert!(state.hits.mobile_switch.is_empty()); + assert!(state.hits.mobile_close.is_empty()); + assert!(state.hits.mobile_targets.is_empty()); + + state.mode = ClientShellMode::Navigate; + let short = state.compose(44, 2).expect("short mobile switcher"); + assert_eq!(short.cells[0].symbol, "─"); + assert!(state.hits.mobile_close.is_empty()); + assert!(state.hits.mobile_targets.is_empty()); +} diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs new file mode 100644 index 00000000..4cf97152 --- /dev/null +++ b/src/client/shell/tests/mod.rs @@ -0,0 +1,220 @@ +use super::*; +use crate::api::schema::AgentStatus; +use crate::protocol::{ + ClientShellAgent, ClientShellPane, ClientShellTab, ClientShellWorktree, PaneSurfacePane, + PaneSurfaceSplit, PaneSurfaceSplitDirection, SurfaceRect, +}; +use crossterm::event::MouseEvent; + +fn snapshot() -> ClientShellSnapshot { + ClientShellSnapshot { + boot_id: "boot-1".into(), + revision: 1, + config_diagnostic: None, + product_announcement: None, + update_available: None, + update_install_command: "herdr update".into(), + server_keybindings_toml: None, + latest_release_notes_available: false, + integration_updates_available: false, + worktree_directory: "/tmp/herdr-worktrees".into(), + release_notes: None, + focused_workspace_id: Some("ws_1".into()), + focused_tab_id: Some("tab_1".into()), + focused_pane_id: Some("pane_1".into()), + tab_bar_right: Vec::new(), + tab_bar_right_separator: " ".into(), + agent_view_label: None, + agent_order: Vec::new(), + workspaces: vec![ClientShellWorkspace { + workspace_id: "ws_1".into(), + active_tab_id: "tab_1".into(), + new_workspace_cwd: "/repo".into(), + number: 1, + label: "client-shell".into(), + custom_label: false, + branch: Some("main".into()), + git_ahead_behind: None, + tokens: Vec::new(), + worktree: None, + focused: true, + agent_status: AgentStatus::Idle, + }], + tabs: vec![ClientShellTab { + tab_id: "tab_1".into(), + workspace_id: "ws_1".into(), + number: 1, + label: "1".into(), + custom_label: false, + zoomed: false, + focused: true, + agent_status: AgentStatus::Idle, + }], + panes: vec![ClientShellPane { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + label: None, + cwd: Some("/repo".into()), + foreground_cwd: Some("/repo".into()), + focused: true, + right_click_passthrough: false, + }], + agents: Vec::new(), + commands: Vec::new(), + } +} + +fn worktree_list_result(open_workspace_id: Option<&str>) -> crate::api::schema::ResponseResult { + crate::api::schema::ResponseResult::WorktreeList { + source: crate::api::schema::WorktreeSourceInfo { + repo_key: "repo-key".into(), + repo_name: "repo".into(), + repo_root: "/repo".into(), + source_checkout_path: "/repo".into(), + source_workspace_id: Some("ws_1".into()), + }, + worktrees: vec![crate::api::schema::WorktreeInfo { + path: "/repo-feature".into(), + branch: Some("feature".into()), + is_bare: false, + is_detached: false, + is_prunable: false, + is_linked_worktree: true, + open_workspace_id: open_workspace_id.map(str::to_owned), + label: "repo".into(), + }], + } +} + +fn surface() -> PaneSurfaceFrame { + let surface_buffer = Buffer::with_lines(["LIVE", "PANE"]); + PaneSurfaceFrame { + boot_id: "boot-1".into(), + projection_revision: 1, + surface_revision: 1, + frame: FrameData::from_ratatui_buffer_with_hyperlinks( + &surface_buffer, + Some(crate::protocol::CursorState { + x: 1, + y: 1, + visible: true, + shape: 2, + }), + &[], + ), + panes: vec![PaneSurfacePane { + pane_id: "pane_1".into(), + content_revision: 0, + rect: SurfaceRect { + x: 0, + y: 0, + width: 4, + height: 2, + }, + inner_rect: SurfaceRect { + x: 0, + y: 0, + width: 4, + height: 2, + }, + scrollbar_rect: None, + scroll: None, + focused: true, + mouse_reporting: false, + sgr_pixel_mouse: false, + alternate_screen_active: false, + pixel_width: 0, + pixel_height: 0, + }], + splits: Vec::new(), + popup: None, + graphics: crate::protocol::SurfaceGraphicsScene::default(), + } +} + +fn pane_scroll_result( + offset_from_bottom: u64, + max_offset_from_bottom: u64, + viewport_rows: u64, +) -> crate::api::schema::ResponseResult { + crate::api::schema::ResponseResult::PaneInfo { + pane: crate::api::schema::PaneInfo { + pane_id: "pane_1".into(), + terminal_id: "terminal_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + focused: true, + cwd: None, + foreground_cwd: None, + label: None, + agent: None, + title: None, + terminal_title: None, + terminal_title_stripped: None, + display_agent: None, + agent_status: crate::api::schema::AgentStatus::Unknown, + state_labels: HashMap::new(), + tokens: HashMap::new(), + agent_session: None, + scroll: Some(crate::api::schema::PaneScrollInfo { + offset_from_bottom, + max_offset_from_bottom, + viewport_rows, + }), + revision: 0, + }, + } +} + +fn copy_search_result( + matches: Vec<crate::api::schema::PaneTextRange>, + current: Option<u32>, +) -> crate::api::schema::ResponseResult { + let total = matches.len() as u64; + crate::api::schema::ResponseResult::PaneCopySearch { + pane_id: "pane_1".into(), + content_revision: 0, + matches, + total, + current, + current_global: current.map(u64::from), + } +} + +fn surface_with_popup() -> PaneSurfaceFrame { + let mut surface = surface(); + let popup_buffer = Buffer::with_lines(["popup-live", "", ""]); + surface.popup = Some(Box::new(crate::protocol::ClientShellPopupSurface { + terminal_id: "terminal-popup".into(), + title: "popup title".into(), + width: Some(crate::protocol::ClientShellPopupSize::Cells(12)), + height: Some(crate::protocol::ClientShellPopupSize::Cells(5)), + frame: FrameData::from_ratatui_buffer_with_hyperlinks( + &popup_buffer, + Some(crate::protocol::CursorState { + x: 2, + y: 1, + visible: true, + shape: 1, + }), + &[], + ), + mouse_reporting: true, + sgr_pixel_mouse: false, + pixel_width: 0, + pixel_height: 0, + })); + surface +} + +mod agents_worktrees_notifications; +mod chrome_context; +mod copy; +#[path = "input.rs"] +mod input_domain; +mod keybindings_settings; +mod mobile; +mod mouse_selection; +mod popup_focus_projection; +mod startup_overlays; diff --git a/src/client/shell/tests/mouse_selection.rs b/src/client/shell/tests/mouse_selection.rs new file mode 100644 index 00000000..9734db7c --- /dev/null +++ b/src/client/shell/tests/mouse_selection.rs @@ -0,0 +1,686 @@ +use super::*; + +#[test] +fn ctrl_click_routes_link_activation_through_endpoint_then_client_host() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + let down = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::CONTROL, + }; + let activate = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]); + let [ClientShellAction::Endpoint { request, .. }] = &activate.actions[..] else { + panic!("expected link activation request"); + }; + let request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneLinkActivate(params) + if params.pane_id == "pane_1" && params.viewport_row == 1 && params.col == 2 + )); + + let up = MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + ..down + }; + let held = state.handle_raw_events(vec![RawInputEvent::Mouse(up)]); + assert!(held.requests.is_empty() && held.actions.is_empty()); + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + url: Some("https://example.test".to_owned()), + handled: false, + }), + ); + assert!(matches!( + &actions[..], + [ClientShellAction::OpenSafeWebUrl(url)] if url == "https://example.test" + )); + assert!(!state.url_click_consumes_until_up); +} + +#[test] +fn ctrl_click_without_a_link_replays_the_original_gesture() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + let down = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::CONTROL, + }; + let activate = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]); + let request_id = match &activate.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + _ => panic!("expected link activation request"), + }; + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + url: None, + handled: false, + }), + ); + assert!(matches!( + &actions[..], + [ClientShellAction::ReplayMouse(events)] if events == &vec![down] + )); + let replay = match actions.into_iter().next().expect("replay action") { + ClientShellAction::ReplayMouse(events) => state.replay_mouse_events(events), + _ => unreachable!(), + }; + assert!(matches!( + &replay.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::PaneFocus(_)) + )); + assert!(state.selection.is_some()); +} + +#[test] +fn pane_split_drag_uses_projected_handle_and_stable_tab_path() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.splits.push(PaneSurfaceSplit { + direction: PaneSurfaceSplitDirection::Horizontal, + pos: 40, + area: SurfaceRect { + x: 0, + y: 0, + width: 80, + height: 19, + }, + hit_rect: SurfaceRect { + x: 40, + y: 0, + width: 1, + height: 19, + }, + path: vec![false, true], + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("split pane surface"); + let split = state.hits.pane_splits[0].clone(); + + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: split.hit_rect.x, + row: split.hit_rect.y + 2, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::PaneSplit { .. }) + )); + let mut replacement = snapshot(); + replacement.revision = 2; + replacement + .tab_bar_right + .push(crate::protocol::ClientShellTabStatusSegment { + text: "updated".into(), + accent: false, + }); + let mut replacement_surface = surface(); + replacement_surface.projection_revision = 2; + replacement_surface.splits.push(PaneSurfaceSplit { + direction: PaneSurfaceSplitDirection::Horizontal, + pos: 40, + area: SurfaceRect { + x: 0, + y: 0, + width: 80, + height: 19, + }, + hit_rect: SurfaceRect { + x: 40, + y: 0, + width: 1, + height: 19, + }, + path: vec![false, true], + }); + state.set_snapshot(Box::new(replacement)); + state.set_pane_surface(replacement_surface); + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: split.area.x + 48, + row: split.hit_rect.y + 2, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &drag.actions[..] else { + panic!("pane split drag should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::LayoutSetSplitRatio(params) + if params.tab_id.as_deref() == Some("tab_1") + && params.path == vec![false, true] + && (params.ratio - 0.6).abs() < f32::EPSILON + )); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: split.area.x + 48, + row: split.hit_rect.y + 2, + modifiers: KeyModifiers::empty(), + })]); + assert!(release.actions.is_empty()); + assert!(state.chrome_drag.is_none()); +} + +#[test] +fn disabled_mouse_chrome_keeps_tab_wheel_but_removes_split_drag_hits() { + let mut config = Config::default(); + config.ui.mouse_capture = false; + let mut projected = snapshot(); + let mut second_tab = projected.tabs[0].clone(); + second_tab.tab_id = "tab_2".into(); + second_tab.number = 2; + second_tab.label = "2".into(); + second_tab.focused = false; + projected.tabs.push(second_tab); + let mut pane_surface = surface(); + pane_surface.splits.push(PaneSurfaceSplit { + direction: PaneSurfaceSplitDirection::Horizontal, + pos: 40, + area: SurfaceRect { + x: 0, + y: 0, + width: 80, + height: 19, + }, + hit_rect: SurfaceRect { + x: 40, + y: 0, + width: 1, + height: 19, + }, + path: Vec::new(), + }); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("mouse-disabled shell"); + assert!(state.hits.pane_splits.is_empty()); + let first_tab = state.hits.tabs[0].0; + let wheel = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: first_tab.x, + row: first_tab.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &wheel.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::TabFocus(target) if target.tab_id == "tab_2" + ) + )); +} + +#[test] +fn client_double_click_selects_and_copies_endpoint_row_word() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + let click = || { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + let release = || { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + + state.handle_raw_events(vec![click()]); + state.handle_raw_events(vec![release()]); + let second = state.handle_raw_events(vec![click()]); + let ClientShellAction::Endpoint { request, .. } = second + .actions + .iter() + .find(|action| { + matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) + ) + }) + .expect("word-row read") + else { + unreachable!() + }; + let word_request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor == crate::api::schema::PaneTextPoint { row: 0, col: 0 } + && params.cursor == crate::api::schema::PaneTextPoint { row: 0, col: 3 } + )); + + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &word_request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: "LIVE".into(), + }), + ); + assert!(repaint); + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_finalized)); + let [ClientShellAction::Endpoint { request, .. }] = &actions[..] else { + panic!("auto-copy should read the selected word"); + }; + let copy_request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor.col == 0 && params.cursor.col == 3 + )); + let (_, actions) = state.handle_endpoint_result( + "boot-1", + ©_request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: "LIVE".into(), + }), + ); + assert!(matches!( + &actions[..], + [ClientShellAction::ClipboardWrite(bytes)] if bytes == b"LIVE" + )); +} + +#[test] +fn pane_mouse_input_keeps_stable_target_and_endpoint_encoding() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::ALT, + })]); + let [ClientMessage::ClientShellPaneInput { pane_id, events }] = &click.requests[..] else { + panic!("pane application click should use targeted canonical input"); + }; + assert_eq!(pane_id, "pane_1"); + assert!(matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left + ), + position: ClientMousePosition::Cell { column: 2, row: 1 }, + modifiers, + .. + }] if *modifiers == KeyModifiers::ALT.bits() + )); + let moved = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Moved, + column: 0, + row: 0, + modifiers: KeyModifiers::ALT, + })]); + assert!(moved.requests.is_empty()); + assert!(state.pane_mouse_gesture.is_some()); + state.hits.panes.clear(); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::ALT, + })]); + assert!(matches!( + &release.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn pane_pixel_mouse_preserves_pane_relative_pixel_coordinates() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + pane_surface.panes[0].sgr_pixel_mouse = true; + pane_surface.panes[0].pixel_width = 39; + pane_surface.panes[0].pixel_height = 38; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + let geometry = + crate::input::mouse::HostGeometry::new(106, 20, 1060, 400).expect("host geometry"); + let x = u32::from(pane.inner_rect.x) * 10 + 21; + let y = u32::from(pane.inner_rect.y) * 20 + 21; + let report = format!("\x1b[<0;{x};{y}M"); + + let outcome = state.handle_pixel_mouse(report.as_bytes(), geometry); + assert!(matches!( + &outcome.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left + ), + position: ClientMousePosition::Pixels { x: 20, y: 20, .. }, + .. + }] + ) + )); + + let lost = state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + assert!(matches!( + &lost.requests[..], + [ + ClientMessage::ClientShellPaneInput { pane_id, events }, + ClientMessage::ClientShellFocus { focused: false } + ] if pane_id == "pane_1" && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + position: ClientMousePosition::Pixels { x: 20, y: 20, .. }, + .. + }] + ) + )); +} + +#[test] +fn pane_owned_right_click_forwards_the_complete_gesture() { + let mut snapshot = snapshot(); + snapshot.panes[0].right_click_passthrough = true; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &down.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, .. }] if pane_id == "pane_1" + )); + assert!(state.overlay.is_none()); + assert!(state.pane_mouse_gesture.is_some()); + + let up = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Right), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &up.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Right + ), + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn tab_click_waits_for_release_and_drag_reorders_by_stable_id() { + let mut projected = snapshot(); + for index in 2..=3 { + let mut tab = projected.tabs[0].clone(); + tab.tab_id = format!("tab_{index}"); + tab.number = index; + tab.label = index.to_string(); + tab.focused = false; + projected.tabs.push(tab); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("three tabs"); + let first = state.hits.tabs[0].0; + let third = state.hits.tabs[2].0; + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: first.x + 1, + row: first.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(down.actions.is_empty()); + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: third.right().saturating_sub(1), + row: third.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(drag.repaint); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::Tab { + ref tab_id, + insert_index: Some(3), + .. + }) if tab_id == "tab_1" + )); + let frame = state.compose(106, 20).expect("tab drop indicator"); + assert!(frame + .cells + .iter() + .take(frame.width as usize) + .any(|cell| cell.symbol == "│")); + + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: third.right().saturating_sub(1), + row: third.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &release.actions[..] else { + panic!("tab drag should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::TabMove(params) + if params.tab_id == "tab_1" && params.insert_index == 3 + )); + + state.compose(106, 20).expect("tabs after drag"); + let second = state.hits.tabs[1].0; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: second.x + 1, + row: second.y, + modifiers: KeyModifiers::empty(), + })]); + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: second.x + 1, + row: second.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &click.actions[0], + ClientShellAction::Endpoint { request, .. } + if matches!(&request.method, crate::api::schema::Method::TabFocus(target) if target.tab_id == "tab_2") + )); +} + +#[test] +fn tab_drag_clears_its_drop_target_after_leaving_the_tab_row() { + let mut projected = snapshot(); + for index in 2..=3 { + let mut tab = projected.tabs[0].clone(); + tab.tab_id = format!("tab_{index}"); + tab.number = index; + tab.label = index.to_string(); + tab.focused = false; + projected.tabs.push(tab); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("three tabs"); + let first = state.hits.tabs[0].0; + let third = state.hits.tabs[2].0; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: first.x + 1, + row: first.y, + modifiers: KeyModifiers::empty(), + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: third.x, + row: third.y, + modifiers: KeyModifiers::empty(), + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: third.x, + row: third.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::Tab { + insert_index: None, + .. + }) + )); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: third.x, + row: third.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(release.actions.is_empty()); +} + +#[test] +fn tab_wheel_switches_tabs_without_changing_overflow_scroll() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("tab bar"); + let tab = state.hits.tabs[0].0; + + let outcome = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: tab.x, + row: tab.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &outcome.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::TabFocus(target) if target.tab_id == "tab_1" + ) + )); + assert_eq!(state.tab_scroll, 0); + state.compose(106, 20).expect("tab bar after wheel"); + assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_1")); +} + +#[test] +fn context_menu_keyboard_and_outside_click_are_client_owned() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + let tab = state.hits.tabs[0].0; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: tab.x + 1, + row: tab.y, + modifiers: KeyModifiers::empty(), + })]); + state.compose(106, 20).expect("tab context menu"); + let moved = state.handle_input_bytes(b"\x1b[B"); + assert!(moved.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + highlighted: 1, + .. + })) + )); + let text = state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "not pane input", + ))]); + assert!(text.requests.is_empty()); + let paste = state.handle_raw_events(vec![RawInputEvent::Paste("not pane input".into())]); + assert!(paste.requests.is_empty()); + let outside = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 105, + row: 19, + modifiers: KeyModifiers::empty(), + })]); + assert!(outside.repaint); + assert!(state.overlay.is_none()); +} diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs new file mode 100644 index 00000000..6ccdcf6e --- /dev/null +++ b/src/client/shell/tests/popup_focus_projection.rs @@ -0,0 +1,1226 @@ +use super::*; + +#[test] +fn clipboard_image_targets_the_focused_pane_or_active_popup() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + + assert_eq!( + state.clipboard_image_target(), + Some(crate::protocol::ClientClipboardImageTarget::Pane( + "pane_1".into() + )) + ); + + state.mode = ClientShellMode::Prefix; + assert_eq!(state.clipboard_image_target(), None); + state.mode = ClientShellMode::Terminal; + state.overlay = Some(ClientShellOverlay::Onboarding); + assert_eq!(state.clipboard_image_target(), None); + state.overlay = None; + + state.set_pane_surface(surface_with_popup()); + assert_eq!( + state.clipboard_image_target(), + Some(crate::protocol::ClientClipboardImageTarget::Popup( + "terminal-popup".into() + )) + ); + state.overlay = Some(ClientShellOverlay::Onboarding); + assert_eq!(state.clipboard_image_target(), None); +} + +#[test] +fn modal_paste_target_requires_a_focused_editable_client_field() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + assert!(!state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename pane", + input: String::new(), + replace_on_type: false, + target: ClientRenameTarget::Pane { + pane_id: "pane_1".into(), + }, + })); + assert!(state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { + source_workspace_id: "ws_1".into(), + repo_name: "repo".into(), + branch: String::new(), + checkout_path: String::new(), + replace_on_type: false, + error: None, + creating: true, + }, + )); + assert!(!state.modal_paste_target_active()); + if let Some(ClientShellOverlay::WorktreeCreate(create)) = state.overlay.as_mut() { + create.creating = false; + } + assert!(state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { + source_workspace_id: "ws_1".into(), + entries: Vec::new(), + selected: 0, + query: String::new(), + search_focused: false, + error: None, + opening: false, + }, + )); + assert!(!state.modal_paste_target_active()); + if let Some(ClientShellOverlay::WorktreeOpen(open)) = state.overlay.as_mut() { + open.search_focused = true; + } + assert!(state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { + query: String::new(), + search_focused: false, + selected: 0, + scroll: 0, + filter: None, + expanded_workspaces: HashSet::new(), + })); + assert!(!state.modal_paste_target_active()); + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.search_focused = true; + } + assert!(state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay { + query: String::new(), + search_focused: false, + scroll: 0, + })); + assert!(!state.modal_paste_target_active()); + if let Some(ClientShellOverlay::Help(help)) = state.overlay.as_mut() { + help.search_focused = true; + } + assert!(state.modal_paste_target_active()); + + state.overlay = None; + state.copy_mode = Some(ClientCopyModeState { + pane_id: "pane_1".into(), + content_revision: 0, + geometry: (80, 24), + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + offset_from_bottom: 0, + max_offset_from_bottom: 0, + entry_offset_from_bottom: 0, + selection: None, + search_prompt: Some(ClientCopySearchPrompt { + direction: crate::api::schema::PaneCopySearchDirection::Forward, + query: String::new(), + }), + search_query: String::new(), + search_direction: None, + search_matches: Vec::new(), + search_total: 0, + search_current: None, + search_current_global: None, + search_generation: 0, + copy_after_search: false, + }); + assert!(state.modal_paste_target_active()); + state.popup_pending = true; + assert!(!state.modal_paste_target_active()); +} + +#[test] +fn non_overlay_ctrl_v_is_forwarded_to_the_focused_pane() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let key = crate::input::TerminalKey::new(KeyCode::Char('v'), KeyModifiers::CONTROL); + + let outcome = state.handle_raw_events(vec![RawInputEvent::Key(key)]); + + assert!(matches!( + &outcome.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!(&events[..], [ClientPaneInputEvent::Key { .. }]) + )); +} + +#[test] +fn desktop_composition_keeps_shell_outside_origin_relative_surface() { + let config = ClientShellConfig::from_config(&Config::default()); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + let frame = state.compose(106, 20).expect("composed frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("spaces")); + assert!(text.contains("client-shell")); + assert!(text.contains("main")); + assert!(text.contains("LIVE")); + assert!(!text.contains("1 1")); + assert_eq!( + frame.cursor.as_ref().map(|cursor| (cursor.x, cursor.y)), + Some((27, 2)) + ); +} + +#[test] +fn client_composes_popup_terminal_content_inside_client_owned_chrome() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + + let frame = state.compose(106, 20).expect("popup frame"); + let popup = state.hits.popup.as_ref().expect("popup hit geometry"); + assert_eq!(popup.rect.width, 12); + assert_eq!(popup.rect.height, 5); + assert_eq!(popup.inner_rect.width, 9); + assert_eq!(popup.inner_rect.height, 3); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("popup tit")); + assert!(text.contains("popup-liv")); + assert_eq!( + frame.cursor.as_ref().map(|cursor| (cursor.x, cursor.y)), + Some((popup.inner_rect.x + 2, popup.inner_rect.y + 1)) + ); +} + +#[test] +fn popup_owns_keys_text_paste_and_mouse_before_shell_controls() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + state.compose(106, 20).expect("popup frame"); + + for bytes in [b"x".as_slice(), b"\x02".as_slice(), b"\x1b".as_slice()] { + let input = state.handle_input_bytes(bytes); + assert!(matches!( + &input.requests[..], + [ClientMessage::ClientShellPopupInput { terminal_id, .. }] + if terminal_id == "terminal-popup" + )); + assert_eq!(state.mode, ClientShellMode::Terminal); + } + + let text = state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "ime", + ))]); + assert!(matches!( + &text.requests[..], + [ClientMessage::ClientShellPopupInput { events, .. }] + if matches!(&events[..], [ClientPaneInputEvent::TextCommit(value)] if value == "ime") + )); + let paste = state.handle_raw_events(vec![RawInputEvent::Paste("paste".into())]); + assert!(matches!( + &paste.requests[..], + [ClientMessage::ClientShellPopupInput { events, .. }] + if matches!(&events[..], [ClientPaneInputEvent::Paste(value)] if value == "paste") + )); + + let popup = state.hits.popup.as_ref().expect("popup hit").clone(); + let mouse = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: popup.inner_rect.x + 3, + row: popup.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &mouse.requests[..], + [ClientMessage::ClientShellPopupInput { events, .. }] + if matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + position: ClientMousePosition::Cell { column: 3, row: 1 }, + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_some()); + state.set_pane_surface(surface()); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn popup_transition_dismisses_client_overlays_and_restores_pane_input_after_close() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::Help), + &mut ClientShellInput::default(), + ); + assert!(state.overlay.is_some()); + + state.set_pane_surface(surface_with_popup()); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + let popup_input = state.handle_input_bytes(b"p"); + assert!(matches!( + &popup_input.requests[..], + [ClientMessage::ClientShellPopupInput { .. }] + )); + + state.set_pane_surface(surface()); + let pane_input = state.handle_input_bytes(b"p"); + assert!(matches!( + &pane_input.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, .. }] if pane_id == "pane_1" + )); +} + +#[test] +fn popup_target_survives_surface_invalidation_during_resize() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + state.invalidate_pane_surface(); + + assert!(matches!( + &state.handle_input_bytes(b"x").requests[..], + [ClientMessage::ClientShellPopupInput { terminal_id, .. }] + if terminal_id == "terminal-popup" + )); + let mouse = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(mouse.requests.is_empty()); + assert!(mouse.actions.is_empty()); +} + +#[test] +fn popup_close_reprocesses_held_key_repeats_into_the_focused_pane() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + + let press = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('x'), + KeyModifiers::empty(), + ))]); + assert!(matches!( + &press.requests[..], + [ClientMessage::ClientShellPopupInput { .. }] + )); + + state.set_pane_surface(surface()); + let repeat = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()) + .with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(matches!( + &repeat.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, .. }] if pane_id == "pane_1" + )); +} + +#[test] +fn pending_popup_suppresses_held_pane_repeats_but_preserves_release() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let key = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()); + assert!(matches!( + &state + .handle_raw_events(vec![RawInputEvent::Key(key.clone())]) + .requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + + state.popup_pending = true; + let repeat = state.handle_raw_events(vec![RawInputEvent::Key( + key.clone() + .with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(repeat.requests.is_empty()); + let release = state.handle_raw_events(vec![RawInputEvent::Key( + key.with_kind(crossterm::event::KeyEventKind::Release), + )]); + assert!(matches!( + &release.requests[..], + [ClientMessage::ClientShellPaneInput { events, .. }] + if matches!( + &events[..], + [ClientPaneInputEvent::Key { + kind: crate::protocol::ClientKeyKind::Release, + .. + }] + ) + )); +} + +#[test] +fn prefix_input_source_changes_are_client_owned_and_focus_safe() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.switch_ascii_input_source_in_prefix = true; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + assert!(state.take_input_source_changes().is_empty()); + + let prefix = crate::input::TerminalKey::new(KeyCode::Char('b'), KeyModifiers::CONTROL); + state.handle_raw_events(vec![RawInputEvent::Key(prefix)]); + assert_eq!(state.take_input_source_changes(), vec![true]); + + state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + assert!(state.take_input_source_changes().is_empty()); + let escape = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()); + state.handle_raw_events(vec![RawInputEvent::Key(escape.clone())]); + assert!(state.take_input_source_changes().is_empty()); + state.handle_raw_events(vec![RawInputEvent::OuterFocusGained]); + assert_eq!(state.take_input_source_changes(), vec![false]); + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('b'), + KeyModifiers::CONTROL, + ))]); + assert_eq!(state.take_input_source_changes(), vec![true]); + state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + state.handle_raw_events(vec![RawInputEvent::OuterFocusGained]); + assert!(state.take_input_source_changes().is_empty()); + state.handle_raw_events(vec![RawInputEvent::Key(escape)]); + assert_eq!(state.take_input_source_changes(), vec![false]); +} + +#[test] +fn focus_loss_releases_held_pane_keys_before_reporting_focus() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let key = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()) + .with_generated_text(Some("x".to_owned())) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x58, + virtual_scan_code: 0x2d, + unicode: 'x' as u16, + control_key_state: 0, + }); + let press = state.handle_raw_events(vec![RawInputEvent::Key(key)]); + assert!(matches!( + &press.requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + + let lost = state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + assert!(matches!( + &lost.requests[..], + [ + ClientMessage::ClientShellPaneInput { events, .. }, + ClientMessage::ClientShellFocus { focused: false } + ] if matches!( + &events[..], + [ClientPaneInputEvent::Key { + kind: crate::protocol::ClientKeyKind::Release, + .. + }] + ) + )); + assert!(state.input_leases.is_empty()); +} + +#[test] +fn focus_loss_releases_active_pane_mouse_before_reporting_focus() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &down.requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + assert!(state.pane_mouse_gesture.is_some()); + + let lost = state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + assert!(matches!( + &lost.requests[..], + [ + ClientMessage::ClientShellPaneInput { pane_id, events }, + ClientMessage::ClientShellFocus { focused: false } + ] if pane_id == "pane_1" && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + position: ClientMousePosition::Cell { column: 2, row: 1 }, + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn focus_gain_reports_focus_and_honors_redraw_policy() { + let mut config = Config::default(); + config.ui.redraw_on_focus_gained = false; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let gained = state.handle_raw_events(vec![RawInputEvent::OuterFocusGained]); + assert!(!gained.repaint); + assert!(gained.query_host_appearance); + assert!(matches!( + &gained.requests[..], + [ClientMessage::ClientShellFocus { focused: true }] + )); +} + +#[test] +fn pane_mouse_release_survives_popup_open_transition() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &down.requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + assert!(state.pane_mouse_gesture.is_some()); + + state.set_pane_surface(surface_with_popup()); + let up = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &up.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn popup_command_blocks_underlying_input_until_surface_or_error() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("t"), + label: "prefix+t".into(), + command: "secret-popup-command".into(), + action: crate::config::CustomCommandAction::Popup, + description: None, + width: None, + height: None, + }; + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_popup".into(), + binding_label: binding.label.clone(), + binding_labels: binding.bindings.labels(), + action: crate::protocol::ClientShellCommandAction::Popup, + description: None, + }); + state.set_snapshot(Box::new(projection)); + state.set_pane_surface(surface()); + + let mut invoke = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut invoke); + assert!(state.popup_pending); + assert!(state + .handle_input_bytes(b"not-for-pane") + .requests + .is_empty()); + assert!(state + .handle_raw_events(vec![RawInputEvent::Paste("secret".into())]) + .requests + .is_empty()); + + let request_id = match &invoke.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + other => panic!("expected popup command request, got {other:?}"), + }; + let (repaint, _) = state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("command_failed".into()), + message: "popup failed".into(), + }), + ); + assert!(repaint); + assert!(!state.popup_pending); + assert!(matches!( + &state.handle_input_bytes(b"p").requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("t"), + label: "prefix+t".into(), + command: "secret-popup-command".into(), + action: crate::config::CustomCommandAction::Popup, + description: None, + width: None, + height: None, + }; + let mut invoke = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut invoke); + let request_id = match &invoke.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + other => panic!("expected popup command request, got {other:?}"), + }; + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::Ok {}), + ); + assert!(state.popup_pending); + assert!(state + .handle_input_bytes(b"still-blocked") + .requests + .is_empty()); + let deadline = state.popup_pending_deadline.expect("pending timeout"); + state.tick_popup_pending(deadline); + assert!(!state.popup_pending); +} + +#[test] +fn shell_refuses_mismatched_projection_and_clears_stale_hits_in_either_order() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("initial frame"); + assert!(!state.hits.panes.is_empty()); + + let mut replacement = snapshot(); + replacement.revision = 2; + state.set_snapshot(Box::new(replacement)); + assert!(state.hits.panes.is_empty()); + assert!(state.compose(106, 20).is_none()); + + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("initial frame"); + let mut replacement_surface = surface(); + replacement_surface.projection_revision = 2; + state.set_pane_surface(replacement_surface); + assert!(state.hits.panes.is_empty()); + assert!(state.compose(106, 20).is_none()); +} + +#[test] +fn shell_ignores_older_same_boot_snapshot_and_surface() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut current_snapshot = snapshot(); + current_snapshot.revision = 2; + current_snapshot.workspaces[0].label = "current".into(); + state.set_snapshot(Box::new(current_snapshot)); + let mut current_surface = surface(); + current_surface.projection_revision = 2; + current_surface.frame.cells[0].symbol = "N".into(); + state.set_pane_surface(current_surface); + state.compose(106, 20).expect("current shell"); + assert!(!state.hits.panes.is_empty()); + let held_key = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()); + assert!(matches!( + &state + .handle_raw_events(vec![RawInputEvent::Key(held_key.clone())]) + .requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + + let mut stale_snapshot = snapshot(); + stale_snapshot.workspaces[0].label = "stale".into(); + state.set_snapshot(Box::new(stale_snapshot)); + let mut stale_surface = surface(); + stale_surface.frame.cells[0].symbol = "O".into(); + state.set_pane_surface(stale_surface); + + let installed_snapshot = state.snapshot.as_deref().expect("current snapshot"); + assert_eq!(installed_snapshot.revision, 2); + assert_eq!(installed_snapshot.workspaces[0].label, "current"); + let installed_surface = state.pane_surface.as_ref().expect("current pane surface"); + assert_eq!(installed_surface.projection_revision, 2); + assert_eq!(installed_surface.frame.cells[0].symbol, "N"); + + let mut ahead_surface = surface(); + ahead_surface.projection_revision = 4; + ahead_surface.frame.cells[0].symbol = "A".into(); + state.set_pane_surface(ahead_surface); + let mut delayed_surface = surface(); + delayed_surface.projection_revision = 3; + delayed_surface.frame.cells[0].symbol = "D".into(); + state.set_pane_surface(delayed_surface); + let installed_surface = state.pane_surface.as_ref().expect("newest pane surface"); + assert_eq!(installed_surface.projection_revision, 4); + assert_eq!(installed_surface.frame.cells[0].symbol, "A"); + + let mut replacement_boot = snapshot(); + replacement_boot.boot_id = "boot-2".into(); + state.set_snapshot(Box::new(replacement_boot)); + assert!(state.pane_surface.is_none()); + assert!(state.hits.panes.is_empty()); + let release = state.handle_raw_events(vec![RawInputEvent::Key( + held_key.with_kind(crossterm::event::KeyEventKind::Release), + )]); + assert!( + release.requests.is_empty(), + "boot replacement must discard held input leases" + ); + let mut prior_boot_surface = surface(); + prior_boot_surface.projection_revision = u64::MAX; + state.set_pane_surface(prior_boot_surface); + assert!( + state.pane_surface.is_none(), + "an old endpoint surface must not cross the boot boundary" + ); +} + +#[test] +fn resize_invalidation_drops_stale_hits_but_preserves_gesture_release() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.pane_mouse_gesture.is_some()); + + state.invalidate_pane_surface(); + assert!(state.pane_surface.is_none()); + assert!(state.hits.panes.is_empty()); + let stale_click = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: 27, + row: 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(stale_click.requests.is_empty()); + assert!(stale_click.actions.is_empty()); + + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &release.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn pane_scrollbar_track_and_thumb_use_stable_endpoint_scroll_requests() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scrollbar_rect = Some(SurfaceRect { + x: 3, + y: 0, + width: 1, + height: 2, + }); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + let track = pane.scrollbar_rect.expect("scrollbar track"); + let metrics = pane.scroll.expect("scroll metrics"); + + let track_click = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: track.y, + modifiers: KeyModifiers::empty(), + })]); + let expected = crate::ui::scrollbar_offset_from_row(metrics, track, track.y); + assert!(track_click.requests.is_empty()); + assert!(matches!( + &track_click.actions[..], + [ + ClientShellAction::Endpoint { request: focus, .. }, + ClientShellAction::Endpoint { request: scroll, .. } + ] if matches!( + &focus.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + ) && matches!( + &scroll.method, + crate::api::schema::Method::PaneScroll(params) + if params.pane_id == "pane_1" + && params.offset_from_bottom == expected as u64 + ) + )); + let track_scroll_id = match &track_click.actions[1] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + state.handle_endpoint_result( + "boot-1", + &track_scroll_id, + Ok(pane_scroll_result(expected as u64, 20, 2)), + ); + + let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("scrollbar thumb"); + let thumb_down = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: thumb.top, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &thumb_down.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + ) + )); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::PaneScrollbar { .. }) + )); + + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: track.x, + row: track.y, + modifiers: KeyModifiers::empty(), + })]); + let expected = crate::ui::scrollbar_offset_from_drag_row(metrics, track, track.y, 0); + assert!(matches!( + &drag.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.pane_id == "pane_1" + && params.offset_from_bottom == expected as u64 + ) + )); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(release.actions.is_empty()); + assert!(state.chrome_drag.is_none()); +} + +#[test] +fn edit_scrollback_binding_targets_the_focused_endpoint_pane() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut input = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::EditScrollback), + &mut input, + ); + + assert!(input.requests.is_empty()); + assert!(matches!( + &input.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneEditScrollback(target) + if target.pane_id == "pane_1" + ) + )); +} + +#[test] +fn sidebar_scrollbars_use_proportional_shared_geometry_and_drag() { + let mut projected = snapshot(); + for index in 2..=10 { + let mut workspace = projected.workspaces[0].clone(); + workspace.workspace_id = format!("ws_{index}"); + workspace.number = index; + workspace.label = format!("workspace-{index}"); + workspace.focused = false; + projected.workspaces.push(workspace); + } + for index in 1..=10 { + projected.agents.push(crate::protocol::ClientShellAgent { + pane_id: format!("agent-pane-{index}"), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some(format!("agent-{index}")), + display_agent: None, + agent: Some("codex".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: index, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("overflowing sidebars"); + + for agent in [false, true] { + let (track, metrics) = if agent { + ( + state.hits.agent_scrollbar, + state.hits.agent_scroll_metrics.expect("agent metrics"), + ) + } else { + ( + state.hits.workspace_scrollbar, + state + .hits + .workspace_scroll_metrics + .expect("workspace metrics"), + ) + }; + assert!(track.width > 0); + let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("scrollbar thumb"); + assert!(thumb.len > 1); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: thumb.top, + modifiers: KeyModifiers::empty(), + })]); + let dragged = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::empty(), + })]); + assert!(dragged.repaint); + if agent { + assert_eq!(state.agent_scroll, metrics.max_offset_from_bottom); + } else { + assert_eq!(state.workspace_scroll, metrics.max_offset_from_bottom); + } + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::empty(), + })]); + } +} + +#[test] +fn popup_preemption_cancels_settings_theme_preview() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.open_settings_overlay(); + let original_theme = state.config.theme_name.clone(); + let original_palette = state.config.palette.clone(); + + state.handle_input_bytes(b"j"); + assert_ne!(state.config.theme_name, original_theme); + assert_ne!(state.config.palette.accent, original_palette.accent); + + state.set_pane_surface(surface_with_popup()); + + assert!(!matches!( + state.overlay, + Some(ClientShellOverlay::Settings(_)) + )); + assert_eq!(state.config.theme_name, original_theme); + assert_eq!(state.config.palette, original_palette); +} + +#[test] +fn pending_scroll_target_does_not_relabel_an_older_surface() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface.clone()); + let mut outcome = ClientShellInput::default(); + state.push_pane_scroll_offset("pane_1".into(), 10, &mut outcome); + state.set_pane_surface(pane_surface); + assert_eq!( + state + .pane_surface + .as_ref() + .and_then(|surface| surface.panes[0].scroll) + .map(|scroll| scroll.offset_from_bottom), + Some(0) + ); + assert_eq!(state.pane_scroll_targets.get("pane_1"), Some(&10)); +} + +#[test] +fn retained_surface_patch_updates_only_pane_cells_without_recomposing_chrome() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let pane_surface = surface(); + let mut updated_pane = pane_surface.panes[0].clone(); + updated_pane.content_revision = 2; + state.set_pane_surface(pane_surface); + let composed = state.compose(100, 30).expect("initial composed frame"); + let layout = state.layout(100, 30); + let patch = crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 1, + surface_revision: 2, + rows: vec![crate::protocol::PaneSurfacePatchRow { + x: 0, + y: 0, + cells: vec![ + crate::protocol::CellData { + symbol: "N".into(), + fg: 0, + bg: 0, + modifier: 0, + skip: false, + hyperlink: None, + }; + 4 + ], + }], + panes: vec![updated_pane], + cursor: None, + }; + + let ClientPaneSurfacePatchOutcome::Applied(Some(patch)) = state.apply_pane_surface_patch(patch) + else { + panic!("expected fast retained patch"); + }; + let patched = apply_composed_surface_patch(&composed, patch).expect("apply composed patch"); + let pane_index = usize::from(layout.pane_surface.y) * usize::from(patched.width) + + usize::from(layout.pane_surface.x); + assert_eq!(patched.cells[pane_index].symbol, "N"); + assert_eq!( + patched.cells[0], composed.cells[0], + "sidebar chrome changed" + ); + assert_eq!(state.pane_surface.as_ref().unwrap().surface_revision, 2); + + let mut forced_full = surface(); + forced_full.surface_revision = 3; + forced_full.frame.cells[0].symbol = "F".into(); + state.set_pane_surface(forced_full); + assert_eq!(state.pane_surface.as_ref().unwrap().surface_revision, 3); + assert_eq!( + state.pane_surface.as_ref().unwrap().frame.cells[0].symbol, + "F" + ); +} + +#[test] +fn retained_surface_patch_recomposes_client_owned_mode_and_diagnostic_rows() { + for diagnostic in [false, true] { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let pane_surface = surface(); + let mut updated_pane = pane_surface.panes[0].clone(); + updated_pane.content_revision = 2; + state.set_pane_surface(pane_surface); + state.compose(100, 30).expect("initial composed frame"); + if diagnostic { + state.config_diagnostic = Some("invalid config".into()); + } else { + state.mode = ClientShellMode::Prefix; + } + let patch = crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 1, + surface_revision: 2, + rows: Vec::new(), + panes: vec![updated_pane], + cursor: None, + }; + + assert!(matches!( + state.apply_pane_surface_patch(patch), + ClientPaneSurfacePatchOutcome::Applied(None) + )); + } +} + +#[test] +fn retained_surface_patch_updates_scrollbar_cells_and_pane_hit_metadata() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.frame = FrameData::from_ratatui_buffer_with_hyperlinks( + &Buffer::with_lines(["LIVE ", "PANE "]), + None, + &[], + ); + pane_surface.panes[0].rect.width = 5; + state.set_pane_surface(pane_surface.clone()); + let composed = state.compose(100, 30).expect("initial composed frame"); + let layout = state.layout(100, 30); + let mut updated_pane = pane_surface.panes[0].clone(); + updated_pane.scrollbar_rect = Some(SurfaceRect { + x: 4, + y: 0, + width: 1, + height: 2, + }); + updated_pane.scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 2, + max_offset_from_bottom: 8, + viewport_rows: 2, + }); + updated_pane.mouse_reporting = true; + updated_pane.sgr_pixel_mouse = true; + let patch = crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 1, + surface_revision: 2, + rows: vec![crate::protocol::PaneSurfacePatchRow { + x: 4, + y: 0, + cells: vec![crate::protocol::CellData { + symbol: "▐".into(), + fg: 0, + bg: 0, + modifier: 0, + skip: false, + hyperlink: None, + }], + }], + panes: vec![updated_pane], + cursor: None, + }; + + let ClientPaneSurfacePatchOutcome::Applied(Some(patch)) = state.apply_pane_surface_patch(patch) + else { + panic!("expected fast retained patch"); + }; + let patched = apply_composed_surface_patch(&composed, patch).expect("apply composed patch"); + let scrollbar_index = usize::from(layout.pane_surface.y) * usize::from(patched.width) + + usize::from(layout.pane_surface.x + 4); + assert_eq!(patched.cells[scrollbar_index].symbol, "▐"); + let hit = state + .hits + .panes + .iter() + .find(|hit| hit.pane_id == "pane_1") + .expect("pane hit"); + assert_eq!( + hit.scrollbar_rect, + Some(Rect::new( + layout.pane_surface.x + 4, + layout.pane_surface.y, + 1, + 2, + )) + ); + assert_eq!( + hit.scroll.map(|scroll| scroll.max_offset_from_bottom), + Some(8) + ); + assert!(hit.mouse_reporting); + assert!(hit.sgr_pixel_mouse); +} + +#[test] +fn retained_surface_patch_rejects_stale_base_without_mutating_surface() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let before = state.pane_surface.clone(); + let outcome = state.apply_pane_surface_patch(crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 0, + surface_revision: 2, + rows: Vec::new(), + panes: Vec::new(), + cursor: None, + }); + assert!(matches!(outcome, ClientPaneSurfacePatchOutcome::Rejected)); + assert_eq!(state.pane_surface, before); +} diff --git a/src/client/shell/tests/startup_overlays.rs b/src/client/shell/tests/startup_overlays.rs new file mode 100644 index 00000000..40ba0bd6 --- /dev/null +++ b/src/client/shell/tests/startup_overlays.rs @@ -0,0 +1,1259 @@ +use super::*; + +#[test] +fn endpoint_product_announcement_is_client_rendered_modal_and_dismissed_by_identity() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.product_announcement = + Some(crate::protocol::ClientShellProductAnnouncement { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "A client-owned announcement".into(), + body: (0..40) + .map(|index| format!("- announcement line {index}")) + .collect::<Vec<_>>() + .join("\n"), + preview: false, + }); + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + state.set_pane_surface(surface_with_popup()); + + let frame = state.compose(106, 30).expect("announcement frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("A client-owned announcement")); + assert!(text.contains("product announcement · v0.8.2")); + assert!(!state.hits.product_announcement_scrollbar.is_empty()); + + let popup_key = state.handle_input_bytes(b"x"); + assert!(popup_key.requests.is_empty()); + let popup_paste = state.handle_raw_events(vec![RawInputEvent::Paste("secret".into())]); + assert!(popup_paste.requests.is_empty()); + + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })]); + state.handle_input_bytes(b"\x1b[6~"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement( + crate::app::state::ProductAnnouncementState { scroll: 11, .. } + )) + )); + let repeated = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::PageDown, KeyModifiers::empty()) + .with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(repeated.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement( + crate::app::state::ProductAnnouncementState { scroll: 11, .. } + )) + )); + + let dismissed = state.handle_input_bytes(b"\r"); + assert!(state.overlay.is_none()); + assert!(matches!( + &dismissed.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::ProductAnnouncementDismiss(params) + if params.version == "0.8.2" && params.id == "client-shell" + ) + )); + + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + assert!(state.overlay.is_none(), "same announcement stays dismissed"); + endpoint_snapshot + .product_announcement + .as_mut() + .expect("announcement") + .id = "new-announcement".into(); + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + )); + state.chrome_drag = Some(ClientChromeDrag::ProductAnnouncementScrollbar { grab_row_offset: 0 }); + endpoint_snapshot.product_announcement = None; + state.set_snapshot(Box::new(endpoint_snapshot)); + assert!(state.overlay.is_none()); + assert!(state.chrome_drag.is_none()); + assert!(state.dismissed_product_announcement.is_none()); +} + +#[test] +fn failed_product_announcement_dismiss_reopens_authoritative_snapshot() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.product_announcement = + Some(crate::protocol::ClientShellProductAnnouncement { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "Client shell".into(), + body: "announcement".into(), + preview: false, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + let dismissed = state.handle_input_bytes(b"\r"); + let request_id = match &dismissed.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + actions => panic!("unexpected actions: {actions:?}"), + }; + + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("stale_announcement".into()), + message: "dismiss failed".into(), + }), + ); + assert!(repaint); + assert!(actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + )); + assert!(state.dismissed_product_announcement.is_none()); +} + +#[test] +fn release_notes_reconcile_and_failed_dismiss_reopens_authoritative_snapshot() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.3".into(), + body: "first notes".into(), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + state.open_release_notes(); + + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.4".into(), + body: "second notes".into(), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { ref version, .. } + )) if version == "0.8.4" + )); + + let dismissed = state.handle_input_bytes(b"\r"); + let request_id = match &dismissed.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + actions => panic!("unexpected actions: {actions:?}"), + }; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.5".into(), + body: "current notes".into(), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("stale_release_notes".into()), + message: "dismiss failed".into(), + }), + ); + assert!(repaint); + assert!(actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { ref version, .. } + )) if version == "0.8.5" + )); +} + +#[test] +fn product_announcement_mouse_is_modal_and_closes_only_from_its_button() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.product_announcement = + Some(crate::protocol::ClientShellProductAnnouncement { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "Client shell".into(), + body: (0..40) + .map(|index| format!("- line {index}")) + .collect::<Vec<_>>() + .join("\n"), + preview: false, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface_with_popup()); + state.compose(106, 30).expect("announcement frame"); + + let outside = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })]); + assert!(outside.requests.is_empty()); + assert!(outside.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + )); + + let metrics = state + .hits + .product_announcement_scroll_metrics + .expect("scroll metrics"); + let track = state.hits.product_announcement_scrollbar; + let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("thumb"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: thumb.top, + modifiers: KeyModifiers::NONE, + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::NONE, + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::NONE, + })]); + assert!(state.chrome_drag.is_none()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement( + crate::app::state::ProductAnnouncementState { scroll, .. } + )) if usize::from(scroll) == state.hits.product_announcement_max_scroll + )); + + let close = state.hits.overlay_primary; + let closed = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: close.x, + row: close.y, + modifiers: KeyModifiers::NONE, + })]); + assert!(state.overlay.is_none()); + assert!(matches!( + &closed.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::ProductAnnouncementDismiss(_)) + )); +} + +#[test] +fn onboarding_has_priority_over_endpoint_product_announcement() { + let config = ClientShellConfig::from_config(&Config::default()).with_startup_onboarding(true); + let mut state = ClientShellState::new(config); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.product_announcement = + Some(crate::protocol::ClientShellProductAnnouncement { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "Hidden until a later launch".into(), + body: "announcement body".into(), + preview: false, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Onboarding) + )); +} + +#[test] +fn startup_onboarding_is_client_rendered_and_modal() { + let config = ClientShellConfig::from_config(&Config::default()).with_startup_onboarding(true); + let mut state = ClientShellState::new(config); + let early = state.handle_input_bytes(b"\r"); + assert!(early.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Onboarding) + )); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + let frame = state.compose(106, 20).expect("onboarding frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("terminal workspace manager for coding agents")); + assert!(text.contains("this is a mouse-first terminal")); + assert!(text.contains("ctrl+b enters prefix mode")); + assert!(text.contains("install optional agent integrations")); + assert_eq!(state.hits.overlay_primary.width, 12); + + let ignored = state.handle_input_bytes(b"x"); + assert!(ignored.requests.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Onboarding) + )); + let outside = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })]); + assert!(outside.actions.is_empty()); + assert!(outside.requests.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Onboarding) + )); + + state.set_pane_surface(surface_with_popup()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Onboarding) + )); + let popup_input = state.handle_input_bytes(b"hidden-popup-input"); + assert!(popup_input.requests.is_empty()); + let popup_paste = state.handle_raw_events(vec![RawInputEvent::Paste("secret".into())]); + assert!(popup_paste.requests.is_empty()); +} + +#[test] +fn onboarding_completion_persists_and_opens_endpoint_integrations() { + let path = std::env::temp_dir().join(format!( + "herdr-client-onboarding-{}-{}.toml", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::write(&path, "[terminal]\ndefault_shell = \"fish\"\n") + .expect("write onboarding config"); + let onboarding_config = || { + let mut config = + ClientShellConfig::from_config(&Config::default()).with_startup_onboarding(true); + config.local_config_path = path.clone(); + config + }; + + let config = onboarding_config(); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let outcome = state.handle_input_bytes(b"\r"); + + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Integrations, + loading_integrations: true, + .. + })) + )); + assert!(matches!( + &outcome.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::IntegrationList(_)) + )); + let persisted = std::fs::read_to_string(&path).expect("read onboarding config"); + assert!(persisted.contains("onboarding = false")); + assert!(persisted.contains("default_shell = \"fish\"")); + + for input in [b"\x1b[C".as_slice(), b"l".as_slice()] { + let config = onboarding_config(); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let outcome = state.handle_input_bytes(input); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Integrations, + .. + })) + )); + assert_eq!(outcome.actions.len(), 1); + } + + let config = onboarding_config(); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("onboarding mouse frame"); + let button = state.hits.overlay_primary; + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: button.x, + row: button.y, + modifiers: KeyModifiers::NONE, + })]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Integrations, + .. + })) + )); + assert_eq!(click.actions.len(), 1); + + let unreadable_path = path.with_extension("dir"); + std::fs::create_dir(&unreadable_path).expect("create unreadable config path"); + let mut config = onboarding_config(); + config.local_config_path = unreadable_path.clone(); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let failed_write = state.handle_input_bytes(b"\r"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Integrations, + .. + })) + )); + assert_eq!(failed_write.actions.len(), 1); + assert!(state + .config_diagnostic + .as_deref() + .is_some_and(|diagnostic| diagnostic.contains("failed to read config"))); + assert!(unreadable_path.is_dir()); + std::fs::remove_dir(&unreadable_path).expect("remove unreadable config path"); + + std::fs::remove_file(path).expect("remove onboarding config"); +} + +#[test] +fn startup_config_diagnostics_are_client_rendered_and_persist_until_replaced() { + let config = ClientShellConfig::from_config(&Config::default()) + .with_startup_config_diagnostic(Some("local config warning".into())); + let mut state = ClientShellState::new(config); + let mut shared_snapshot = snapshot(); + shared_snapshot.config_diagnostic = Some("local config warning".into()); + state.set_snapshot(Box::new(shared_snapshot)); + assert_eq!( + state.config_diagnostic.as_deref(), + Some("client + endpoint: local config warning") + ); + + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.config_diagnostic = Some("endpoint config warning".into()); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + + let frame = state.compose(106, 20).expect("diagnostic frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("client: local config warning")); + assert!(text.contains("endpoint: endpoint config warning")); + + state.handle_input_bytes(b"x"); + assert!(state.config_diagnostic.is_some()); + + state.set_snapshot(Box::new(snapshot())); + assert_eq!( + state.config_diagnostic.as_deref(), + Some("local config warning") + ); +} + +#[test] +fn config_diagnostic_offsets_only_the_pane_rows_it_overlaps() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.toast_delay_seconds = 0; + let mut state = ClientShellState::new(config); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.config_diagnostic = Some("one-line warning".into()); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.visible_notification = Some(ClientVisibleNotification { + event: SemanticNotification { + kind: SemanticNotificationKind::Custom, + title: "notification".into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: Some(crate::config::ToastHerdrPosition::TopRight), + }, + deadline: std::time::Instant::now(), + }); + + state.compose(106, 20).expect("one-line frame"); + let pane_area = state.layout(106, 20).pane_surface; + assert_eq!(state.hits.notification_toast.y, pane_area.y); + let targetless_hit = state.hits.notification_toast; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: targetless_hit.x, + row: targetless_hit.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.visible_notification.is_some()); + + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.config_diagnostic = Some("first warning\nsecond warning".into()); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.compose(106, 20).expect("two-line frame"); + assert_eq!(state.hits.notification_toast.y, pane_area.y); + + state + .visible_notification + .as_mut() + .expect("visible notification") + .event + .position = Some(crate::config::ToastHerdrPosition::BottomRight); + state.compose(106, 20).expect("bottom notification frame"); + assert_eq!(state.hits.notification_toast.bottom(), 19); +} + +#[test] +fn endpoint_reload_result_does_not_override_snapshot_diagnostic_authority() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.config_diagnostic = Some("endpoint warning".into()); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.pending_requests.insert( + "reload-1".into(), + PendingEndpointRequest { + boot_id: "boot-1".into(), + confirmation_workspace_id: None, + kind: PendingEndpointKind::ReloadConfig, + }, + ); + + state.handle_endpoint_result( + "boot-1", + "reload-1", + Ok(crate::api::schema::ResponseResult::ConfigReload { + status: crate::config::ConfigReloadStatus::Partial, + diagnostics: vec!["keybinding warning".into()], + }), + ); + assert_eq!(state.config_diagnostic.as_deref(), Some("endpoint warning")); + + state.set_snapshot(Box::new(snapshot())); + assert!(state.config_diagnostic.is_none()); +} + +#[test] +fn endpoint_keybindings_hide_only_local_keybinding_diagnostics() { + let config = ClientShellConfig::from_config(&Config::default()) + .with_keybinding_source(ClientShellKeybindingSource::Endpoint); + let diagnostics = vec![ + "unsafe direct keybinding: keys.close_pane would intercept typing".into(), + "theme warning".into(), + ]; + + assert!(config.local_config_diagnostic(&diagnostics[..1]).is_none()); + assert!(config.local_config_diagnostic(&diagnostics).is_some()); +} + +#[test] +fn live_client_config_keeps_sound_diagnostics() { + let mut shell_config = ClientShellConfig::from_config(&Config::default()); + let mut config = Config::default(); + config.ui.sound.path = Some(std::path::PathBuf::from("invalid.wav")); + + let diagnostics = shell_config.apply_live_config(&config, &[], &[]); + assert!(diagnostics + .iter() + .any(|diagnostic| diagnostic.contains("expected an mp3 file"))); +} + +#[test] +fn update_ready_menu_opens_client_owned_release_notes_and_dismisses_by_version() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.update_available = Some("0.8.3".into()); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.3".into(), + body: (0..40) + .map(|index| format!("- release line {index}")) + .collect::<Vec<_>>() + .join("\n"), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + state.set_pane_surface(surface()); + let shell = state.compose(106, 30).expect("shell frame"); + assert_eq!(state.hits.global_launcher.width, 8); + let launcher = state.hits.global_launcher; + let shell_buffer = shell.to_ratatui_buffer().expect("shell buffer"); + let badge_x = launcher.right().saturating_sub(6); + assert_eq!( + shell_buffer[(badge_x, launcher.y)].fg, + state.config.palette.accent + ); + assert_eq!( + shell_buffer[(badge_x + 2, launcher.y)].fg, + state.config.palette.overlay0 + ); + + state.sidebar_collapsed = true; + let collapsed = state.compose(106, 30).expect("collapsed update shell"); + let collapsed_buffer = collapsed.to_ratatui_buffer().expect("collapsed buffer"); + assert_eq!( + collapsed_buffer[(state.hits.sidebar_toggle.x, state.hits.sidebar_toggle.y)].fg, + state.config.palette.accent + ); + state.sidebar_collapsed = false; + state.mode = ClientShellMode::Navigate; + let navigate = state.compose(106, 30).expect("navigate update status"); + let navigate_text = navigate + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(navigate_text.contains("update ready")); + state.mode = ClientShellMode::Prefix; + let prefix = state + .compose(106, 30) + .expect("prefix without update status"); + let prefix_text = prefix + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(!prefix_text.contains("update ready")); + state.mode = ClientShellMode::Navigate; + + state.toggle_global_menu(); + let menu = state.compose(106, 30).expect("update menu"); + let text = menu + .cells + .chunks(menu.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("● update ready")); + let update_row = state.hits.global_menu_rows[3].0; + assert_eq!(update_row.width, 16); + let menu_buffer = menu.to_ratatui_buffer().expect("menu buffer"); + assert_eq!( + menu_buffer[(update_row.x + 1, update_row.y)].fg, + state.config.palette.accent + ); + assert_eq!( + menu_buffer[(update_row.x + 3, update_row.y)].fg, + state.config.palette.text + ); + state.activate_global_menu_item(3, &mut ClientShellInput::default()); + let notes = state.compose(106, 30).expect("release notes"); + let bottom_row_start = usize::from(notes.width) * usize::from(notes.height - 1); + let bottom_row = notes.cells[bottom_row_start..] + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(!bottom_row.contains("NAVIGATE")); + let text = notes + .cells + .chunks(notes.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("v0.8.3")); + assert!(text.contains("update ready")); + assert!(text.contains("detach, run herdr update")); + assert!(!state.hits.release_notes_scrollbar.is_empty()); + let outer = crate::ui::centered_popup_rect( + Rect::new(0, 0, 106, 30), + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + ) + .expect("release notes outer"); + let inner = Rect::new(outer.x + 1, outer.y + 1, outer.width - 2, outer.height - 2); + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + assert_eq!( + state.hits.overlay_primary, + crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )) + ); + let notes_buffer = notes.to_ratatui_buffer().expect("release notes buffer"); + let title_cell = ¬es_buffer[(stack.header.x + 1, stack.header.y)]; + assert_eq!(title_cell.fg, state.config.palette.text); + assert!(title_cell.modifier.contains(Modifier::BOLD)); + assert_eq!( + notes_buffer[(state.hits.overlay_primary.x, state.hits.overlay_primary.y)].bg, + state.config.palette.accent + ); + + let outside = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })]); + assert!(outside.requests.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes(_)) + )); + let pane_text = state.handle_raw_events(vec![ + RawInputEvent::Text(crate::input::TextCommit::new("ime")), + RawInputEvent::Paste("secret".into()), + ]); + assert!(pane_text.requests.is_empty()); + + let metrics = state + .hits + .release_notes_scroll_metrics + .expect("release notes scroll metrics"); + let track = state.hits.release_notes_scrollbar; + let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("release notes thumb"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: thumb.top, + modifiers: KeyModifiers::NONE, + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::NONE, + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::NONE, + })]); + assert!(state.chrome_drag.is_none()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { scroll, .. } + )) if usize::from(scroll) == state.hits.release_notes_max_scroll + )); + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = state.overlay.as_mut() { + notes.scroll = 0; + } + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { scroll: 3, .. } + )) + )); + let repeated = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::PageDown, KeyModifiers::empty()) + .with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(repeated.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { scroll: 3, .. } + )) + )); + let dismissed = state.handle_input_bytes(b"\r"); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(matches!( + &dismissed.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::ReleaseNotesDismiss(params) + if params.version == "0.8.3" + ) + )); + + endpoint_snapshot.boot_id = "boot-2".into(); + endpoint_snapshot.revision = 2; + endpoint_snapshot.update_available = None; + endpoint_snapshot + .release_notes + .as_mut() + .expect("release notes") + .preview = false; + state.set_snapshot(Box::new(endpoint_snapshot)); + let mut installed_surface = surface(); + installed_surface.boot_id = "boot-2".into(); + installed_surface.projection_revision = 2; + state.set_pane_surface(installed_surface); + state.compose(106, 30).expect("installed shell"); + assert_eq!(state.hits.global_launcher.width, 6); + state.toggle_global_menu(); + let installed = state.compose(106, 30).expect("installed menu"); + let installed_text = installed + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(installed_text.contains("what's new")); + assert!(!installed_text.contains("● what's new")); +} + +#[test] +fn coalesced_release_notes_open_and_scroll_uses_current_geometry() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.update_available = Some("0.8.3".into()); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.3".into(), + body: (0..40) + .map(|index| format!("- release line {index}")) + .collect::<Vec<_>>() + .join("\n"), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("initial shell"); + state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 3, + })); + + state.handle_raw_events(vec![ + RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Enter, + KeyModifiers::empty(), + )), + RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::PageDown, + KeyModifiers::empty(), + )), + ]); + + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { scroll: 8, .. } + )) + )); +} + +#[test] +fn coalesced_release_notes_open_and_mouse_uses_current_geometry() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let body = (0..40) + .map(|index| format!("- release line {index}")) + .collect::<Vec<_>>() + .join("\n"); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.update_available = Some("0.8.3".into()); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.3".into(), + body: body.clone(), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("initial shell"); + + let outer = crate::ui::centered_popup_rect( + Rect::new(0, 0, 106, 30), + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + ) + .expect("release notes outer"); + let inner = Rect::new(outer.x + 1, outer.y + 1, outer.width - 2, outer.height - 2); + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + let notes = crate::app::state::ReleaseNotesState { + version: "0.8.3".into(), + body, + scroll: 0, + preview: true, + }; + let metrics = crate::ui::release_notes_scroll_metrics( + ¬es, + "herdr update", + stack.content, + &state.config.palette, + ); + let track = crate::ui::release_notes_scrollbar_rect(stack.content, metrics) + .expect("release notes track"); + let close = crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )); + + state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 3, + })); + state.handle_raw_events(vec![ + RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Enter, + KeyModifiers::empty(), + )), + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::NONE, + }), + ]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { scroll, .. } + )) if scroll > 0 + )); + + state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 3, + })); + let closed = state.handle_raw_events(vec![ + RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Enter, + KeyModifiers::empty(), + )), + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: close.x, + row: close.y, + modifiers: KeyModifiers::NONE, + }), + ]); + assert!(state.overlay.is_none()); + assert!(matches!( + &closed.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::ReleaseNotesDismiss(_)) + )); +} + +#[test] +fn outdated_integration_badges_launcher_settings_and_settings_tab() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.integration_updates_available = true; + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + let shell = state.compose(106, 30).expect("integration attention shell"); + assert_eq!(state.hits.global_launcher.width, 8); + let shell_text = shell + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(shell_text.contains("● menu")); + + state.toggle_global_menu(); + let menu = state.compose(106, 30).expect("integration attention menu"); + let menu_text = menu + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(menu_text.contains("● settings")); + assert!(!menu_text.contains("update ready")); + + state.activate_global_menu_item(0, &mut ClientShellInput::default()); + let settings = state.compose(106, 30).expect("settings integration badge"); + let settings_text = settings + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(settings_text.contains("● integrations")); + let integrations_tab = state + .hits + .settings_tabs + .iter() + .find(|(_, section)| *section == ClientSettingsSection::Integrations) + .map(|(rect, _)| *rect) + .expect("integrations tab"); + let settings_buffer = settings.to_ratatui_buffer().expect("settings buffer"); + assert_eq!( + settings_buffer[(integrations_tab.x + 1, integrations_tab.y)].fg, + state.config.palette.accent + ); + assert_eq!( + settings_buffer[(integrations_tab.x + 3, integrations_tab.y)].fg, + state.config.palette.overlay1 + ); +} + +#[test] +fn combined_update_and_integration_attention_preserves_both_badges() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.update_available = Some("0.8.3".into()); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.integration_updates_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.3".into(), + body: "### Changed\n- Both attention states".into(), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("combined attention shell"); + assert_eq!(state.hits.global_launcher.width, 8); + + state.toggle_global_menu(); + let menu = state.compose(106, 30).expect("combined attention menu"); + let text = menu + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + let settings = text.find("● settings").expect("settings badge"); + let update = text.find("● update ready").expect("update badge"); + assert!(settings < update); + + state.overlay = None; + state.mode = ClientShellMode::Navigate; + let navigate = state.compose(106, 30).expect("combined attention navigate"); + let navigate_text = navigate + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(navigate_text.contains("update ready")); +} + +#[test] +fn current_release_notes_use_whats_new_without_attention_badge() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.2".into(), + body: "### Changed\n- Client shell".into(), + preview: false, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("shell frame"); + assert_eq!(state.hits.global_launcher.width, 6); + state.toggle_global_menu(); + let menu = state.compose(106, 30).expect("what's new menu"); + let text = menu + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(text.contains("what's new")); +} + +#[test] +fn client_settings_preview_restore_and_endpoint_integrations_are_owned_by_overlay() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 0, + })); + let open = state.handle_input_bytes(b"\r"); + assert!(open.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Theme, + .. + })) + )); + let original_theme = state.config.theme_name.clone(); + let original_palette = state.config.palette.clone(); + state.handle_input_bytes(b"j"); + assert_ne!(state.config.theme_name, original_theme); + assert_ne!(state.config.palette.accent, original_palette.accent); + state.handle_input_bytes(b"\x1b"); + assert!(state.overlay.is_none()); + assert_eq!(state.config.theme_name, original_theme); + assert_eq!(state.config.palette.accent, original_palette.accent); + + state.open_settings_overlay(); + state.handle_input_bytes(b"j"); + state.handle_input_bytes(b"\t"); + state + .compose(106, 30) + .expect("settings outside-click geometry"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.overlay.is_none()); + assert_eq!(state.config.theme_name, original_theme); + assert_eq!(state.config.palette.accent, original_palette.accent); + + state.open_settings_overlay(); + state.compose(106, 30).expect("settings overlay"); + for _ in 0..3 { + let next = state.handle_input_bytes(b"\t"); + assert!(next.actions.is_empty()); + } + let integrations = state.handle_input_bytes(b"\t"); + let [ClientShellAction::Endpoint { request, .. }] = &integrations.actions[..] else { + panic!("integration section should request endpoint status"); + }; + assert!(matches!( + request.method, + crate::api::schema::Method::IntegrationList(_) + )); + let request_id = request.id.clone(); + assert!( + state + .handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::IntegrationList { + integrations: vec![ + crate::api::schema::IntegrationInfo { + target: crate::api::schema::IntegrationTarget::Codex, + label: "codex".into(), + command: "codex".into(), + available: true, + state: crate::api::schema::IntegrationState::Outdated, + }, + crate::api::schema::IntegrationInfo { + target: crate::api::schema::IntegrationTarget::Claude, + label: "claude".into(), + command: "claude".into(), + available: false, + state: crate::api::schema::IntegrationState::NotInstalled, + }, + ], + }), + ) + .0 + ); + let frame = state.compose(106, 30).expect("loaded integrations"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("update available")); + assert!(text.contains("not found")); + assert!(!text.contains("pane labels")); + + let popup = state.hits.settings_popup; + let blank_click = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: popup.right().saturating_sub(2), + row: popup.y + 3, + modifiers: KeyModifiers::empty(), + })]); + assert!(!blank_click.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(_)) + )); + + let install = state.handle_input_bytes(b"\r"); + assert_eq!(install.actions.len(), 1); + assert!(matches!( + &install.actions[0], + ClientShellAction::Endpoint { request, .. } + if matches!( + request.method, + crate::api::schema::Method::IntegrationInstall( + crate::api::schema::IntegrationInstallParams { + target: crate::api::schema::IntegrationTarget::Codex + } + ) + ) + )); + let escape = state.handle_input_bytes(b"\x1b"); + assert!(!escape.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(_)) + )); + let install_request_id = match &install.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!("integration install action"), + }; + let (repaint, refresh_actions) = state.handle_endpoint_result( + "boot-1", + &install_request_id, + Ok(crate::api::schema::ResponseResult::IntegrationInstall { + target: crate::api::schema::IntegrationTarget::Codex, + details: crate::api::schema::IntegrationInstallResult { + messages: vec!["installed codex".into()], + }, + }), + ); + assert!(repaint); + assert!(matches!( + refresh_actions.as_slice(), + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::IntegrationList(_)) + )); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + loading_integrations: true, + installing_integrations: false, + ref integration_messages, + .. + })) if integration_messages == &["installed codex"] + )); +} diff --git a/src/client/shell/worktree_overlays.rs b/src/client/shell/worktree_overlays.rs new file mode 100644 index 00000000..a2b5c65f --- /dev/null +++ b/src/client/shell/worktree_overlays.rs @@ -0,0 +1,410 @@ +use super::*; + +pub(super) fn render_worktree_create_overlay( + b: &mut Buffer, + create: &ClientWorktreeCreateOverlay, + p: &Palette, +) -> Option<OverlayRender> { + let popup = popup(b.area, 68, 12)?; + let inner = panel(b, popup, p.accent, p.panel_bg)?; + put_text( + b, + inner.x, + inner.y, + inner.width, + "new worktree", + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + b, + inner.x, + inner.y + 2, + inner.width, + " branch", + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + let input = Rect::new(inner.x, inner.y + 3, inner.width, 1); + b.set_style(input, Style::default().fg(p.text).bg(p.surface0)); + put_text( + b, + input.x, + input.y, + input.width, + &format!(" {}", create.branch), + Style::default().fg(p.text).bg(p.surface0), + ); + put_text( + b, + inner.x, + inner.y + 5, + inner.width, + " checkout", + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + put_text( + b, + inner.x, + inner.y + 6, + inner.width, + &format!(" {}", create.checkout_path), + Style::default().fg(p.subtext0).bg(p.panel_bg), + ); + if create.creating { + put_text( + b, + inner.x, + inner.y + 8, + inner.width, + " creating…", + Style::default().fg(p.accent).bg(p.panel_bg), + ); + } else if let Some(error) = create.error.as_deref() { + put_text( + b, + inner.x, + inner.y + 8, + inner.width, + &format!(" {error}"), + Style::default().fg(p.red).bg(p.panel_bg), + ); + } + let buttons = row(inner, &[20, 12], 2, 9); + let [primary, cancel] = buttons.as_slice() else { + return None; + }; + button( + b, + *primary, + " ↵ create and open ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + button( + b, + *cancel, + " esc cancel ", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ); + Some(OverlayRender { + primary: *primary, + clear: Rect::default(), + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: (!create.creating).then(|| crate::protocol::CursorState { + x: (input.x + 1 + display_width(&create.branch)).min(input.right() - 1), + y: input.y, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} + +pub(super) fn render_worktree_open_overlay( + b: &mut Buffer, + open: &ClientWorktreeOpenOverlay, + p: &Palette, +) -> Option<OverlayRender> { + let popup_height = (open.entries.len().saturating_mul(2) + 7).clamp(12, 26) as u16; + let popup = popup(b.area, 96, popup_height)?; + let inner = panel(b, popup, p.accent, p.panel_bg)?; + put_text( + b, + inner.x, + inner.y, + inner.width, + "open worktree", + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + let search = Rect::new(inner.x, inner.y + 1, inner.width, 1); + let filtered = open.filtered_indices(); + put_text( + b, + search.x, + search.y, + search.width, + &if open.search_focused || !open.query.is_empty() { + format!(" / {}", open.query) + } else { + " / filter worktrees".to_owned() + }, + Style::default() + .fg(if open.search_focused { + p.text + } else { + p.overlay0 + }) + .bg(p.panel_bg), + ); + let count = if filtered.len() == open.entries.len() { + format!("{} checkouts", open.entries.len()) + } else { + format!("{}/{} checkouts", filtered.len(), open.entries.len()) + }; + put_right_text( + b, + search, + search.y, + &count, + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + put_text( + b, + inner.x, + inner.y + 2, + inner.width, + &"─".repeat(inner.width as usize), + Style::default().fg(p.surface1).bg(p.panel_bg), + ); + let body = Rect::new( + inner.x, + inner.y + 3, + inner.width, + inner.height.saturating_sub(6), + ); + let visible_count = (body.height / 2).max(1) as usize; + let selected_position = filtered + .iter() + .position(|index| *index == open.selected) + .unwrap_or(0); + let start = selected_position + .saturating_sub(visible_count.saturating_sub(1)) + .min(filtered.len().saturating_sub(visible_count)); + let mut row_hits = Vec::new(); + for (visible, entry_index) in filtered + .iter() + .copied() + .skip(start) + .take(visible_count) + .enumerate() + { + let entry = &open.entries[entry_index]; + let rect = Rect::new(body.x, body.y + visible as u16 * 2, body.width, 2); + row_hits.push((rect, entry_index)); + let selected = entry_index == open.selected; + let style = if selected { + Style::default().fg(contrast(p)).bg(p.accent) + } else { + Style::default().fg(p.text).bg(p.panel_bg) + }; + b.set_style(rect, style); + put_text( + b, + rect.x, + rect.y, + rect.width, + &format!(" {}", entry.label), + style.add_modifier(Modifier::BOLD), + ); + let status = entry.status_label(); + if !status.is_empty() { + put_right_text(b, rect, rect.y, status, style); + } + put_text( + b, + rect.x, + rect.y + 1, + rect.width, + &format!(" {}", entry.path), + if selected { + style + } else { + Style::default().fg(p.overlay0).bg(p.panel_bg) + }, + ); + } + if filtered.is_empty() { + put_text( + b, + body.x, + body.y, + body.width, + " no matching worktrees", + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + } + if open.opening { + put_text( + b, + inner.x, + inner.bottom() - 3, + inner.width, + " opening…", + Style::default().fg(p.accent).bg(p.panel_bg), + ); + } else if let Some(error) = open.error.as_deref() { + put_text( + b, + inner.x, + inner.bottom() - 3, + inner.width, + &format!(" {error}"), + Style::default().fg(p.red).bg(p.panel_bg), + ); + } + let buttons = row(inner, &[10, 12], 2, inner.height.saturating_sub(1)); + let [primary, cancel] = buttons.as_slice() else { + return None; + }; + button( + b, + *primary, + " ↵ open ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + button( + b, + *cancel, + " esc cancel ", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ); + Some(OverlayRender { + primary: *primary, + clear: Rect::default(), + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: search, + worktree_rows: row_hits, + cursor: (open.search_focused && !open.opening).then(|| crate::protocol::CursorState { + x: (search.x + 3 + display_width(&open.query)).min(search.right() - 1), + y: search.y, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} + +pub(super) fn render_worktree_remove_overlay( + b: &mut Buffer, + remove: &ClientWorktreeRemoveOverlay, + p: &Palette, +) -> Option<OverlayRender> { + let popup = popup(b.area, 72, 10)?; + let inner = panel(b, popup, p.red, p.panel_bg)?; + put_text( + b, + inner.x, + inner.y, + inner.width, + " delete worktree checkout?", + Style::default() + .fg(p.red) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + b, + inner.x, + inner.y + 1, + inner.width, + " This removes the checkout folder:", + Style::default().fg(p.text).bg(p.panel_bg), + ); + put_text( + b, + inner.x, + inner.y + 2, + inner.width, + &format!(" {}", remove.path), + Style::default().fg(p.subtext0).bg(p.panel_bg), + ); + put_text( + b, + inner.x, + inner.y + 3, + inner.width, + " The branch is not deleted. The Herdr workspace will close.", + Style::default().fg(p.text).bg(p.panel_bg), + ); + if remove.force_confirmation { + put_text( + b, + inner.x, + inner.y + 4, + inner.width, + " Dirty or untracked files will be permanently deleted.", + Style::default().fg(p.red).bg(p.panel_bg), + ); + } + if remove.removing { + put_text( + b, + inner.x, + inner.y + 5, + inner.width, + " removing…", + Style::default().fg(p.accent).bg(p.panel_bg), + ); + } else if let Some(error) = remove.error.as_deref() { + put_text( + b, + inner.x, + inner.y + 5, + inner.width, + &format!(" {error}"), + Style::default().fg(p.red).bg(p.panel_bg), + ); + } + let buttons = row(inner, &[18, 12], 2, 7); + let [primary, cancel] = buttons.as_slice() else { + return None; + }; + button( + b, + *primary, + if remove.force_confirmation { + " ↵ delete anyway " + } else { + " ↵ remove " + }, + Style::default() + .fg(contrast(p)) + .bg(p.red) + .add_modifier(Modifier::BOLD), + ); + button( + b, + *cancel, + " esc cancel ", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ); + Some(OverlayRender { + primary: *primary, + clear: Rect::default(), + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: None, + ..OverlayRender::default() + }) +} diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs new file mode 100644 index 00000000..792cd248 --- /dev/null +++ b/src/client/shell/worktrees.rs @@ -0,0 +1,545 @@ +use super::*; + +impl ClientShellState { + fn endpoint_worktree_directory(&self) -> Option<std::path::PathBuf> { + self.snapshot + .as_deref() + .map(|snapshot| std::path::PathBuf::from(&snapshot.worktree_directory)) + } + + pub(super) fn insert_worktree_overlay_text(&mut self, text: &str) -> bool { + match self.overlay.as_mut() { + Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating => { + if create.replace_on_type { + create.branch.clear(); + create.replace_on_type = false; + } + create.branch.push_str(text); + self.sync_worktree_create_path(); + true + } + Some(ClientShellOverlay::WorktreeOpen(open)) + if open.search_focused && !open.opening => + { + open.query.push_str(text); + if let Some(first) = open.filtered_indices().first().copied() { + open.selected = first; + } + true + } + _ => false, + } + } + + pub(super) fn route_worktree_overlay_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) -> bool { + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + match self.overlay.as_ref() { + Some(ClientShellOverlay::WorktreeCreate(_)) => { + let creating = matches!( + self.overlay, + Some(ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { creating: true, .. } + )) + ); + match code { + KeyCode::Esc if !creating => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Enter => self.submit_worktree_create(outcome), + KeyCode::Backspace if !creating => { + if let Some(ClientShellOverlay::WorktreeCreate(create)) = + self.overlay.as_mut() + { + if create.replace_on_type { + create.branch.clear(); + create.replace_on_type = false; + } else { + create.branch.pop(); + } + } + self.sync_worktree_create_path(); + outcome.repaint = true; + } + KeyCode::Char(character) + if !creating + && modifiers + .difference(crossterm::event::KeyModifiers::SHIFT) + .is_empty() => + { + let text = key + .generated_text + .clone() + .unwrap_or_else(|| character.to_string()); + self.insert_worktree_overlay_text(&text); + outcome.repaint = true; + } + _ => {} + } + true + } + Some(ClientShellOverlay::WorktreeOpen(_)) => { + let opening = matches!( + self.overlay, + Some(ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { opening: true, .. } + )) + ); + let search_focused = matches!( + self.overlay, + Some(ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { + search_focused: true, + .. + } + )) + ); + match code { + KeyCode::Esc if !opening => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Enter => self.submit_worktree_open(outcome), + KeyCode::Up if !opening => { + self.move_worktree_open_selection(-1); + outcome.repaint = true; + } + KeyCode::Down if !opening => { + self.move_worktree_open_selection(1); + outcome.repaint = true; + } + KeyCode::Char('/') if !opening && !search_focused => { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() + { + open.search_focused = true; + } + outcome.repaint = true; + } + KeyCode::Backspace if !opening && search_focused => { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() + { + open.query.pop(); + if let Some(first) = open.filtered_indices().first().copied() { + open.selected = first; + } + } + outcome.repaint = true; + } + KeyCode::Char(character) + if !opening + && search_focused + && modifiers + .difference(crossterm::event::KeyModifiers::SHIFT) + .is_empty() => + { + let text = key + .generated_text + .clone() + .unwrap_or_else(|| character.to_string()); + self.insert_worktree_overlay_text(&text); + outcome.repaint = true; + } + _ => {} + } + true + } + Some(ClientShellOverlay::WorktreeRemove(_)) => { + let removing = matches!( + self.overlay, + Some(ClientShellOverlay::WorktreeRemove( + ClientWorktreeRemoveOverlay { removing: true, .. } + )) + ); + match code { + KeyCode::Esc if !removing => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Enter => self.submit_worktree_remove(outcome), + _ => {} + } + true + } + _ => false, + } + } + + pub(super) fn begin_worktree_action( + &mut self, + action: crate::input::KeybindAction, + outcome: &mut ClientShellInput, + ) { + let Some(workspace_id) = self.workspace_action_id() else { + return; + }; + self.begin_worktree_action_for(action, workspace_id, outcome); + } + + pub(super) fn begin_worktree_action_for( + &mut self, + action: crate::input::KeybindAction, + workspace_id: String, + outcome: &mut ClientShellInput, + ) { + use crate::api::schema::{Method, WorktreeListParams}; + use crate::input::KeybindAction; + + let workspace = self.snapshot.as_deref().and_then(|snapshot| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + }); + let linked = workspace + .and_then(|workspace| workspace.worktree.as_ref()) + .is_some_and(|worktree| worktree.is_linked_worktree); + let kind = match action { + KeybindAction::NewWorktree | KeybindAction::OpenWorktree if linked => { + self.endpoint_error = Some( + "New and open worktree actions start from the repo parent workspace." + .to_owned(), + ); + outcome.repaint = true; + return; + } + KeybindAction::NewWorktree => PendingEndpointKind::PrepareWorktreeCreate { + workspace_id: workspace_id.clone(), + }, + KeybindAction::OpenWorktree => PendingEndpointKind::PrepareWorktreeOpen { + workspace_id: workspace_id.clone(), + }, + KeybindAction::RemoveWorktree if !linked => { + self.endpoint_error = + Some("This workspace is not a Herdr-managed worktree checkout.".to_owned()); + outcome.repaint = true; + return; + } + KeybindAction::RemoveWorktree => PendingEndpointKind::PrepareWorktreeRemove { + workspace_id: workspace_id.clone(), + }, + _ => return, + }; + self.push_endpoint_method_with_kind( + Method::WorktreeList(WorktreeListParams { + workspace_id: Some(workspace_id), + cwd: None, + trust_repository: false, + }), + kind, + outcome, + ); + } + + pub(super) fn sync_worktree_create_path(&mut self) { + let Some(worktree_directory) = self.endpoint_worktree_directory() else { + return; + }; + let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() else { + return; + }; + create.checkout_path = crate::worktree::default_checkout_path( + &worktree_directory, + &create.repo_name, + &create.branch, + ) + .display() + .to_string(); + create.error = None; + } + + pub(super) fn submit_worktree_create(&mut self, outcome: &mut ClientShellInput) { + let Some(worktree_directory) = self.endpoint_worktree_directory() else { + return; + }; + let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() else { + return; + }; + if create.creating { + return; + } + let branch = create.branch.trim().to_owned(); + if branch.is_empty() { + create.error = Some("branch is required".to_owned()); + outcome.repaint = true; + return; + } + create.branch = branch.clone(); + create.replace_on_type = false; + create.checkout_path = + crate::worktree::default_checkout_path(&worktree_directory, &create.repo_name, &branch) + .display() + .to_string(); + create.creating = true; + create.error = None; + let workspace_id = create.source_workspace_id.clone(); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::WorktreeCreate(crate::api::schema::WorktreeCreateParams { + workspace_id: Some(workspace_id), + cwd: None, + branch: Some(branch), + base: Some("HEAD".to_owned()), + path: None, + label: None, + focus: true, + trust_repository: false, + }), + PendingEndpointKind::WorktreeCreate, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn move_worktree_open_selection(&mut self, delta: isize) { + let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() else { + return; + }; + let filtered = open.filtered_indices(); + if filtered.is_empty() { + open.selected = 0; + return; + } + let current = filtered + .iter() + .position(|index| *index == open.selected) + .unwrap_or(0); + let next = (current as isize + delta).clamp(0, filtered.len() as isize - 1) as usize; + open.selected = filtered[next]; + } + + pub(super) fn submit_worktree_open(&mut self, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() else { + return; + }; + if open.opening { + return; + } + let Some(index) = open.selected_entry_index() else { + return; + }; + let Some(entry) = open.entries.get(index) else { + return; + }; + let workspace_id = open.source_workspace_id.clone(); + let path = entry.path.clone(); + open.selected = index; + open.opening = true; + open.error = None; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::WorktreeOpen(crate::api::schema::WorktreeOpenParams { + workspace_id: Some(workspace_id), + cwd: None, + path: Some(path), + branch: None, + label: None, + focus: true, + trust_repository: false, + }), + PendingEndpointKind::WorktreeOpen, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn submit_worktree_remove(&mut self, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::WorktreeRemove(remove)) = self.overlay.as_mut() else { + return; + }; + if remove.removing { + return; + } + let workspace_id = remove.workspace_id.clone(); + let forced = remove.force_confirmation; + remove.removing = true; + remove.error = None; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::WorktreeRemove(crate::api::schema::WorktreeRemoveParams { + workspace_id, + force: forced, + trust_repository: false, + }), + PendingEndpointKind::WorktreeRemove { forced }, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn handle_worktree_endpoint_result( + &mut self, + kind: PendingEndpointKind, + result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>, + ) -> bool { + use crate::api::schema::ResponseResult; + + match (kind, result) { + ( + PendingEndpointKind::PrepareWorktreeCreate { workspace_id }, + Ok(ResponseResult::WorktreeList { source, .. }), + ) => { + let seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_micros().min(u128::from(u64::MAX)) as u64) + .unwrap_or(0); + let branch = crate::worktree::generated_branch_slug(seed); + let Some(worktree_directory) = self.endpoint_worktree_directory() else { + return false; + }; + let checkout_path = crate::worktree::default_checkout_path( + &worktree_directory, + &source.repo_name, + &branch, + ) + .display() + .to_string(); + self.overlay = Some(ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { + source_workspace_id: workspace_id, + repo_name: source.repo_name, + branch, + checkout_path, + replace_on_type: true, + error: None, + creating: false, + }, + )); + true + } + ( + PendingEndpointKind::PrepareWorktreeOpen { workspace_id }, + Ok(ResponseResult::WorktreeList { worktrees, .. }), + ) => { + let entries = worktrees + .into_iter() + .filter(|entry| !entry.is_bare && !entry.is_prunable) + .map(|entry| { + let label = entry.branch.clone().unwrap_or_else(|| entry.label.clone()); + ClientWorktreeOpenEntry { + path: entry.path, + branch: entry.branch, + is_linked_worktree: entry.is_linked_worktree, + is_detached: entry.is_detached, + open_workspace_id: entry.open_workspace_id, + label, + } + }) + .collect::<Vec<_>>(); + if entries.is_empty() { + self.endpoint_error = Some("No Git worktrees found for this repo.".to_owned()); + } else { + self.overlay = Some(ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { + source_workspace_id: workspace_id, + entries, + selected: 0, + query: String::new(), + search_focused: false, + error: None, + opening: false, + }, + )); + } + true + } + ( + PendingEndpointKind::PrepareWorktreeRemove { workspace_id }, + Ok(ResponseResult::WorktreeList { worktrees, .. }), + ) => { + let path = worktrees + .into_iter() + .find(|entry| entry.open_workspace_id.as_deref() == Some(&workspace_id)) + .map(|entry| entry.path); + if let Some(path) = path { + self.overlay = Some(ClientShellOverlay::WorktreeRemove( + ClientWorktreeRemoveOverlay { + workspace_id, + path, + error: None, + removing: false, + force_confirmation: false, + }, + )); + } else { + self.endpoint_error = + Some("This workspace is not a Herdr-managed worktree checkout.".to_owned()); + } + true + } + (PendingEndpointKind::WorktreeCreate, Ok(ResponseResult::WorktreeCreated { .. })) + | (PendingEndpointKind::WorktreeOpen, Ok(ResponseResult::WorktreeOpened { .. })) + | ( + PendingEndpointKind::WorktreeRemove { .. }, + Ok(ResponseResult::WorktreeRemoved { .. }), + ) => { + self.overlay = None; + true + } + (PendingEndpointKind::WorktreeCreate, Err(error)) => { + if let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() { + create.creating = false; + create.error = Some(error.message); + } + true + } + (PendingEndpointKind::WorktreeOpen, Err(error)) => { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() { + open.opening = false; + open.error = Some(error.message); + } + true + } + (PendingEndpointKind::WorktreeRemove { forced: false }, Err(error)) + if error.code.as_deref() == Some("dirty_worktree_requires_force") => + { + if let Some(ClientShellOverlay::WorktreeRemove(remove)) = self.overlay.as_mut() { + remove.removing = false; + remove.force_confirmation = true; + remove.error = None; + } + true + } + (PendingEndpointKind::WorktreeRemove { .. }, Err(error)) => { + if let Some(ClientShellOverlay::WorktreeRemove(remove)) = self.overlay.as_mut() { + remove.removing = false; + remove.error = Some(error.message); + } + true + } + ( + PendingEndpointKind::PrepareWorktreeCreate { .. } + | PendingEndpointKind::PrepareWorktreeOpen { .. } + | PendingEndpointKind::PrepareWorktreeRemove { .. }, + Err(error), + ) => { + self.endpoint_error = Some(error.message); + true + } + (_, Ok(_)) => { + self.endpoint_error = + Some("endpoint returned an unexpected worktree result".to_owned()); + true + } + ( + PendingEndpointKind::Generic + | PendingEndpointKind::ProductAnnouncementDismiss { .. } + | PendingEndpointKind::ReleaseNotesDismiss + | PendingEndpointKind::PopupCommand + | PendingEndpointKind::ReloadConfig + | PendingEndpointKind::IntegrationList + | PendingEndpointKind::IntegrationInstall + | PendingEndpointKind::SelectionCopy { .. } + | PendingEndpointKind::PaneScroll { .. } + | PendingEndpointKind::WordSelection { .. } + | PendingEndpointKind::PaneLinkActivate { .. } + | PendingEndpointKind::CopyMotion { .. } + | PendingEndpointKind::CopySearch { .. }, + Err(error), + ) => { + self.endpoint_error = Some(error.message); + true + } + } + } +} diff --git a/src/client/terminal_geometry.rs b/src/client/terminal_geometry.rs new file mode 100644 index 00000000..d1f305ab --- /dev/null +++ b/src/client/terminal_geometry.rs @@ -0,0 +1,238 @@ +use std::io; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +#[cfg(any(unix, test))] +use tracing::debug; + +use super::ClientLoopEvent; + +const DEFAULT_CELL_WIDTH_PX: u32 = 8; +const DEFAULT_CELL_HEIGHT_PX: u32 = 16; + +/// Average cell size derived from a terminal ioctl pixel extent. +/// +/// The extent need not divide evenly by the grid: terminals may include padding, +/// and pixel mouse coordinates retain the raw extent for proportional mapping. +pub(super) fn ioctl_cell_size( + columns: u16, + rows: u16, + width_px: u32, + height_px: u32, +) -> Option<(u32, u32)> { + if columns == 0 || rows == 0 || width_px == 0 || height_px == 0 { + return None; + } + Some(( + (width_px / u32::from(columns)).max(1), + (height_px / u32::from(rows)).max(1), + )) +} + +fn ioctl_terminal_geometry() -> Option<(u16, u16, u32, u32)> { + let size = crossterm::terminal::window_size().ok()?; + let (cell_width_px, cell_height_px) = ioctl_cell_size( + size.columns, + size.rows, + u32::from(size.width), + u32::from(size.height), + )?; + Some((size.columns, size.rows, cell_width_px, cell_height_px)) +} + +pub(super) fn cell_size_fallback(reported: u64, last: Option<(u32, u32)>) -> (u32, u32) { + unpack_cell_size(reported) + .or(last.filter(|(width, height)| *width > 0 && *height > 0)) + .unwrap_or((DEFAULT_CELL_WIDTH_PX, DEFAULT_CELL_HEIGHT_PX)) +} + +#[cfg(any(unix, test))] +pub(super) fn pack_cell_size(width_px: u32, height_px: u32) -> u64 { + (u64::from(width_px) << 32) | u64::from(height_px) +} + +fn unpack_cell_size(packed: u64) -> Option<(u32, u32)> { + let width_px = (packed >> 32) as u32; + let height_px = (packed & u64::from(u32::MAX)) as u32; + (width_px > 0 && height_px > 0).then_some((width_px, height_px)) +} + +fn current_terminal_geometry( + pixel_geometry_enabled: bool, + pixel_geometry_fallback: bool, + reported_cell_size: &AtomicU64, + last_cell_size: Option<(u32, u32)>, +) -> (u16, u16, u32, u32, bool) { + if !pixel_geometry_enabled { + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + return (cols, rows, 0, 0, false); + } + if let Some((cols, rows, cell_width_px, cell_height_px)) = ioctl_terminal_geometry() { + return (cols, rows, cell_width_px, cell_height_px, true); + } + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + if !pixel_geometry_fallback { + return (cols, rows, 0, 0, false); + } + let (cell_width_px, cell_height_px) = + cell_size_fallback(reported_cell_size.load(Ordering::Acquire), last_cell_size); + (cols, rows, cell_width_px, cell_height_px, false) +} + +/// Reads terminal geometry before the handshake. Pixel input and direct graphics +/// are eligible only when one ioctl supplied a coherent exact geometry snapshot. +pub(super) fn initial_terminal_geometry( + pixel_geometry_enabled: bool, + pixel_geometry_fallback: bool, +) -> (u16, u16, u32, u32, bool) { + if !pixel_geometry_enabled { + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + return (cols, rows, 0, 0, false); + } + match ioctl_terminal_geometry() { + Some((cols, rows, width, height)) => (cols, rows, width, height, true), + None => { + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + if pixel_geometry_fallback { + ( + cols, + rows, + DEFAULT_CELL_WIDTH_PX, + DEFAULT_CELL_HEIGHT_PX, + false, + ) + } else { + (cols, rows, 0, 0, false) + } + } + } +} + +pub(super) fn resize_report_required( + signalled: bool, + new_size: (u16, u16, u32, u32, bool), + last_size: (u16, u16, u32, u32, bool), +) -> bool { + signalled || new_size != last_size +} + +/// Watches the terminal size and sends resize events when it changes. +/// +/// The baseline cell size must match what the handshake sent to the server: +/// reading a fresh one here would race the host cell size reply and could +/// swallow the first change. +#[allow(clippy::too_many_arguments)] // The arguments are one immutable launch snapshot, not shared state. +pub(super) fn resize_poll_loop( + resize_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>, + initial_cols: u16, + initial_rows: u16, + initial_cell_width: u32, + initial_cell_height: u32, + initial_pixel_geometry_exact: bool, + pixel_geometry_enabled: bool, + pixel_geometry_fallback: bool, + reported_cell_size: &AtomicU64, + should_quit: &Arc<AtomicBool>, +) { + crate::platform::watch_terminal_resize_signal(); + let mut last_size = ( + initial_cols, + initial_rows, + initial_cell_width, + initial_cell_height, + initial_pixel_geometry_exact, + ); + while !should_quit.load(Ordering::Acquire) { + std::thread::sleep(Duration::from_millis(100)); + let signalled = crate::platform::take_terminal_resize_signal(); + let new_size = current_terminal_geometry( + pixel_geometry_enabled, + pixel_geometry_fallback, + reported_cell_size, + Some((last_size.2, last_size.3)), + ); + if resize_report_required(signalled, new_size, last_size) { + last_size = new_size; + if resize_tx + .blocking_send(ClientLoopEvent::Resize( + new_size.0, new_size.1, new_size.2, new_size.3, new_size.4, + )) + .is_err() + { + break; + } + } + } +} + +#[cfg(any(not(windows), test))] +pub(super) fn query_host_terminal_appearance() { + let _ = write_host_terminal_appearance_query(io::stdout()); +} + +#[cfg(any(not(windows), test))] +pub(super) fn write_host_terminal_appearance_query(mut writer: impl io::Write) -> io::Result<()> { + writer.write_all(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())?; + writer.flush() +} + +pub(super) fn query_host_terminal_theme() { + let _ = write_host_terminal_theme_query(io::stdout()); +} + +pub(super) fn should_query_host_terminal_theme() -> bool { + !cfg!(windows) +} + +pub(super) fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> { + let query = crate::terminal_theme::host_terminal_theme_query_sequence( + crate::platform::should_query_host_terminal_palette(), + ); + writer.write_all(query.as_bytes())?; + writer.flush() +} + +const HOST_CELL_SIZE_QUERY: &[u8] = b"\x1b[16t"; + +pub(super) fn query_host_cell_size() { + let _ = write_host_cell_size_query(io::stdout()); +} + +pub(super) fn should_query_host_cell_size() -> bool { + !cfg!(windows) +} + +pub(super) fn host_cell_size_query_required(kitty_graphics_enabled: bool) -> bool { + kitty_graphics_enabled && should_query_host_cell_size() && ioctl_terminal_geometry().is_none() +} + +pub(super) fn write_host_cell_size_query(mut writer: impl io::Write) -> io::Result<()> { + writer.write_all(HOST_CELL_SIZE_QUERY)?; + writer.flush() +} + +#[cfg(any(unix, test))] +pub(super) fn store_reported_cell_size( + reported_cell_size: &AtomicU64, + width_px: u32, + height_px: u32, +) { + let packed = pack_cell_size(width_px, height_px); + if reported_cell_size.swap(packed, Ordering::AcqRel) != packed { + debug!(width_px, height_px, "host terminal reported cell size"); + } +} + +#[cfg(any(unix, test))] +pub(super) fn reported_cell_size_from_events( + events: &[crate::raw_input::RawInputEvent], +) -> Option<(u32, u32)> { + events.iter().rev().find_map(|event| match event { + crate::raw_input::RawInputEvent::HostCellSizeReport { + width_px, + height_px, + } => Some((*width_px, *height_px)), + _ => None, + }) +} diff --git a/src/client/terminal_sessions.rs b/src/client/terminal_sessions.rs new file mode 100644 index 00000000..7d7bcc8f --- /dev/null +++ b/src/client/terminal_sessions.rs @@ -0,0 +1,265 @@ +use std::io::{self, BufRead, Write as _}; + +use base64::Engine; +use interprocess::local_socket::traits::Stream as _; +use interprocess::TryClone as _; +use tracing::info; + +use crate::ipc::LocalStream; +use crate::protocol::{ + self, AttachScrollDirection, AttachScrollSource, ClientMessage, RenderEncoding, ServerMessage, + MAX_GRAPHICS_FRAME_SIZE, +}; +use crate::server::socket_paths::client_socket_path; + +use super::{do_handshake, init_logging, write_to_server, ClientError}; + +/// Runs a read-only terminal session observer and prints one JSON envelope per frame. +pub fn run_terminal_session_observe(target: String, cols: u16, rows: u16) -> io::Result<()> { + let mut stream = + connect_terminal_session_stream(target.clone(), cols, rows, "observing terminal session")?; + write_to_server(&mut stream, &ClientMessage::ObserveTerminal { target })?; + write_terminal_session_output(stream) +} + +/// Runs a writable terminal session controller. +pub fn run_terminal_session_control( + target: String, + takeover: bool, + cols: u16, + rows: u16, +) -> io::Result<()> { + let mut stream = connect_terminal_session_stream( + target.clone(), + cols, + rows, + "controlling terminal session", + )?; + write_to_server( + &mut stream, + &ClientMessage::ControlTerminal { target, takeover }, + )?; + + let mut write_stream = stream.try_clone()?; + let _input_thread = std::thread::spawn(move || { + let stdin = io::stdin(); + for line in stdin.lock().lines() { + let Ok(line) = line else { + break; + }; + if line.trim().is_empty() { + continue; + } + match terminal_control_command_from_json(&line) { + Ok(message) => { + let release = matches!(message, ClientMessage::Detach); + if write_to_server(&mut write_stream, &message).is_err() { + return; + } + if release { + return; + } + } + Err(err) => eprintln!("herdr: terminal session control input ignored: {err}"), + } + } + let _ = write_to_server(&mut write_stream, &ClientMessage::Detach); + }); + + write_terminal_session_output(stream) +} + +fn connect_terminal_session_stream( + target: String, + cols: u16, + rows: u16, + log_message: &'static str, +) -> io::Result<LocalStream> { + init_logging(); + + let socket_path = client_socket_path(); + crate::logging::startup("client"); + info!(path = %socket_path.display(), target = %target, cols, rows, "{log_message}"); + + let mut stream = match crate::ipc::connect_local_stream(&socket_path) { + Ok(stream) => stream, + Err(err) => { + eprintln!("herdr: {}", ClientError::ConnectionFailed(err)); + std::process::exit(1); + } + }; + + match do_handshake(&mut stream, cols, rows, 0, 0, false, None, false, false) { + Ok(RenderEncoding::TerminalAnsi) => {} + Ok(encoding) => { + eprintln!( + "herdr: terminal session observe negotiated unsupported encoding {encoding:?}" + ); + std::process::exit(1); + } + Err(err) => { + eprintln!("herdr: {err}"); + std::process::exit(1); + } + } + + stream.set_nonblocking(false)?; + Ok(stream) +} + +fn write_terminal_session_output(mut stream: LocalStream) -> io::Result<()> { + let mut stdout = io::stdout().lock(); + loop { + match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE) { + Ok(ServerMessage::Terminal(frame)) => { + let encoded = base64::engine::general_purpose::STANDARD.encode(&frame.bytes); + let line = serde_json::json!({ + "type": "terminal.frame", + "seq": frame.seq, + "encoding": "ansi", + "width": frame.width, + "height": frame.height, + "full": frame.full, + "bytes": encoded, + }); + serde_json::to_writer(&mut stdout, &line)?; + stdout.write_all(b"\n")?; + stdout.flush()?; + } + Ok(ServerMessage::ServerShutdown { reason }) => { + let line = serde_json::json!({ + "type": "terminal.closed", + "reason": reason, + }); + serde_json::to_writer(&mut stdout, &line)?; + stdout.write_all(b"\n")?; + stdout.flush()?; + return Ok(()); + } + Ok(ServerMessage::Graphics { .. }) => {} + Ok(_) => {} + Err(protocol::FramingError::UnexpectedEof) => return Ok(()), + Err(err) => return Err(io::Error::other(err.to_string())), + } + } +} + +#[derive(serde::Deserialize)] +#[serde(tag = "type")] +enum TerminalControlCommand { + #[serde(rename = "terminal.input")] + Input { + text: Option<String>, + bytes: Option<String>, + }, + #[serde(rename = "terminal.resize")] + Resize { + cols: u16, + rows: u16, + #[serde(default)] + cell_width_px: u32, + #[serde(default)] + cell_height_px: u32, + }, + #[serde(rename = "terminal.scroll")] + Scroll { + direction: TerminalControlScrollDirection, + lines: u16, + #[serde(default)] + source: TerminalControlScrollSource, + #[serde(default)] + column: Option<u16>, + #[serde(default)] + row: Option<u16>, + #[serde(default)] + modifiers: u8, + }, + #[serde(rename = "terminal.release")] + Release {}, +} + +#[derive(Clone, Copy, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +enum TerminalControlScrollDirection { + Up, + Down, +} + +#[derive(Clone, Copy, Default, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +enum TerminalControlScrollSource { + #[default] + Wheel, + PageKey, +} + +pub(super) fn terminal_control_command_from_json(raw: &str) -> Result<ClientMessage, String> { + let command = serde_json::from_str::<TerminalControlCommand>(raw) + .map_err(|err| format!("invalid json command: {err}"))?; + match command { + TerminalControlCommand::Input { text, bytes } => { + let data = match (text, bytes) { + (Some(_), Some(_)) => { + return Err("terminal.input accepts text or bytes, not both".into()) + } + (Some(text), None) => text.into_bytes(), + (None, Some(bytes)) => base64::engine::general_purpose::STANDARD + .decode(bytes) + .map_err(|err| format!("invalid terminal.input bytes: {err}"))?, + (None, None) => Vec::new(), + }; + Ok(ClientMessage::Input { data }) + } + TerminalControlCommand::Resize { + cols, + rows, + cell_width_px, + cell_height_px, + } => { + if cols == 0 || rows == 0 { + return Err("terminal.resize cols and rows must be greater than 0".into()); + } + Ok(ClientMessage::Resize { + cols, + rows, + cell_width_px, + cell_height_px, + pixel_mouse: false, + }) + } + TerminalControlCommand::Scroll { + direction, + lines, + source, + column, + row, + modifiers, + } => { + if lines == 0 { + return Err("terminal.scroll lines must be greater than 0".into()); + } + let direction = match direction { + TerminalControlScrollDirection::Up => AttachScrollDirection::Up, + TerminalControlScrollDirection::Down => AttachScrollDirection::Down, + }; + let source = match source { + TerminalControlScrollSource::Wheel => AttachScrollSource::Wheel, + TerminalControlScrollSource::PageKey => AttachScrollSource::PageKey { + input: match direction { + AttachScrollDirection::Up => b"\x1b[5~".to_vec(), + AttachScrollDirection::Down => b"\x1b[6~".to_vec(), + }, + }, + }; + Ok(ClientMessage::AttachScroll { + source, + direction, + lines, + column, + row, + modifiers, + }) + } + TerminalControlCommand::Release {} => Ok(ClientMessage::Detach), + } +} diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs new file mode 100644 index 00000000..9c035346 --- /dev/null +++ b/src/client/terminal_setup.rs @@ -0,0 +1,445 @@ +//! Terminal setup and restoration for the rendered client. + +use std::io::{self, Write as _}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crossterm::event::{ + DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, + EnableFocusChange, EnableMouseCapture, +}; +#[cfg(not(windows))] +use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}; +use crossterm::execute; +use crossterm::terminal::{DisableLineWrap, EnableLineWrap}; + +use super::frame_output::clear_received_kitty_graphics; +use super::terminal_geometry::should_query_host_terminal_theme; + +// --------------------------------------------------------------------------- +// Terminal setup / restore +// --------------------------------------------------------------------------- + +/// Sets up the terminal for client mode (raw mode, optional mouse, keyboard enhancements). +/// +/// Returns a guard that restores the terminal when dropped. +pub(super) fn setup_terminal(mouse_capture: bool) -> io::Result<TerminalGuard> { + setup_terminal_with_capabilities(true, mouse_capture) +} + +/// Sets up a direct attach terminal. +/// +/// Direct attach forwards stdin to the attached PTY. When configured, mouse +/// capture lets wheel events drive the attached viewport or reach child +/// programs that requested mouse input. +pub(super) fn setup_direct_attach_terminal(mouse_capture: bool) -> io::Result<TerminalGuard> { + setup_terminal_with_capabilities(false, mouse_capture) +} + +pub(super) fn setup_terminal_with_capabilities( + enable_client_protocols: bool, + mouse_capture: bool, +) -> io::Result<TerminalGuard> { + ratatui::init(); + crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; + let host_color_scheme_reports = + should_enable_host_color_scheme_reports(enable_client_protocols); + + #[cfg(windows)] + let windows_ssh_session = is_ssh_session(); + #[cfg(windows)] + let mut windows_virtual_terminal_input = + if windows_vti_input_backend_enabled() && windows_ssh_session { + enable_windows_virtual_terminal_input() + } else { + WindowsVirtualTerminalInputSetup::default() + }; + + if enable_client_protocols { + set_mouse_capture(mouse_capture, false)?; + execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?; + if host_color_scheme_reports { + write_host_color_scheme_report_mode(&mut io::stdout(), true)?; + } + push_keyboard_enhancement_flags()?; + } else { + if should_query_host_terminal_theme() { + write_host_color_scheme_report_mode(&mut io::stdout(), false)?; + } + set_mouse_capture(mouse_capture, false)?; + execute!(io::stdout(), EnableBracketedPaste)?; + } + + #[cfg(windows)] + if enable_client_protocols && windows_vti_input_backend_enabled() && !windows_ssh_session { + windows_virtual_terminal_input = enable_windows_virtual_terminal_input(); + } + + #[cfg(windows)] + if enable_client_protocols + && windows_vti_input_backend_enabled() + && windows_virtual_terminal_input.active + && windows_win32_input_mode_enabled() + { + if let Err(err) = enable_windows_win32_input_mode(&mut io::stdout()) { + if let Some(mode) = windows_virtual_terminal_input.restore_mode { + restore_windows_input_mode_value(mode); + } + return Err(err); + } + } + + let modify_other_keys_mode = enable_client_protocols + .then(crate::input::host_modify_other_keys_mode) + .flatten(); + if let Some(mode) = modify_other_keys_mode { + io::stdout().write_all(mode.set_sequence())?; + io::stdout().flush()?; + } + + execute!(io::stdout(), DisableLineWrap)?; + + Ok(TerminalGuard { + reset_keyboard_enhancements: enable_client_protocols, + reset_modify_other_keys: modify_other_keys_mode.is_some(), + reset_host_color_scheme_reports: host_color_scheme_reports, + restore_claimed: Arc::new(AtomicBool::new(false)), + restored: false, + #[cfg(windows)] + restore_windows_input_mode: windows_virtual_terminal_input.restore_mode, + }) +} + +pub(super) fn should_enable_host_color_scheme_reports(enable_client_protocols: bool) -> bool { + enable_client_protocols && should_query_host_terminal_theme() +} + +/// Guard that restores the terminal when dropped. +pub(super) struct TerminalGuard { + reset_keyboard_enhancements: bool, + reset_modify_other_keys: bool, + reset_host_color_scheme_reports: bool, + restore_claimed: Arc<AtomicBool>, + restored: bool, + #[cfg(windows)] + restore_windows_input_mode: Option<u32>, +} + +pub(super) fn write_host_color_scheme_report_mode( + writer: &mut impl io::Write, + enabled: bool, +) -> io::Result<()> { + let sequence = if enabled { + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE + } else { + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE + }; + writer.write_all(sequence.as_bytes())?; + writer.flush() +} + +pub(super) fn write_terminal_restore_postlude( + writer: &mut impl io::Write, + reset_host_color_scheme_reports: bool, +) -> io::Result<()> { + if reset_host_color_scheme_reports { + writer.write_all( + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), + )?; + } + // Restore a visible cursor and reset DECSCUSR back to the terminal default. + writer.write_all(b"\x1b[?25h\x1b[0 q")?; + writer.flush() +} + +pub(super) fn should_draw_host_cursor(mode: crate::config::HostCursorModeConfig) -> bool { + match mode { + crate::config::HostCursorModeConfig::Auto => { + crate::platform::should_draw_host_cursor_by_default() + } + crate::config::HostCursorModeConfig::Native => false, + crate::config::HostCursorModeConfig::Drawn => true, + } +} + +#[cfg(windows)] +#[derive(Default)] +pub(super) struct WindowsVirtualTerminalInputSetup { + active: bool, + restore_mode: Option<u32>, +} + +#[cfg(windows)] +pub(super) fn enable_windows_virtual_terminal_input() -> WindowsVirtualTerminalInputSetup { + use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Console::{ + GetConsoleMode, GetStdHandle, SetConsoleMode, ENABLE_VIRTUAL_TERMINAL_INPUT, + STD_INPUT_HANDLE, + }; + + let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + tracing::warn!("failed to get Windows console input handle for VT input"); + return WindowsVirtualTerminalInputSetup::default(); + } + + let mut mode = 0; + if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { + tracing::warn!("failed to read Windows console input mode for VT input"); + return WindowsVirtualTerminalInputSetup::default(); + } + + let desired = windows_virtual_terminal_input_mode(mode); + if desired == mode { + return WindowsVirtualTerminalInputSetup { + active: true, + restore_mode: None, + }; + } + + if unsafe { SetConsoleMode(handle, desired) } == 0 { + tracing::warn!("failed to enable Windows virtual terminal input"); + return WindowsVirtualTerminalInputSetup::default(); + } + + let mut applied = 0; + if unsafe { GetConsoleMode(handle, &mut applied) } == 0 { + tracing::warn!("failed to verify Windows virtual terminal input mode"); + let _ = unsafe { SetConsoleMode(handle, mode) }; + return WindowsVirtualTerminalInputSetup::default(); + } + if applied & ENABLE_VIRTUAL_TERMINAL_INPUT == 0 { + tracing::warn!("Windows virtual terminal input bit did not stick"); + let _ = unsafe { SetConsoleMode(handle, mode) }; + return WindowsVirtualTerminalInputSetup::default(); + } + + WindowsVirtualTerminalInputSetup { + active: true, + restore_mode: Some(mode), + } +} + +pub(super) fn is_ssh_session() -> bool { + std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some() +} + +#[cfg(windows)] +pub(super) fn windows_vti_input_backend_enabled() -> bool { + std::env::var("HERDR_WINDOWS_INPUT_BACKEND") + .map(|backend| !backend.eq_ignore_ascii_case("crossterm")) + .unwrap_or(true) +} + +#[cfg(any(windows, test))] +pub(super) fn windows_virtual_terminal_input_mode(mode: u32) -> u32 { + mode | 0x0200 +} + +#[cfg(windows)] +fn restore_windows_input_mode_value(mode: u32) { + use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Console::{GetStdHandle, SetConsoleMode, STD_INPUT_HANDLE}; + + let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return; + } + if unsafe { SetConsoleMode(handle, mode) } == 0 { + tracing::warn!("failed to restore Windows console input mode"); + } +} + +pub(super) fn effective_mouse_capture( + server_enabled: bool, + direct_attach_preference: bool, +) -> bool { + server_enabled || direct_attach_preference +} + +pub(super) fn effective_sgr_pixel_mouse( + enabled: bool, + requested: bool, + exact_geometry: bool, +) -> bool { + enabled && requested && exact_geometry +} + +pub(super) fn set_mouse_capture(enabled: bool, sgr_pixels: bool) -> io::Result<()> { + crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; + #[cfg(windows)] + if is_ssh_session() && windows_vti_input_backend_enabled() { + return crate::terminal_modes::set_windows_ssh_mouse_reporting( + &mut io::stdout(), + enabled, + sgr_pixels, + ); + } + if enabled { + execute!(io::stdout(), EnableMouseCapture)?; + if sgr_pixels { + io::stdout().write_all(b"\x1b[?1016h")?; + io::stdout().flush()?; + } + Ok(()) + } else { + match execute!(io::stdout(), DisableMouseCapture) { + Ok(()) => Ok(()), + #[cfg(windows)] + Err(err) if err.to_string() == "Initial console modes not set" => Ok(()), + Err(err) => Err(err), + } + } +} + +fn restore_terminal_state_once( + restore_claimed: &AtomicBool, + reset_keyboard_enhancements: bool, + reset_modify_other_keys: bool, + reset_host_color_scheme_reports: bool, + #[cfg(windows)] restore_windows_input_mode: Option<u32>, +) -> io::Result<()> { + if restore_claimed.swap(true, Ordering::AcqRel) { + return Ok(()); + } + restore_terminal_state( + reset_keyboard_enhancements, + reset_modify_other_keys, + reset_host_color_scheme_reports, + #[cfg(windows)] + restore_windows_input_mode, + ) +} + +fn restore_terminal_state( + reset_keyboard_enhancements: bool, + reset_modify_other_keys: bool, + reset_host_color_scheme_reports: bool, + #[cfg(windows)] restore_windows_input_mode: Option<u32>, +) -> io::Result<()> { + let _ = clear_received_kitty_graphics(&mut io::stdout()); + + // Reset modifyOtherKeys if we enabled it. + if reset_modify_other_keys { + let _ = io::stdout().write_all(b"\x1b[>4;0m"); + let _ = io::stdout().flush(); + } + + if reset_keyboard_enhancements { + let _ = pop_keyboard_enhancement_flags(); + } + + let _ = execute!( + io::stdout(), + EnableLineWrap, + DisableFocusChange, + DisableBracketedPaste + ); + let _ = set_mouse_capture(false, false); + #[cfg(windows)] + if let Some(mode) = restore_windows_input_mode { + restore_windows_input_mode_value(mode); + } + + let restore_result = ratatui::try_restore(); + let postlude_result = + write_terminal_restore_postlude(&mut io::stdout(), reset_host_color_scheme_reports); + + #[cfg(windows)] + if windows_vti_input_backend_enabled() && windows_win32_input_mode_enabled() { + let _ = disable_windows_win32_input_mode(&mut io::stdout()); + } + + restore_result.and(postlude_result) +} + +#[cfg(not(windows))] +fn push_keyboard_enhancement_flags() -> io::Result<()> { + execute!( + io::stdout(), + PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags()) + ) +} + +#[cfg(windows)] +fn push_keyboard_enhancement_flags() -> io::Result<()> { + Ok(()) +} + +#[cfg(not(windows))] +fn pop_keyboard_enhancement_flags() -> io::Result<()> { + execute!(io::stdout(), PopKeyboardEnhancementFlags) +} + +#[cfg(windows)] +fn pop_keyboard_enhancement_flags() -> io::Result<()> { + Ok(()) +} + +#[cfg(windows)] +fn windows_win32_input_mode_enabled() -> bool { + std::env::var("HERDR_WINDOWS_INPUT_PROBE") + .map(|probe| probe.eq_ignore_ascii_case("win32")) + .unwrap_or(true) +} + +#[cfg(windows)] +fn enable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> { + writer.write_all(b"\x1b[?9001h")?; + writer.flush() +} + +#[cfg(windows)] +fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> { + writer.write_all(b"\x1b[?9001l")?; + writer.flush() +} + +impl TerminalGuard { + /// Captures the restoration state for use by the process panic hook. + pub(super) fn panic_restore(&self) -> impl Fn() + Send + Sync + 'static { + let restore_claimed = self.restore_claimed.clone(); + let reset_keyboard_enhancements = self.reset_keyboard_enhancements; + let reset_modify_other_keys = self.reset_modify_other_keys; + let reset_host_color_scheme_reports = self.reset_host_color_scheme_reports; + #[cfg(windows)] + let restore_windows_input_mode = self.restore_windows_input_mode; + move || { + let _ = restore_terminal_state_once( + &restore_claimed, + reset_keyboard_enhancements, + reset_modify_other_keys, + reset_host_color_scheme_reports, + #[cfg(windows)] + restore_windows_input_mode, + ); + } + } + + pub(super) fn restore(mut self) -> io::Result<()> { + self.restored = true; + restore_terminal_state_once( + &self.restore_claimed, + self.reset_keyboard_enhancements, + self.reset_modify_other_keys, + self.reset_host_color_scheme_reports, + #[cfg(windows)] + self.restore_windows_input_mode, + ) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + if !self.restored { + let _ = restore_terminal_state_once( + &self.restore_claimed, + self.reset_keyboard_enhancements, + self.reset_modify_other_keys, + self.reset_host_color_scheme_reports, + #[cfg(windows)] + self.restore_windows_input_mode, + ); + } + } +} diff --git a/src/client/tests/mod.rs b/src/client/tests/mod.rs new file mode 100644 index 00000000..d8f71a84 --- /dev/null +++ b/src/client/tests/mod.rs @@ -0,0 +1,823 @@ +use super::*; +use std::ffi::OsString; +use std::sync::{Mutex, OnceLock}; + +fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +#[test] +fn resize_signal_reports_even_when_polled_size_is_unchanged() { + let size = (120, 40, 8, 16, true); + assert!(resize_report_required(true, size, size)); + assert!(!resize_report_required(false, size, size)); + assert!(resize_report_required(false, (120, 41, 8, 16, true), size)); + assert!(resize_report_required(false, (120, 40, 9, 18, true), size)); + assert!(resize_report_required(false, (120, 40, 8, 16, false), size)); +} + +#[test] +fn direct_graphics_profile_is_narrow_and_transport_safe() { + for (program, term, kitty, expected) in [ + ("ghostty", "", false, true), + ("WezTerm", "", false, true), + ("", "xterm-kitty", false, true), + ("", "xterm-256color", true, true), + ("", "xterm-256color", false, false), + ] { + assert_eq!( + direct_graphics_profile_values(program, term, kitty, false, true), + expected + ); + } + assert!(!direct_graphics_profile_values( + "ghostty", "", false, true, true + )); + assert!(!direct_graphics_profile_values( + "ghostty", "", false, false, false + )); +} + +fn restore_env_var(key: &str, value: Option<OsString>) { + if let Some(value) = value { + std::env::set_var(key, value); + } else { + std::env::remove_var(key); + } +} + +struct EnvVarGuard { + key: &'static str, + previous: Option<OsString>, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + restore_env_var(self.key, self.previous.clone()); + } +} + +#[test] +fn windows_virtual_terminal_input_mode_sets_only_vti_bit() { + assert_eq!(windows_virtual_terminal_input_mode(0x01f0), 0x03f0); + assert_eq!(windows_virtual_terminal_input_mode(0x03f0), 0x03f0); +} + +struct EnvVarsRemovedGuard { + previous: Vec<(&'static str, Option<OsString>)>, +} + +impl EnvVarsRemovedGuard { + fn new(keys: &[&'static str]) -> Self { + let previous: Vec<_> = keys + .iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(); + for key in keys { + std::env::remove_var(key); + } + Self { previous } + } +} + +impl Drop for EnvVarsRemovedGuard { + fn drop(&mut self) { + for (key, value) in self.previous.clone() { + restore_env_var(key, value); + } + } +} + +#[test] +fn remote_client_uses_extended_handshake_timeout() { + let _guard = env_lock().lock().unwrap(); + let _remote = EnvVarGuard::set(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR, "local"); + + assert_eq!(handshake_read_timeout(), REMOTE_HANDSHAKE_READ_TIMEOUT); +} + +#[test] +fn host_cursor_policy_auto_uses_platform_default() { + assert_eq!( + should_draw_host_cursor(crate::config::HostCursorModeConfig::Auto), + crate::platform::should_draw_host_cursor_by_default() + ); +} + +#[test] +fn host_cursor_policy_native_and_drawn_override_auto_detection() { + let _guard = env_lock().lock().unwrap(); + let _env = EnvVarGuard::set("TERM_PROGRAM", "WezTerm"); + + assert!(!should_draw_host_cursor( + crate::config::HostCursorModeConfig::Native + )); + assert!(should_draw_host_cursor( + crate::config::HostCursorModeConfig::Drawn + )); +} + +#[cfg(unix)] +#[test] +fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() { + let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap(); + assert!(should_bridge_clipboard_image_paste( + &[0x16], + true, + Some(ctrl_v) + )); + assert!(should_bridge_clipboard_image_paste( + b"\x1b[118;5u", + true, + Some(ctrl_v) + )); + assert!(should_bridge_clipboard_image_paste( + b"\x1b[200~\x1b[201~", + true, + None + )); + assert!(!should_bridge_clipboard_image_paste( + b"\x1b[200~\x1b[201~", + false, + Some(ctrl_v) + )); + assert!(!should_bridge_clipboard_image_paste( + b"\x1b[200~text\x1b[201~", + true, + Some(ctrl_v) + )); + assert!(!should_bridge_clipboard_image_paste(&[0x16], true, None)); + assert!(!should_bridge_clipboard_image_paste( + b"v", + true, + Some(ctrl_v) + )); +} + +struct TempImageFile { + path: std::path::PathBuf, +} + +impl TempImageFile { + fn new(extension: &str, bytes: &[u8]) -> Self { + Self::with_name_fragment("test", extension, bytes) + } + + fn with_name_fragment(name_fragment: &str, extension: &str, bytes: &[u8]) -> Self { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "herdr-client-drop-{name_fragment}-{}-{nanos}.{extension}", + std::process::id() + )); + std::fs::write(&path, bytes).unwrap(); + Self { path } + } +} + +impl Drop for TempImageFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} +#[cfg(unix)] +#[test] +fn remote_image_file_drop_bridge_reads_bracketed_absolute_image_path() { + let file = TempImageFile::new("PNG", b"image-bytes"); + let input = format!("\x1b[200~{}\x1b[201~", file.path.display()); + + let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap(); + + assert_eq!(image.extension, "png"); + assert_eq!(image.bytes, b"image-bytes"); +} + +#[cfg(unix)] +#[test] +fn remote_image_file_drop_bridge_reads_plain_quoted_path_with_newline() { + let file = TempImageFile::new("jpeg", b"jpeg-bytes"); + let input = format!("'{}'\n", file.path.display()); + + let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap(); + + assert_eq!(image.extension, "jpg"); + assert_eq!(image.bytes, b"jpeg-bytes"); +} + +#[cfg(unix)] +#[test] +fn remote_image_file_drop_bridge_unescapes_spaces_in_paths() { + let file = TempImageFile::with_name_fragment("space test", "png", b"image-bytes"); + let escaped_path = file.path.display().to_string().replace(' ', "\\ "); + + let image = read_image_file_from_terminal_drop(escaped_path.as_bytes(), true).unwrap(); + + assert_eq!(image.extension, "png"); + assert_eq!(image.bytes, b"image-bytes"); +} + +#[cfg(unix)] +#[test] +fn remote_image_file_drop_bridge_ignores_non_remote_and_non_image_input() { + let file = TempImageFile::new("png", b"image-bytes"); + let path = file.path.display().to_string(); + + assert!(read_image_file_from_terminal_drop(path.as_bytes(), false).is_none()); + assert!(read_image_file_from_terminal_drop(b"relative.png\n", true).is_none()); + assert!(read_image_file_from_terminal_drop(b"/tmp/file.txt\n", true).is_none()); + assert!(read_image_file_from_terminal_drop( + format!("{}\nextra", file.path.display()).as_bytes(), + true + ) + .is_none()); +} + +#[test] +fn graphics_bytes_are_written_inside_synchronized_blit_with_saved_cursor() { + let mut output = Vec::new(); + write_encoded_frame_with_graphics( + &mut output, + b"\x1b[?2026htext\x1b[?2026lcursor", + b"graphics", + ) + .unwrap(); + + assert_eq!( + output, + b"\x1b[?2026htext\x1b7graphics\x1b8\x1b[?2026lcursor" + ); +} + +#[test] +fn empty_graphics_writes_only_blit_frame() { + let mut output = Vec::new(); + write_encoded_frame_with_graphics(&mut output, b"text", b"").unwrap(); + + assert_eq!(output, b"text"); +} + +#[test] +fn terminal_frame_kitty_detection_matches_apc_prefix() { + assert!(contains_kitty_graphics_bytes(b"text\x1b_Ga=p;\x1b\\")); + assert!(!contains_kitty_graphics_bytes(b"text\x1b[?2026h")); +} + +#[test] +fn kitty_graphics_image_id_parser_tracks_herdr_ids_only() { + let ids = kitty_graphics_image_ids( + b"text\x1b_Ga=t,t=d,f=32,s=1,v=1,i=10023,q=2;AAAA\x1b\\\x1b_Ga=p,i=10023,p=7;\x1b\\", + ); + assert_eq!(ids, vec![10023, 10023]); +} + +#[test] +fn kitty_graphics_cleanup_deletes_tracked_images_not_all_images() { + record_received_kitty_graphics(b"\x1b_Ga=t,i=123,q=2;AAAA\x1b\\"); + let mut output = Vec::new(); + clear_received_kitty_graphics(&mut output).unwrap(); + let text = String::from_utf8(output).unwrap(); + assert!(text.contains("a=d,d=I,i=123")); + assert!(!text.contains("d=A")); +} + +#[test] +fn write_host_terminal_appearance_query_emits_mode_2031_query() { + let mut output = Vec::new(); + write_host_terminal_appearance_query(&mut output).unwrap(); + assert_eq!(output, b"\x1b[?996n"); +} + +#[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_terminal_theme_query_sequence( + crate::platform::should_query_host_terminal_palette(), + ) + .as_bytes() + ); + assert!( + !output + .windows(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.len()) + .any(|window| window + == crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes()) + ); +} + +#[test] +fn write_host_color_scheme_report_mode_emits_mode_sequences() { + let mut output = Vec::new(); + write_host_color_scheme_report_mode(&mut output, true).unwrap(); + write_host_color_scheme_report_mode(&mut output, false).unwrap(); + + let mut expected = Vec::new(); + expected.extend_from_slice( + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE.as_bytes(), + ); + expected.extend_from_slice( + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), + ); + assert_eq!(output, expected); +} + +#[test] +fn color_scheme_change_event_requests_host_theme_query() { + let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); + + assert!(crate::raw_input::events_require_host_terminal_theme_query( + &events + )); +} + +#[test] +fn host_terminal_theme_query_is_disabled_on_windows() { + assert_eq!(should_query_host_terminal_theme(), !cfg!(windows)); +} + +#[test] +fn write_host_cell_size_query_emits_xtwinops_request() { + let mut output = Vec::new(); + write_host_cell_size_query(&mut output).unwrap(); + + assert_eq!(output, b"\x1b[16t"); +} + +#[test] +fn host_cell_size_query_is_disabled_on_windows() { + assert_eq!(should_query_host_cell_size(), !cfg!(windows)); +} + +#[test] +fn cell_size_fallback_prefers_reported_then_previous_size() { + assert_eq!(cell_size_fallback(0, None), (8, 16)); + assert_eq!(cell_size_fallback(0, Some((11, 22))), (11, 22)); + assert_eq!( + cell_size_fallback(pack_cell_size(10, 21), Some((11, 22))), + (10, 21) + ); + assert_eq!(cell_size_fallback(pack_cell_size(10, 0), None), (8, 16)); + assert_eq!(cell_size_fallback(pack_cell_size(0, 21), None), (8, 16)); +} + +#[test] +fn reported_cell_size_is_taken_from_host_cell_size_events() { + let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); + assert_eq!( + super::terminal_geometry::reported_cell_size_from_events(&events), + None + ); + + let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[6;21;10t\x1b[6;18;9t"); + assert_eq!( + super::terminal_geometry::reported_cell_size_from_events(&events), + Some((9, 18)) + ); +} + +#[test] +fn color_scheme_reports_are_enabled_only_for_full_clients() { + assert_eq!( + should_enable_host_color_scheme_reports(true), + !cfg!(windows) + ); + assert!(!should_enable_host_color_scheme_reports(false)); +} + +#[test] +fn terminal_restore_postlude_restores_visible_default_cursor() { + let mut output = Vec::new(); + write_terminal_restore_postlude(&mut output, false).unwrap(); + assert_eq!(output, b"\x1b[?25h\x1b[0 q"); +} + +#[test] +fn direct_attach_mouse_capture_combines_local_preference_with_child_demand() { + assert!(effective_mouse_capture(false, true)); + assert!(effective_mouse_capture(true, false)); + assert!(!effective_mouse_capture(false, false)); + assert!(effective_sgr_pixel_mouse(true, true, true)); + assert!(!effective_sgr_pixel_mouse(true, true, false)); +} + +#[test] +fn terminal_restore_postlude_disables_color_scheme_reports_when_enabled() { + let mut output = Vec::new(); + write_terminal_restore_postlude(&mut output, true).unwrap(); + + let mut expected = Vec::new(); + expected.extend_from_slice( + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), + ); + expected.extend_from_slice(b"\x1b[?25h\x1b[0 q"); + assert_eq!(output, expected); +} + +#[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_detached_default_session_reattach_hint() { + let _guard = env_lock().lock().unwrap(); + let _env = EnvVarsRemovedGuard::new(&[ + crate::remote::REATTACH_COMMAND_ENV_VAR, + crate::session::SESSION_ENV_VAR, + ]); + let err = ClientError::ServerShutdown { + reason: Some("detached".into()), + }; + let msg = err.to_string(); + assert!( + msg.contains("Run `herdr` to reattach"), + "should suggest default reattach command: {msg}" + ); +} + +#[test] +fn client_error_display_detached_named_session_reattach_hint() { + let _guard = env_lock().lock().unwrap(); + let _remote_env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]); + let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work"); + let err = ClientError::ServerShutdown { + reason: Some("detached".into()), + }; + let msg = err.to_string(); + assert!( + msg.contains("Run `herdr session attach work` to reattach"), + "should suggest named session reattach command: {msg}" + ); +} + +#[test] +fn client_error_display_detached_remote_reattach_hint_takes_precedence() { + let _guard = env_lock().lock().unwrap(); + let _remote_env = EnvVarGuard::set( + crate::remote::REATTACH_COMMAND_ENV_VAR, + "herdr --remote host --session work", + ); + let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work"); + let err = ClientError::ServerShutdown { + reason: Some("detached".into()), + }; + let msg = err.to_string(); + assert!( + msg.contains("Run `herdr --remote host --session work` to reattach"), + "should prefer remote reattach command: {msg}" + ); +} + +#[test] +fn client_error_display_connection_lost() { + let _guard = env_lock().lock().unwrap(); + let _env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]); + 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 client_error_display_remote_connection_lost_has_reattach_hint() { + let _guard = env_lock().lock().unwrap(); + let _remote_env = EnvVarGuard::set( + crate::remote::REATTACH_COMMAND_ENV_VAR, + "herdr --remote host --session work", + ); + let err = ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")); + let msg = err.to_string(); + assert!( + msg.contains("lost connection to remote Herdr"), + "should mention remote connection loss: {msg}" + ); + assert!( + msg.contains("panes may still be running"), + "should explain possible persistence: {msg}" + ); + assert!( + msg.contains("Run `herdr --remote host --session work` to reattach"), + "should show remote reattach command: {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 reload_local_client_config_refreshes_local_client_presentation_state() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let path = std::env::temp_dir().join(format!( + "herdr-client-config-reload-{}-{}.toml", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write( + &path, + "[ui]\nredraw_on_focus_gained = false\nhost_cursor = \"drawn\"\nmouse_capture = false\n", + ) + .unwrap(); + let path_string = path.to_string_lossy().to_string(); + let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string); + let mut sound_config = crate::config::SoundConfig::default(); + let mut redraw_on_focus_gained = true; + let mut draw_host_cursor = false; + let mut remote_image_paste_key = None; + let mut mouse_capture = true; + + reload_local_client_config( + &mut sound_config, + &mut redraw_on_focus_gained, + &mut draw_host_cursor, + &mut remote_image_paste_key, + &mut mouse_capture, + ); + + assert!(!redraw_on_focus_gained); + assert!(draw_host_cursor); + assert!(!mouse_capture); + let _ = std::fs::remove_file(path); +} + +#[test] +fn reload_local_client_config_keeps_ui_preferences_when_ui_is_invalid() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let path = std::env::temp_dir().join(format!( + "herdr-client-invalid-ui-reload-{}-{}.toml", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, "[ui]\nmouse_capture = \"invalid\"\n").unwrap(); + let path_string = path.to_string_lossy().to_string(); + let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string); + let mut sound_config = crate::config::SoundConfig::default(); + let mut redraw_on_focus_gained = false; + let mut draw_host_cursor = true; + let mut remote_image_paste_key = None; + let mut mouse_capture = false; + + reload_local_client_config( + &mut sound_config, + &mut redraw_on_focus_gained, + &mut draw_host_cursor, + &mut remote_image_paste_key, + &mut mouse_capture, + ); + + assert!(!mouse_capture); + assert!(!redraw_on_focus_gained); + assert!(draw_host_cursor); + let _ = std::fs::remove_file(path); +} + +#[test] +fn toast_notify_from_server_is_emitted_even_when_attach_config_was_off() { + let sound_config = crate::config::SoundConfig::default(); + let mut emitted = None; + + handle_notify_with_notifiers( + NotifyKind::Toast, + "pi finished", + Some("workspace 1"), + &sound_config, + |title, body| { + emitted = Some((title.to_string(), body.map(str::to_string))); + Ok(true) + }, + |_, _| Ok(false), + ); + + assert_eq!( + emitted, + Some(("pi finished".to_string(), Some("workspace 1".to_string()))) + ); +} + +#[test] +fn system_toast_notify_from_server_uses_system_notifier() { + let sound_config = crate::config::SoundConfig::default(); + let mut emitted = None; + + handle_notify_with_notifiers( + NotifyKind::SystemToast, + "pi finished", + Some("workspace 1"), + &sound_config, + |_, _| Ok(false), + |title, body| { + emitted = Some((title.to_string(), body.map(str::to_string))); + Ok(true) + }, + ); + + assert_eq!( + emitted, + Some(("pi finished".to_string(), Some("workspace 1".to_string()))) + ); +} + +#[test] +fn system_toast_notify_preserves_colon_in_title() { + let sound_config = crate::config::SoundConfig::default(); + let mut emitted = None; + + handle_notify_with_notifiers( + NotifyKind::SystemToast, + "build: failed", + Some("api workspace"), + &sound_config, + |_, _| Ok(false), + |title, body| { + emitted = Some((title.to_string(), body.map(str::to_string))); + Ok(true) + }, + ); + + assert_eq!( + emitted, + Some(( + "build: failed".to_string(), + Some("api workspace".to_string()) + )) + ); +} + +#[test] +fn decode_clipboard_payload_decodes_base64() { + assert_eq!(decode_clipboard_payload("dGVzdA=="), Some(b"test".to_vec())); +} + +#[test] +fn ioctl_cell_size_accepts_fractional_terminal_geometry() { + assert_eq!(ioctl_cell_size(80, 24, 800, 480), Some((10, 20))); + assert_eq!(ioctl_cell_size(80, 24, 805, 480), Some((10, 20))); + assert_eq!(ioctl_cell_size(80, 24, 800, 485), Some((10, 20))); + assert_eq!(ioctl_cell_size(80, 24, 0, 485), None); +} + +#[test] +fn decode_clipboard_payload_rejects_invalid_base64() { + assert_eq!(decode_clipboard_payload("not-base64!!!"), None); +} + +#[test] +fn terminal_control_input_command_accepts_text() { + let action = + terminal_control_command_from_json(r#"{"type":"terminal.input","text":"hello"}"#).unwrap(); + let ClientMessage::Input { data } = action else { + panic!("expected input command"); + }; + assert_eq!(data, b"hello"); +} + +#[test] +fn terminal_control_input_command_accepts_base64_bytes() { + let action = + terminal_control_command_from_json(r#"{"type":"terminal.input","bytes":"G1tB"}"#).unwrap(); + let ClientMessage::Input { data } = action else { + panic!("expected input command"); + }; + assert_eq!(data, b"\x1b[A"); +} + +#[test] +fn terminal_control_resize_command_maps_to_client_resize() { + let action = terminal_control_command_from_json( + r#"{"type":"terminal.resize","cols":100,"rows":30,"cell_width_px":8,"cell_height_px":16}"#, + ) + .unwrap(); + let ClientMessage::Resize { + cols, + rows, + cell_width_px, + cell_height_px, + pixel_mouse, + } = action + else { + panic!("expected resize command"); + }; + assert_eq!( + (cols, rows, cell_width_px, cell_height_px), + (100, 30, 8, 16) + ); + assert!(!pixel_mouse); +} + +#[test] +fn terminal_control_scroll_command_maps_to_attach_scroll() { + let action = terminal_control_command_from_json( + r#"{"type":"terminal.scroll","direction":"up","lines":3}"#, + ) + .unwrap(); + let ClientMessage::AttachScroll { + source, + direction, + lines, + .. + } = action + else { + panic!("expected scroll command"); + }; + assert_eq!(source, AttachScrollSource::Wheel); + assert_eq!(direction, AttachScrollDirection::Up); + assert_eq!(lines, 3); +} + +#[test] +fn forward_clipboard_uses_local_clipboard_path() { + unsafe { + std::env::set_var("SSH_CONNECTION", "1 2 3 4"); + } + assert!(forward_clipboard("dGVzdA==")); + assert!(!forward_clipboard("not base64")); + unsafe { + std::env::remove_var("SSH_CONNECTION"); + } +} diff --git a/src/client/timer.rs b/src/client/timer.rs new file mode 100644 index 00000000..bd5c249a --- /dev/null +++ b/src/client/timer.rs @@ -0,0 +1,78 @@ +use std::time::{Duration, Instant}; + +pub(super) struct ClientLoopTimer { + deadline: Option<Instant>, +} + +impl ClientLoopTimer { + pub(super) fn new() -> Self { + Self { deadline: None } + } + + pub(super) fn deadline(&mut self, now: Instant, delay: Duration) -> Instant { + let requested = now.checked_add(delay).unwrap_or(now); + let deadline = self + .deadline + .map_or(requested, |current| current.min(requested)); + self.deadline = Some(deadline); + deadline + } + + pub(super) fn fired(&mut self) { + self.deadline = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn incoming_events_do_not_postpone_timer_deadline() { + let start = Instant::now(); + let delay = Duration::from_millis(100); + let mut timer = ClientLoopTimer::new(); + + let first_deadline = timer.deadline(start, delay); + assert_eq!( + timer.deadline(start + Duration::from_millis(25), delay), + first_deadline + ); + assert_eq!( + timer.deadline(start + Duration::from_millis(50), delay), + first_deadline + ); + assert_eq!( + timer.deadline(start + Duration::from_millis(75), delay), + first_deadline + ); + + timer.fired(); + assert_eq!( + timer.deadline(first_deadline, delay), + first_deadline + delay + ); + } + + #[test] + fn earlier_client_work_can_pull_the_timer_deadline_forward() { + let start = Instant::now(); + let mut timer = ClientLoopTimer::new(); + + assert_eq!( + timer.deadline(start, Duration::from_millis(100)), + start + Duration::from_millis(100) + ); + assert_eq!( + timer.deadline(start + Duration::from_millis(20), Duration::from_millis(10)), + start + Duration::from_millis(30) + ); + assert_eq!( + timer.deadline( + start + Duration::from_millis(25), + Duration::from_millis(100) + ), + start + Duration::from_millis(30) + ); + } +} |
