aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/tests/popup_focus_projection.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/tests/popup_focus_projection.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/client/shell/tests/popup_focus_projection.rs')
-rw-r--r--src/client/shell/tests/popup_focus_projection.rs1226
1 files changed, 1226 insertions, 0 deletions
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);
+}