aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/input.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/input.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/input.rs')
-rw-r--r--src/client/shell/input.rs1011
1 files changed, 1011 insertions, 0 deletions
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);
+ }
+ }
+}