aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
Diffstat (limited to 'src/client')
-rw-r--r--src/client/attach.rs582
-rw-r--r--src/client/clipboard_images.rs248
-rw-r--r--src/client/endpoint_commands.rs224
-rw-r--r--src/client/errors.rs99
-rw-r--r--src/client/frame_output.rs97
-rw-r--r--src/client/handshake.rs189
-rw-r--r--src/client/mod.rs3829
-rw-r--r--src/client/notifications.rs102
-rw-r--r--src/client/shell.rs287
-rw-r--r--src/client/shell/actions.rs1051
-rw-r--r--src/client/shell/agent_sidebar.rs343
-rw-r--r--src/client/shell/composition.rs539
-rw-r--r--src/client/shell/config.rs503
-rw-r--r--src/client/shell/context_menu.rs469
-rw-r--r--src/client/shell/copy_mode.rs871
-rw-r--r--src/client/shell/global_menu.rs110
-rw-r--r--src/client/shell/graphics.rs67
-rw-r--r--src/client/shell/input.rs1011
-rw-r--r--src/client/shell/mobile.rs999
-rw-r--r--src/client/shell/mouse.rs2304
-rw-r--r--src/client/shell/notifications.rs418
-rw-r--r--src/client/shell/overlay_input.rs1091
-rw-r--r--src/client/shell/overlays.rs1169
-rw-r--r--src/client/shell/preferences.rs132
-rw-r--r--src/client/shell/render.rs313
-rw-r--r--src/client/shell/scroll.rs98
-rw-r--r--src/client/shell/settings.rs385
-rw-r--r--src/client/shell/settings_overlay.rs414
-rw-r--r--src/client/shell/sidebar.rs698
-rw-r--r--src/client/shell/state.rs1613
-rw-r--r--src/client/shell/surface_patch.rs266
-rw-r--r--src/client/shell/tabs.rs386
-rw-r--r--src/client/shell/tests/agents_worktrees_notifications.rs1248
-rw-r--r--src/client/shell/tests/chrome_context.rs339
-rw-r--r--src/client/shell/tests/copy.rs1190
-rw-r--r--src/client/shell/tests/input.rs609
-rw-r--r--src/client/shell/tests/keybindings_settings.rs633
-rw-r--r--src/client/shell/tests/mobile.rs559
-rw-r--r--src/client/shell/tests/mod.rs220
-rw-r--r--src/client/shell/tests/mouse_selection.rs686
-rw-r--r--src/client/shell/tests/popup_focus_projection.rs1226
-rw-r--r--src/client/shell/tests/startup_overlays.rs1259
-rw-r--r--src/client/shell/worktree_overlays.rs410
-rw-r--r--src/client/shell/worktrees.rs545
-rw-r--r--src/client/terminal_geometry.rs238
-rw-r--r--src/client/terminal_sessions.rs265
-rw-r--r--src/client/terminal_setup.rs445
-rw-r--r--src/client/tests/mod.rs823
-rw-r--r--src/client/timer.rs78
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(&current),
+ 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(
+ &current_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(
+ &current_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 = &notification.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 = &notification.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, &notification, 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,
+ &notification(),
+ 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, &current.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!(
+ &copy.actions[..],
+ [ClientShellAction::Endpoint { request, .. }]
+ if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_))
+ ));
+ assert!(copy.requests.is_empty());
+ let request_id = match &copy.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",
+ &copy_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 = &notes_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(
+ &notes,
+ "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)
+ );
+ }
+}